version 1

This commit is contained in:
2025-07-15 14:11:39 +08:00
parent 81d4874926
commit dc94cc41c2
16 changed files with 1872 additions and 445 deletions

258
app.py
View File

@ -3,31 +3,29 @@ import time
from pathlib import Path
import json
from ui_fragments import tab0_data_processing,tab1_asset_generation,tab2_online_video_search,tab3_video_composition
# --- 本地模組匯入 ---
from utils.paths import get_project_paths, SHARED_ASSETS_DIR, PROJECTS_DIR
from utils.callbacks import callback_set_project, callback_create_project, callback_run_step,callback_assemble_final_video
from utils.helpers import get_notion_page_titles, display_global_status_message
from ui_fragments.audio_manager import audio_management_fragment
from ui_fragments.video_manager import video_management_fragment
from ui_fragments.video_search import video_search_fragment
from utils.paths import get_project_paths, SHARED_ASSETS_DIR, PROJECTS_DIR,get_project_list
import callbacks
from utils.helpers import get_notion_page_titles, display_operation_status
# from ui_fragments.audio_manager import audio_management_fragment
# from ui_fragments.video_manager import video_management_fragment
# from ui_fragments.video_search import video_search_fragment
# --- 匯入外部處理腳本 ---
# 雖然這些腳本主要在 callbacks 中被呼叫,但在此處匯入有助於理解全貌
from scripts.step1_notion_sync import update_project_from_notion
from scripts.step2_translate_ipa import run_step2_translate_ipa
from scripts.step3_generate_audio import run_step3_generate_audio
from scripts.step4_concatenate_audio import run_step4_concatenate_audio
from scripts.step5_generate_ass import run_step5_generate_ass
from scripts.step6_assemble_video import run_step6_assemble_video
# from scripts.step1_notion_sync import update_project_from_notion
# from scripts.step2_translate_ipa import run_step2_translate_ipa
# from scripts.step3_generate_audio import run_step3_generate_audio
# from scripts.step4_concatenate_audio import run_step4_concatenate_audio
# from scripts.step5_generate_ass import run_step5_generate_ass
# from scripts.step6_assemble_video import run_step6_assemble_video
# --- Streamlit UI 設定 ---
st.set_page_config(layout="wide", page_title="英語影片自動化工作流程")
# --- 狀態初始化 ---
if 'current_project' not in st.session_state:
st.session_state.current_project = None
if 'final_video_path' not in st.session_state:
st.session_state.final_video_path = None
if 'show_video' not in st.session_state:
st.session_state.show_video = False
if 'operation_status' not in st.session_state:
@ -41,9 +39,14 @@ if 'selected_videos' not in st.session_state:
if 'active_preview_id' not in st.session_state:
st.session_state.active_preview_id = None
if 'project' not in st.session_state:
st.session_state.project = None # 'project' 現在儲存的是 Project 物件或 None
if 'project_selector' not in st.session_state:
st.session_state.project_selector = None # 'project_selector' 用來追蹤 selectbox 的狀態
# --- UI 介面 ---
st.title("🎬 英語影片自動化工作流程")
display_global_status_message()
display_operation_status()
# --- 側邊欄 ---
with st.sidebar:
st.header("API & Project Control")
@ -56,161 +59,126 @@ with st.sidebar:
st.header("1. 建立新專案")
try:
if notion_api_key and notion_database_id:
with st.spinner("載入 Notion 頁面..."):
page_titles_map = get_notion_page_titles(notion_api_key, notion_database_id)
st.session_state.page_titles_map = page_titles_map
st.selectbox("選擇一個 Notion 頁面來建立專案:", options=[""] + list(page_titles_map.keys()), index=0, key="selected_title", placeholder="選擇一個頁面...")
page_titles_map = get_notion_page_titles(notion_api_key, notion_database_id)
st.session_state.page_titles_map = page_titles_map
st.selectbox("選擇 Notion 頁面以建立新專案:",
options=[""] + list(page_titles_map.keys()),
key="selected_title")
if st.session_state.selected_title:
st.button(f"'{st.session_state.selected_title}' 建立專案", on_click=callback_create_project)
st.button(f"'{st.session_state.selected_title}' 建立專案",
on_click=callbacks.callback_create_project) # 綁定到標準回呼
else:
st.warning("請在 Streamlit secrets 中設定 Notion API 金鑰和資料庫 ID。")
except Exception as e:
st.error(f"無法載入 Notion 頁面: {e}")
st.divider()
st.header("2. 選擇現有專案")
existing_projects = [p.name for p in PROJECTS_DIR.iterdir() if p.is_dir()]
selected_project_idx = existing_projects.index(st.session_state.current_project) + 1 if st.session_state.current_project in existing_projects else 0
# 1. 介面邏輯變得極簡:直接呼叫工具函式
existing_projects = get_project_list()
# 2. UI 只負責綁定 key 和 on_change 事件
st.selectbox(
"或選擇一個現有專案:",
options=[""] + existing_projects,
index=selected_project_idx,
key="project_selector",
on_change=callback_set_project,
key="project_selector", # selectbox 的狀態由 st.session_state.project_selector 控制
on_change=callbacks.callback_set_project, # 綁定到標準回呼
help="選擇您已經建立的專案。"
)
# --- 主畫面 ---# 1. 從 session_state 中獲取 Project 物件。這是與專案互動的唯一入口。
project = st.session_state.get('project')
# --- 主畫面 ---
if not st.session_state.current_project:
# 2. 判斷的依據變成了 project 物件是否存在,而不是一個字串。
if not project:
# 如果沒有專案,提示使用者。這部分的邏輯是完美的。
st.info("👈 請在側邊欄建立新專案或選擇一個現有專案以開始。")
else:
paths = get_project_paths(st.session_state.current_project)
project_path = paths["root"]
st.header(f"目前專案:`{project.name}`")
st.header(f"目前專案:`{st.session_state.current_project}`")
tab_names = ["1. 資料處理 & AI 加註", "2. 素材生成", " 線上素材搜尋", "3. 影片合成"]
active_tab = st.radio("選擇工作流程步驟:",
options=tab_names,
key="main_tabs_radio",
horizontal=True,
label_visibility="collapsed")
st.markdown("---")
tab_names = ["1. 資料處理 & AI 加註", "2. 素材生成", "2.5. 線上素材搜尋", "3. 影片合成"]
active_tab = st.radio("選擇工作流程步驟:", options=tab_names, key="main_tabs_radio", horizontal=True, label_visibility="collapsed")
st.markdown("---")
# --- 分頁內容 ---
# # --- 分頁內容 ---
if active_tab == tab_names[0]:
data_file = paths["data"]
if data_file.exists():
with st.container():
col1, col2 = st.columns([3, 1])
with col1:
st.subheader("專案資料")
with col2:
st.button("🔄 從 Notion 同步更新", on_click=callback_run_step, args=(update_project_from_notion,), kwargs={"source": "sync_notion", "spinner_text": "正在同步...", "api_key": notion_api_key, "project_path": project_path}, help="從 Notion 抓取此專案的最新資料並覆寫本地檔案。")
with st.expander("預覽專案資料 (data.json)", expanded=False):
st.json(json.loads(data_file.read_text(encoding="utf-8")))
st.divider()
st.subheader("AI 自動加註")
st.button("2. 添加翻譯與 IPA 音標", on_click=callback_run_step, args=(run_step2_translate_ipa,), kwargs={"source": "run_translate", "spinner_text": "正在調用 AI...", "project_path": project_path, "google_creds_path": google_creds_for_translate_path, "notion_api_key": notion_api_key, "notion_database_id": notion_database_id}, disabled=not google_creds_for_translate_path)
if not google_creds_for_translate_path:
st.warning("請在 secrets.toml 中提供 Google Cloud 翻譯認證檔案的路徑。")
else:
st.warning("找不到 data.json 檔案。請先從 Notion 同步。")
st.divider()
st.info("✍️ **人工檢查點**\n\n1. 請檢查 `Notion` 中的內容是否準確。\n2. 修改完成後,**請務必點擊上方的「從 Notion 同步更新」按鈕**。")
elif active_tab == tab_names[1]:
with st.container(border=True):
st.subheader("步驟 3.1: 生成單句音訊")
st.button("執行生成", on_click=callback_run_step, args=(run_step3_generate_audio,), kwargs={"source": "gen_audio", "spinner_text": "正在生成音訊...", "project_path": project_path, "google_creds_path": google_creds_for_TTS_path}, help="根據 data.json 中的每一句英文,生成對應的 .wav 音訊檔並存放在 audio 資料夾。")
st.divider()
tab0_data_processing.render_tab(project)
st.markdown("##### 步驟 3.2: 組合完整音訊")
audio_folder, single_audio_exists = paths["audio"], False
if audio_folder.exists() and any(audio_folder.iterdir()):
single_audio_exists = True
if not single_audio_exists:
st.info("請先執行步驟 3.1 以生成單句音訊。")
st.button("執行組合", on_click=callback_run_step, args=(run_step4_concatenate_audio,), kwargs={"source": "concat_audio", "spinner_text": "正在組合音訊...", "project_path": project_path}, help="將 audio 資料夾中所有的 .wav 檔,按順序組合成一個名為 combined_audio.wav 的檔案。", disabled=not single_audio_exists)
combined_audio_path = paths["combined_audio"]
if combined_audio_path.exists():
st.success("🎉 完整音訊已組合成功!")
st.audio(str(combined_audio_path))
st.divider()
with st.container(border=True):
st.subheader("步驟 4: 生成 ASS 字幕檔")
if not single_audio_exists:
st.info("請先執行步驟 3.1 以生成字幕所需的時間戳。")
st.button("📝 生成 .ass 字幕檔", on_click=callback_run_step, args=(run_step5_generate_ass,), kwargs={"source": "gen_ass", "spinner_text": "正在生成字幕檔...", "project_path": project_path}, disabled=not single_audio_exists)
st.divider()
audio_management_fragment(paths)
elif active_tab == tab_names[2]:
video_search_fragment(paths)
if active_tab == tab_names[1]:
tab1_asset_generation.render_tab(project, callbacks)
elif active_tab == tab_names[3]:
video_management_fragment(paths)
if active_tab == tab_names[2]:
tab2_online_video_search.render_tab(project,callbacks)
if active_tab == tab_names[3]:
tab3_video_composition.render_tab(project, callbacks)
st.divider()
st.subheader("步驟 6.1: 管理與選擇共享影片素材")
shared_videos = [""] + [f.name for f in sorted(SHARED_ASSETS_DIR.glob('*.mp4'))] + [f.name for f in sorted(SHARED_ASSETS_DIR.glob('*.mov'))]
with st.container(border=True):
st.markdown("##### 從共享素材庫中選擇影片")
c1, c2 = st.columns(2)
with c1:
logo_selection = st.selectbox("選擇 Logo 影片:", shared_videos, key="logo_select")
open_selection = st.selectbox("選擇開場影片:", shared_videos, key="open_select")
with c2:
end_selection = st.selectbox("選擇結尾影片:", shared_videos, key="end_select")
# st.subheader("步驟 6.1: 管理與選擇共享影片素材")
# shared_videos = [""] + [f.name for f in sorted(SHARED_ASSETS_DIR.glob('*.mp4'))] + [f.name for f in sorted(SHARED_ASSETS_DIR.glob('*.mov'))]
# with st.container(border=True):
# st.markdown("##### 從共享素材庫中選擇影片")
# c1, c2 = st.columns(2)
# with c1:
# logo_selection = st.selectbox("選擇 Logo 影片:", shared_videos, key="logo_select")
# open_selection = st.selectbox("選擇開場影片:", shared_videos, key="open_select")
# with c2:
# end_selection = st.selectbox("選擇結尾影片:", shared_videos, key="end_select")
with st.expander("**上傳新的共享素材**"):
st.markdown("上傳的影片將存入 `shared_assets` 公共資料夾,可供所有專案重複使用。")
uploaded_files = st.file_uploader("上傳新的影片到共享素材庫", type=["mp4", "mov"], accept_multiple_files=True, label_visibility="collapsed")
if uploaded_files:
for uploaded_file in uploaded_files:
with open(SHARED_ASSETS_DIR / uploaded_file.name, "wb") as f:
f.write(uploaded_file.getbuffer())
st.success(f"成功上傳 {len(uploaded_files)} 個檔案到共享素材庫!")
time.sleep(1)
st.rerun()
# with st.expander("**上傳新的共享素材**"):
# st.markdown("上傳的影片將存入 `shared_assets` 公共資料夾,可供所有專案重複使用。")
# uploaded_files = st.file_uploader("上傳新的影片到共享素材庫", type=["mp4", "mov"], accept_multiple_files=True, label_visibility="collapsed")
# if uploaded_files:
# for uploaded_file in uploaded_files:
# with open(SHARED_ASSETS_DIR / uploaded_file.name, "wb") as f:
# f.write(uploaded_file.getbuffer())
# st.success(f"成功上傳 {len(uploaded_files)} 個檔案到共享素材庫!")
# time.sleep(1)
# st.rerun()
st.divider()
st.subheader("步驟 6.2: 執行最終影片合成")
all_videos_selected = all([logo_selection, open_selection, end_selection])
if not all_videos_selected:
st.info("請從上方的下拉選單中選擇所有四個影片以啟用合成按鈕。")
# st.divider()
# st.subheader("步驟 6.2: 執行最終影片合成")
# all_videos_selected = all([logo_selection, open_selection, end_selection])
# if not all_videos_selected:
# st.info("請從上方的下拉選單中選擇所有四個影片以啟用合成按鈕。")
st.button(
"🎬 合成最終影片",
on_click=callback_assemble_final_video,
kwargs={
"paths": paths,
"source": "assemble_video",
"spinner_text": "影片合成中,請稍候...",
"project_path": paths['root'], # 傳遞給 run_step6_assemble_video
"logo_video": SHARED_ASSETS_DIR / logo_selection if logo_selection else None,
"open_video": SHARED_ASSETS_DIR / open_selection if open_selection else None,
"end_video": SHARED_ASSETS_DIR / end_selection if end_selection else None
},
disabled=not all_videos_selected
)
# st.button(
# "🎬 合成最終影片",
# on_click=callback_assemble_final_video,
# kwargs={
# "paths": paths,
# "source": "assemble_video",
# "spinner_text": "影片合成中,請稍候...",
# "project_path": paths['root'], # 傳遞給 run_step6_assemble_video
# "logo_video": SHARED_ASSETS_DIR / logo_selection if logo_selection else None,
# "open_video": SHARED_ASSETS_DIR / open_selection if open_selection else None,
# "end_video": SHARED_ASSETS_DIR / end_selection if end_selection else None
# },
# disabled=not all_videos_selected
# )
if st.session_state.final_video_path and Path(st.session_state.final_video_path).exists():
st.divider()
st.header("🎉 影片製作完成!")
st.checkbox("顯示/隱藏影片", key="show_video", value=True)
if st.session_state.show_video:
st.video(st.session_state.final_video_path)
# if st.session_state.final_video_path and Path(st.session_state.final_video_path).exists():
# st.divider()
# st.header("🎉 影片製作完成!")
# st.checkbox("顯示/隱藏影片", key="show_video", value=True)
# if st.session_state.show_video:
# st.video(st.session_state.final_video_path)
with open(st.session_state.final_video_path, "rb") as file:
st.download_button(
"📥 下載專案影片",
data=file,
file_name=Path(st.session_state.final_video_path).name,
mime="video/mp4",
use_container_width=True
)
# with open(st.session_state.final_video_path, "rb") as file:
# st.download_button(
# "📥 下載專案影片",
# data=file,
# file_name=Path(st.session_state.final_video_path).name,
# mime="video/mp4",
# use_container_width=True
# )