version 1
This commit is contained in:
@ -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):
|
||||
|
||||
38
ui_fragments/tab0_data_processing.py
Normal file
38
ui_fragments/tab0_data_processing.py
Normal 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. 修改完成後,記得點擊上方的同步按鈕。")
|
||||
101
ui_fragments/tab1_asset_generation.py
Normal file
101
ui_fragments/tab1_asset_generation.py
Normal 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")
|
||||
142
ui_fragments/tab2_online_video_search.py
Normal file
142
ui_fragments/tab2_online_video_search.py
Normal 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)
|
||||
124
ui_fragments/tab3_video_composition.py
Normal file
124
ui_fragments/tab3_video_composition.py
Normal 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
|
||||
)
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user