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

View File

@ -1,5 +1,5 @@
import streamlit as st
from utils.callbacks import toggle_all_audio_checkboxes, callback_delete_selected_audios
from callbacks import toggle_all_audio_checkboxes, callback_delete_selected_audios
@st.fragment
def audio_management_fragment(paths: dict):

View File

@ -0,0 +1,38 @@
# ui_fragments/tab_data_processing.py
import streamlit as st
import callbacks
from project_model import Project # 引入 Project 以便進行型別註記
@st.fragment
def render_tab(project: Project):
"""
負責顯示「1. 資料處理 & AI 加註」Tab 的所有 UI 元件和邏輯。
"""
# 根據 project.data 的狀態來決定顯示哪個 UI
if project.data:
# --- 狀態一:專案「已同步」---
st.session_state.operation_status = {
"success": True,
"message": "✅ 專案資料已就緒。"
}
with st.container(border=True):
col1, col2 = st.columns([3, 1])
with col1:
st.subheader("專案資料與 AI 加註")
with col2:
st.button("🔄 從 Notion 同步更新", on_click=callbacks.callback_sync_notion)
with st.expander("預覽專案資料", expanded=False):
st.json(project.data)
st.divider()
st.subheader("AI 自動加註")
# ... (AI 加註的按鈕和邏輯) ...
st.button("執行 AI 翻譯與音標加註並寫回 Notion", on_click=callbacks.callback_add_zh_ipa)
else:
# --- 狀態二:專案「僅初始化」---
st.info(" 這個專案的框架已建立,請執行第一步來同步核心資料。")
st.button("🚀 步驟一:從 Notion 抓取專案資料", on_click=callbacks.callback_sync_notion, type="primary")
st.divider()
st.info("✍️ **人工檢查點**\n\n1. 請確保 `Notion` 中的內容是您想要的最終版本。\n2. 修改完成後,記得點擊上方的同步按鈕。")

View File

@ -0,0 +1,101 @@
# utils/tab1_asset_generation.py
import streamlit as st
@st.fragment
def render_tab(project, callbacks):
"""
渲染「素材生成」標籤頁的 UI。
這個函式負責處理所有與音效生成和字幕檔生成相關的介面元件。
Args:
project: 存放專案狀態和路徑的物件。
callbacks: 存放按鈕回呼函式的物件。
"""
# --- 音效生成區塊 ---
with st.container(border=True):
st.subheader("音效生成")
# 步驟 3.1
st.markdown("##### 步驟 3.1: 生成單句音訊")
st.button("執行生成", on_click=callbacks.callback_generate_sentence_audio)
st.divider()
# 步驟 3.2
st.markdown("##### 步驟 3.2: 組合完整音訊")
can_concatenate = project.has_sentence_audio()
if not can_concatenate:
st.info("請先執行步驟 3.1 以生成單句音訊。")
st.button("執行組合", on_click=callbacks.callback_concatenate_audio, disabled=not can_concatenate)
if project.has_combined_audio():
st.session_state.operation_status = {
"success": True,
"message": "🎉 完整音訊已組合成功!"
}
st.audio(str(project.paths['combined_audio']))
audio_management_fragment(project,callbacks)
st.divider()
# --- 字幕生成區塊 ---
with st.container(border=True):
st.subheader("步驟 4: 生成 ASS 字幕檔")
can_generate_ass = project.has_sentence_audio()
if not can_generate_ass:
st.info("請先執行步驟 3.1 以生成字幕所需的時間戳。")
st.button("📝 生成 .ass 字幕檔", on_click=callbacks.callback_generate_subtitles, disabled=not can_generate_ass)
st.divider()
@st.fragment
def audio_management_fragment(project,callbacks):
"""A self-contained fragment for managing and deleting project audio files."""
with st.expander("🎧 管理專案音訊素材"):
paths = project.paths
audio_dir = paths["audio"]
output_dir = paths["output"]
audio_files = []
if audio_dir.exists():
audio_files.extend(sorted(audio_dir.glob('*.wav')))
if output_dir.exists():
audio_files.extend(sorted(output_dir.glob('*.wav')))
if not audio_files:
st.info("專案的 `audio` 和 `output` 資料夾中目前沒有音訊檔。")
else:
all_selected = all(st.session_state.get(f"delete_audio_cb_{f.name}", False) for f in audio_files)
if st.session_state.get('select_all_audios', False) != all_selected:
st.session_state.select_all_audios = all_selected
st.markdown("勾選您想要刪除的音訊檔案,然後點擊下方的按鈕。")
st.checkbox(
"全選/取消全選",
key="select_all_audios",
on_change=callbacks.toggle_all_audio_checkboxes,
args=(audio_files,)
)
with st.form("delete_audios_form"):
for audio_file in audio_files:
file_size_kb = audio_file.stat().st_size / 1024
label = f"**{audio_file.parent.name}/{audio_file.name}** ({file_size_kb:.2f} KB)"
st.checkbox(label, key=f"delete_audio_cb_{audio_file.name}")
submitted = st.form_submit_button("🟥 確認刪除選取的音訊", use_container_width=True, type="primary")
if submitted:
callbacks.callback_delete_selected_audios(paths)
st.rerun(scope="fragment")

View File

@ -0,0 +1,142 @@
# utils/tab2_search_video.py
import streamlit as st
import os
from pathlib import Path
from utils.helpers import analyze_ass_for_keywords, search_pixabay_videos, search_pexels_videos
@st.fragment
def render_tab(project,callbacks):
"""一個用於從多個來源搜尋並下載影片的獨立UI片段。"""
paths = project.paths
st.header("步驟 5: 從線上圖庫搜尋影片素材")
pixabay_api_key = st.secrets.get("PIXABAY_API_KEY")
pexels_api_key = st.secrets.get("PEXELS_API_KEY")
if not pixabay_api_key and not pexels_api_key:
st.warning("請至少在 Streamlit secrets 中設定 `PIXABAY_API_KEY` 或 `PEXELS_API_KEY` 以使用此功能。")
return
st.subheader("5.1 搜尋影片")
# 【新增】影片來源選擇
available_sources = []
if pixabay_api_key: available_sources.append("Pixabay")
if pexels_api_key: available_sources.append("Pexels")
if not available_sources:
st.session_state.operation_status = {
"success": False,
"message": "沒有可用的影片來源 API 金鑰。"
}
return
source_choice = st.radio(
"選擇影片來源:",
options=available_sources,
horizontal=True,
key="video_source_choice"
)
keywords = analyze_ass_for_keywords(paths)
default_query = keywords[0] if keywords else ""
with st.form(key="search_form"):
col1, col2 = st.columns([4, 1])
with col1:
st.text_input(
"Search Videos",
value=default_query,
key="search_query_input",
placeholder="輸入關鍵字後按 Enter 或點擊右側按鈕搜尋",
label_visibility="collapsed"
)
with col2:
submitted = st.form_submit_button("🔍 搜尋影片", use_container_width=True)
if submitted:
with st.spinner("正在搜尋並過濾影片..."):
query = st.session_state.get("search_query_input", "")
# 【修改】根據選擇呼叫對應的 API
if source_choice == "Pixabay":
success, message, results = search_pixabay_videos(pixabay_api_key, query)
elif source_choice == "Pexels":
success, message, results = search_pexels_videos(pexels_api_key, query)
else:
success, message, results = False, "未知的影片來源", []
st.session_state.operation_status = {"success": success, "message": message, "source": "search_videos"}
# --- 【修改】資料標準化 ---
standardized_results = []
if success and results:
if source_choice == "Pixabay":
for v in results:
try:
standardized_results.append({
'id': f"pixabay-{v['id']}",
'thumbnail_url': v['videos']['tiny']['thumbnail'],
'video_url': v['videos']['large']['url'],
'preview_url': v['videos']['tiny']['url'],
'width': v['videos']['large']['width'],
'height': v['videos']['large']['height']
})
except KeyError: continue
elif source_choice == "Pexels":
for v in results:
try:
# 尋找合適的影片檔案連結
video_link_hd = next((f['link'] for f in v['video_files'] if f.get('quality') == 'hd'), None)
video_link_sd = next((f['link'] for f in v['video_files'] if f.get('quality') == 'sd'), None)
# 優先使用 HD 畫質,若無則用 SD再沒有就用第一個
final_video_url = video_link_hd or video_link_sd or v['video_files'][0]['link']
standardized_results.append({
'id': f"pexels-{v['id']}",
'thumbnail_url': v['image'],
'video_url': final_video_url,
'preview_url': video_link_sd or final_video_url, # 預覽用 SD 或更高畫質
'width': v['width'],
'height': v['height']
})
except (KeyError, IndexError): continue
st.session_state.search_results = standardized_results
st.session_state.selected_videos = {str(v['id']): {"url": v['video_url'], "selected": False} for v in standardized_results}
st.session_state.active_preview_id = None
if keywords and (keywords[1] or keywords[2]):
st.caption(f"建議關鍵字: `{keywords[1]}` `{keywords[2]}`")
st.divider()
if st.session_state.search_results:
st.subheader("5.2 選擇影片並下載")
st.button("📥 下載選取的影片到專案素材庫", on_click=callbacks.callback_download_videos, args=(paths,), help="將下方勾選的影片下載至目前專案的 `output/test` 資料夾,並自動循序命名。")
num_cols = 5
cols = st.columns(num_cols)
# 【修改】使用標準化後的結果進行渲染
for i, video in enumerate(st.session_state.search_results):
with cols[i % num_cols]:
with st.container(border=True):
media_placeholder = st.empty()
video_id_str = str(video['id'])
if st.session_state.active_preview_id == video_id_str:
media_placeholder.video(video['preview_url'])
preview_button_text, preview_button_type = "⏹️ 停止預覽", "secondary"
else:
media_placeholder.image(video['thumbnail_url'], use_container_width=True)
preview_button_text, preview_button_type = "▶️ 預覽", "primary"
st.caption(f"ID: {video_id_str}")
st.caption(f"尺寸: {video['width']}x{video['height']}")
is_selected = st.checkbox("選取", key=f"select_{video_id_str}", value=st.session_state.selected_videos.get(video_id_str, {}).get('selected', False))
if video_id_str in st.session_state.selected_videos:
st.session_state.selected_videos[video_id_str]['selected'] = is_selected
st.button(preview_button_text, key=f"preview_{video_id_str}", on_click=callbacks.callback_toggle_preview, args=(video_id_str,), use_container_width=True, type=preview_button_type)

View File

@ -0,0 +1,124 @@
import streamlit as st
from pathlib import Path
from config import SHARED_ASSETS_DIR
@st.fragment
def render_tab(project, callbacks):
"""渲染「最終影片合成」標籤頁的 UI。"""
_render_video_management_section(project, callbacks)
_render_shared_asset_uploader(callbacks)
_render_shared_asset_selection(callbacks)
st.divider()
@st.fragment
def _render_video_management_section(project, callbacks):
"""
一個私有的輔助函式,用於渲染影片管理的 UI 部分。
"""
with st.expander("🎬 管理專案影片素材", expanded=False):
paths = project.paths
output_dir = paths.get("output", Path("./output")) # 使用 .get 提供預設值
video_files = []
if output_dir.exists():
video_files = sorted(
[f for f in output_dir.iterdir() if f.suffix.lower() in ['.mp4', '.mov']],
key=lambda f: f.stat().st_mtime,
reverse=True
)
if not video_files:
st.info("專案輸出資料夾 (`/output`) 中目前沒有影片檔。")
else:
st.markdown("勾選您想要刪除的影片,然後點擊下方的按鈕。")
st.checkbox(
"全選/取消全選",
key="select_all_videos",
on_change=callbacks.toggle_all_video_checkboxes,
args=(video_files,)
)
for video_file in video_files:
file_size_mb = video_file.stat().st_size / (1024 * 1024)
st.checkbox(
f"**{video_file.name}** ({file_size_mb:.2f} MB)",
key=f"delete_cb_{video_file.name}"
)
st.button(
"🟥 確認刪除選取的影片",
on_click=callbacks.delete_selected_videos,
use_container_width=True,
type="primary"
)
def _render_shared_asset_selection(callbacks):
"""
一個私有的輔助函式,用於渲染共享素材選擇和上傳的 UI。
"""
st.subheader("步驟 3.2: 選擇共享影片素材")
st.subheader("輔助工具")
# 【改動 1】更健壯的檔案列表讀取方式
try:
shared_videos = [""] + sorted(
[f.name for f in SHARED_ASSETS_DIR.glob('*') if f.suffix.lower() in ['.mp4', '.mov']]
)
except Exception as e:
st.error(f"無法讀取共享素材庫 '{SHARED_ASSETS_DIR}': {e}")
shared_videos = [""]
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("步驟 3.2: 執行最終影片合成")
all_videos_selected = all([logo_selection, open_selection, end_selection])
if not all_videos_selected:
st.info("請從上方的下拉選單中選擇所有 Logo、開場和結尾影片以啟用合成按鈕。")
# 【核心改動】st.button 的呼叫現在非常簡潔
st.button(
"🎬 合成最終影片",
on_click=callbacks.assemble_final_video,
kwargs={
# 只傳遞 UI 直接相關的、最少的資訊 (檔名)
"logo_video_name": logo_selection,
"open_video_name": open_selection,
"end_video_name": end_selection
},
disabled=not all_videos_selected,
use_container_width=True
)
# 將選擇的結果回傳給主函式
return logo_selection, open_selection, end_selection
def _render_shared_asset_uploader(callbacks):
"""一個私有的輔助函式,用於渲染共享素材上傳工具。"""
with st.expander("📂 管理與上傳共享素材", expanded=False):
st.markdown("這裡上傳的影片將存入 `shared_assets` 公共資料夾,可供所有專案重複使用。")
st.file_uploader(
"上傳新的影片到共享素材庫",
type=["mp4", "mov"],
accept_multiple_files=True,
label_visibility="collapsed",
key="shared_video_uploader",
on_change=callbacks.upload_shared_videos
)

View File

@ -1,5 +1,5 @@
import streamlit as st
from utils.callbacks import toggle_all_video_checkboxes, callback_delete_selected_videos
from callbacks import toggle_all_video_checkboxes, callback_delete_selected_videos
@st.fragment
def video_management_fragment(paths):

View File

@ -1,6 +1,6 @@
import streamlit as st
from utils.helpers import analyze_ass_for_keywords, search_pixabay_videos, search_pexels_videos
from utils.callbacks import callback_download_videos, callback_toggle_preview
from callbacks import callback_download_videos, callback_toggle_preview
@st.fragment