Files
gain/app.py
2025-07-08 15:27:03 +08:00

217 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import streamlit as st
import time
from pathlib import Path
import json
# --- 本地模組匯入 ---
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
# --- 匯入外部處理腳本 ---
# 雖然這些腳本主要在 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
# --- 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:
st.session_state.operation_status = {}
if 'search_query' not in st.session_state:
st.session_state.search_query = ""
if 'search_results' not in st.session_state:
st.session_state.search_results = []
if 'selected_videos' not in st.session_state:
st.session_state.selected_videos = {}
if 'active_preview_id' not in st.session_state:
st.session_state.active_preview_id = None
# --- UI 介面 ---
st.title("🎬 英語影片自動化工作流程")
display_global_status_message()
# --- 側邊欄 ---
with st.sidebar:
st.header("API & Project Control")
notion_api_key = st.secrets.get("NOTION_API_KEY")
notion_database_id = st.secrets.get("NOTION_DATABASE_ID")
google_creds_for_translate_path = st.secrets.get("GOOGLE_CREDS_TRANSLATE_PATH")
google_creds_for_TTS_path = st.secrets.get("GOOGLE_CREDS_TTS_PATH")
st.divider()
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="選擇一個頁面...")
if st.session_state.selected_title:
st.button(f"'{st.session_state.selected_title}' 建立專案", on_click=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
st.selectbox(
"或選擇一個現有專案:",
options=[""] + existing_projects,
index=selected_project_idx,
key="project_selector",
on_change=callback_set_project,
help="選擇您已經建立的專案。"
)
# --- 主畫面 ---
if not st.session_state.current_project:
st.info("👈 請在側邊欄建立新專案或選擇一個現有專案以開始。")
else:
paths = get_project_paths(st.session_state.current_project)
project_path = paths["root"]
st.header(f"目前專案:`{st.session_state.current_project}`")
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()
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)
elif active_tab == tab_names[3]:
video_management_fragment(paths)
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")
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.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)
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
)