From 0214a6069bf17508557a8df4115464c24614bf3f Mon Sep 17 00:00:00 2001 From: bruce Date: Tue, 22 Jul 2025 13:39:46 +0800 Subject: [PATCH] update --- app.py | 87 ++---------- callbacks.py | 30 ++-- project_model.py | 30 ++-- scripts/__init__.py | 0 scripts/step1_notion_sync.py | 87 ------------ scripts/step2_translate_ipa.py | 155 --------------------- scripts/step3_generate_audio.py | 104 -------------- scripts/step4_concatenate_audio.py | 78 ----------- scripts/step5_generate_ass.py | 107 --------------- scripts/step6_assemble_video.py | 166 ----------------------- ui_fragments/audio_manager.py | 43 ------ ui_fragments/tab0_data_processing.py | 10 +- ui_fragments/tab1_asset_generation.py | 15 +- ui_fragments/tab2_online_video_search.py | 10 +- ui_fragments/tab3_video_composition.py | 6 +- ui_fragments/video_manager.py | 45 ------ ui_fragments/video_search.py | 136 ------------------- utils/helpers.py | 19 ++- 18 files changed, 69 insertions(+), 1059 deletions(-) delete mode 100644 scripts/__init__.py delete mode 100644 scripts/step1_notion_sync.py delete mode 100644 scripts/step2_translate_ipa.py delete mode 100644 scripts/step3_generate_audio.py delete mode 100644 scripts/step4_concatenate_audio.py delete mode 100644 scripts/step5_generate_ass.py delete mode 100644 scripts/step6_assemble_video.py delete mode 100644 ui_fragments/audio_manager.py delete mode 100644 ui_fragments/video_manager.py delete mode 100644 ui_fragments/video_search.py diff --git a/app.py b/app.py index 24c92c2..bbc9e7f 100644 --- a/app.py +++ b/app.py @@ -8,19 +8,8 @@ from ui_fragments import tab0_data_processing,tab1_asset_generation,tab2_online_ # --- 本地模組匯入 --- 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 +from utils.helpers import get_notion_page_titles -# --- 匯入外部處理腳本 --- -# 雖然這些腳本主要在 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="英語影片自動化工作流程") @@ -46,7 +35,7 @@ if 'project_selector' not in st.session_state: # --- UI 介面 --- st.title("🎬 英語影片自動化工作流程") -display_operation_status() + # --- 側邊欄 --- with st.sidebar: st.header("API & Project Control") @@ -62,7 +51,7 @@ with st.sidebar: 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()), + options=[""] + sorted(list(page_titles_map.keys())), key="selected_title") if st.session_state.selected_title: @@ -78,7 +67,7 @@ with st.sidebar: st.header("2. 選擇現有專案") # 1. 介面邏輯變得極簡:直接呼叫工具函式 - existing_projects = get_project_list() + existing_projects = sorted(get_project_list()) # 2. UI 只負責綁定 key 和 on_change 事件 st.selectbox( @@ -106,79 +95,23 @@ else: label_visibility="collapsed") st.markdown("---") -# # --- 分頁內容 --- + # --- 分頁內容 --- + # 第一頁,notion及專案資料 if active_tab == tab_names[0]: with st.container(border=True): tab0_data_processing.render_tab(project) + # 第二頁,音訊字幕 if active_tab == tab_names[1]: tab1_asset_generation.render_tab(project, callbacks) - + # 第三頁,背景影片搜尋 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") - -# 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 -# ) + \ No newline at end of file diff --git a/callbacks.py b/callbacks.py index 9c93617..a2197a1 100644 --- a/callbacks.py +++ b/callbacks.py @@ -1,7 +1,7 @@ import streamlit as st from pathlib import Path -from scripts.step1_notion_sync import create_project_from_page -from scripts.step6_assemble_video import run_step6_assemble_video +# from scripts_.step1_notion_sync import create_project_from_page +# from scripts_.step6_assemble_video import run_step6_assemble_video from utils.paths import PROJECTS_DIR ,get_project_list from utils import asset_manager import requests @@ -79,8 +79,7 @@ def _execute_project_method(method_name: str, spinner_text: str): return with st.spinner(spinner_text): - success= method_to_call() - st.session_state.operation_status = {"type": "success" if success else "error"} + method_to_call() # --- 所有後續的 Callback 現在都變得極其簡潔 --- @@ -144,31 +143,20 @@ def assemble_final_video(logo_video_name, open_video_name, end_video_name): "message": error_message } -def callback_run_step(step_function, source="unknown", **kwargs): - spinner_text = kwargs.pop("spinner_text", "正在處理中,請稍候...") - with st.spinner(spinner_text): - result = step_function(**kwargs) - if len(result) == 2: success, msg = result - elif len(result) == 3: success, msg, _ = result - else: success, msg = False, "步驟函式回傳格式不符!" - st.session_state.operation_status = {"success": success, "message": msg, "source": source} - if step_function == run_step6_assemble_video and success: - st.session_state.final_video_path = str(result[2]) - st.session_state.show_video = True -def toggle_all_video_checkboxes(self, video_files): +def toggle_all_video_checkboxes( video_files): """【新】切換所有影片核取方塊的選取狀態。""" select_all_state = st.session_state.get('select_all_videos', False) for video_file in video_files: st.session_state[f"delete_cb_{video_file.name}"] = select_all_state -def delete_selected_videos(self): +def delete_selected_videos(): """【新】刪除在 UI 中選取的影片。""" - if not self.project: + if not st.session_state.project: st.session_state.operation_status = {"type": "warning", "message": "請先選擇一個專案。"} return - output_dir = self.project.paths["output"] + output_dir = st.session_state.project.paths["output"] deleted_files_count = 0 errors = [] files_to_delete = [ @@ -226,7 +214,7 @@ def callback_download_videos(paths: dict): st.session_state.operation_status = {"success": False, "message": "尚未選擇任何影片。", "source": "download_videos"} return - download_dir = paths["output"] / "test" + download_dir = paths["temp_video"] download_dir.mkdir(parents=True, exist_ok=True) # --- 循序命名邏輯 START --- @@ -252,7 +240,7 @@ def callback_download_videos(paths: dict): original_path = Path(url) # 使用循序命名 - new_video_name = f"{counter}{original_path.suffix}" + new_video_name = f"{counter:02d}{original_path.suffix}" save_path = download_dir / new_video_name try: diff --git a/project_model.py b/project_model.py index 7a06b17..83d91cb 100644 --- a/project_model.py +++ b/project_model.py @@ -3,7 +3,7 @@ from utils.helpers import get_media_duration,get_media_info from pathlib import Path from typing import List, Dict, Optional import streamlit as st # 為了存取 secrets -from scripts import step1_notion_sync, step2_translate_ipa +# from scripts_ import step1_notion_sync, step2_translate_ipa import json from notion_client import Client from google.cloud import translate_v2 as translate @@ -567,7 +567,7 @@ class Project: # 3. 初始化一個空的 AudioSegment 作為拼接的基礎 # 這是比「拿第一個檔案當基礎」更穩健的做法 combined_audio = AudioSegment.empty() - + # 4. 遍歷所有音訊檔並依次拼接 for wav_file in wav_files: # 讀取單個 .wav 檔案 @@ -625,6 +625,7 @@ class Project: try: # 從 self.data 讀取各語言的文本行 + title_line = self.data.get("title", "") en_lines = [line.strip() for line in self.data.get("en", "").split('\n') if line.strip()] zh_lines = [line.strip() for line in self.data.get("zh", "").split('\n') if line.strip()] ipa_lines = [line.strip() for line in self.data.get("ipa", "").split('\n') if line.strip()] @@ -655,10 +656,11 @@ class Project: subs.info["Title"] = self.name # 6. 定義所有需要的樣式 (從您的腳本中精確複製)[1] - subs.styles["EN"] = pysubs2.SSAStyle(fontname="Noto Sans", fontsize=140, primarycolor=pysubs2.Color(255, 248, 231), outlinecolor=pysubs2.Color(255, 248, 231), outline=2, alignment=pysubs2.Alignment.TOP_CENTER, marginv=280) - subs.styles["IPA"] = pysubs2.SSAStyle(fontname="Noto Sans", fontsize=110, primarycolor=pysubs2.Color(255, 140, 0), outlinecolor=pysubs2.Color(255, 140, 0), outline=1, alignment=pysubs2.Alignment.TOP_CENTER, marginv=340) - subs.styles["ZH"] = pysubs2.SSAStyle(fontname="Noto Sans TC", fontsize=140, primarycolor=pysubs2.Color(102, 128, 153), outlinecolor=pysubs2.Color(102, 128, 153), outline=1, alignment=pysubs2.Alignment.TOP_CENTER, marginv=440) - subs.styles["NUMBER"] = pysubs2.SSAStyle(fontname="Segoe UI Symbol", fontsize=120, primarycolor=pysubs2.Color(144, 144, 144), outlinecolor=pysubs2.Color(144, 144, 144), bold=True, outline=1, borderstyle=1,alignment=pysubs2.Alignment.TOP_RIGHT, marginl=0, marginr=260, marginv=160) + subs.styles["TITLE"] = pysubs2.SSAStyle(fontname="源泉圓體丹 R", fontsize=80, primarycolor=pysubs2.Color(230, 221, 196), outlinecolor=pysubs2.Color(230, 221, 196), outline=0, borderstyle=2, alignment=pysubs2.Alignment.TOP_CENTER, marginl=0, marginr=0, marginv=200) + subs.styles["EN"] = pysubs2.SSAStyle(fontname="Noto Sans", fontsize=140, primarycolor=pysubs2.Color(255, 248, 231), outlinecolor=pysubs2.Color(255, 248, 231), outline=2,borderstyle=2, alignment=pysubs2.Alignment.TOP_CENTER, marginv=400) + subs.styles["IPA"] = pysubs2.SSAStyle(fontname="Noto Sans", fontsize=110, primarycolor=pysubs2.Color(255, 140, 0), outlinecolor=pysubs2.Color(255, 140, 0), outline=1, alignment=pysubs2.Alignment.TOP_CENTER, marginv=500) + subs.styles["ZH"] = pysubs2.SSAStyle(fontname="Noto Sans TC", fontsize=140, primarycolor=pysubs2.Color(152, 188, 193), outlinecolor=pysubs2.Color(152, 188, 193), outline=1, alignment=pysubs2.Alignment.TOP_CENTER, marginv=600) + subs.styles["NUMBER"] = pysubs2.SSAStyle(fontname="NaiKaiFont", fontsize=60, primarycolor=pysubs2.Color(164, 124, 104), outlinecolor=pysubs2.Color(164, 124, 104),outline=1, angle=-10, borderstyle=2,alignment=pysubs2.Alignment.TOP_RIGHT, marginl=0, marginr=260, marginv=160) # 7. 遍歷音訊檔,生成四層字幕事件 st.session_state.operation_status = { @@ -673,10 +675,10 @@ class Project: end_time = current_time_ms + duration_ms # 為每一層字幕建立一個事件 - subs.append(pysubs2.SSAEvent(start=start_time, end=end_time, text=en_lines[i], style="EN")) - subs.append(pysubs2.SSAEvent(start=start_time, end=end_time, text=f"[{ipa_lines[i]}]", style="IPA")) - subs.append(pysubs2.SSAEvent(start=start_time, end=end_time, text=zh_lines[i], style="ZH")) - subs.append(pysubs2.SSAEvent(start=start_time, end=end_time, text=i, style="NUMBER")) + subs.append(pysubs2.SSAEvent(start=start_time, end=end_time, text=f"{{\\fad(300, 300)}}{en_lines[i]}" , style="EN")) + subs.append(pysubs2.SSAEvent(start=start_time, end=end_time, text=f"{{\\fad(300, 300)}}/{ipa_lines[i]}/" , style="IPA")) + subs.append(pysubs2.SSAEvent(start=start_time, end=end_time, text=f"{{\\fad(300, 300)}}{zh_lines[i]}" , style="ZH")) + subs.append(pysubs2.SSAEvent(start=start_time, end=end_time, text=f"{{\\fad(300, 300)}}{str(i+1)}" , style="NUMBER")) current_time_ms = end_time st.session_state.operation_status = { @@ -684,6 +686,7 @@ class Project: "value": (i + 1) / total_files, "message": f"正在處理字幕... ({i+1}/{total_files})" } + subs.insert(0, pysubs2.SSAEvent(start=0, end=end_time, text=title_line, style="TITLE")) # 8. 儲存 .ass 檔案 subs.save(str(ass_path)) st.session_state.operation_status = { @@ -736,7 +739,7 @@ class Project: final_video_path = self.paths['final_video'] bg_final_path = output_dir / "bg_final.mp4" transition_duration = 1.0 - print("111") + for file_path in [logo_video, open_video, end_video, audio_path, ass_path]: if not file_path or not file_path.exists(): st.session_state.operation_status = { @@ -744,7 +747,6 @@ class Project: "message": f"缺少必需的檔案: {e}" } return - print("222") base_videos = sorted([p for p in temp_video_dir.iterdir() if p.is_file() and p.suffix.lower() in ['.mp4', '.mov']]) if not base_videos: @@ -753,7 +755,6 @@ class Project: "message": f"資料夾中沒有影片可供合成。: {e}" } return - print("333") p_loop_count, error_msg = self.calculate_loop_count(transition_duration=transition_duration) @@ -761,7 +762,6 @@ class Project: # 如果計算出錯,拋出一個錯誤讓外層的 except 捕捉 raise ValueError(f"計算循環次數時失敗: {error_msg}") - print("444") # --- 2. 一步式生成主要內容影片 (bg_final.mp4) --- if not bg_final_path.exists(): print(f"⚙️ 準備一步式生成主要內容影片 (循環 {p_loop_count} 次)...") @@ -899,10 +899,8 @@ class Project: video_paths = [p for p in video_folder.iterdir() if p.is_file() and p.suffix.lower() in ['.mp4', '.mov']] video_lengths = [get_media_duration(p) for p in video_paths] video_lengths = [l for l in video_lengths if l is not None and l > 0] - print("ccc") if not video_lengths: return None, "在 `test` 資料夾中找不到有效的影片檔案。" - print("ddd") n = len(video_lengths) m = sum(video_lengths) tr = transition_duration diff --git a/scripts/__init__.py b/scripts/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/scripts/step1_notion_sync.py b/scripts/step1_notion_sync.py deleted file mode 100644 index 39bbd83..0000000 --- a/scripts/step1_notion_sync.py +++ /dev/null @@ -1,87 +0,0 @@ -# scripts/step1_notion_sync.py -import os -import json -import re -from pathlib import Path -from notion_client import Client -import logging - -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') - -def extract_property_value(property_data): - """從 Notion 頁面屬性中提取純文字值。""" - prop_type = property_data.get('type') - if prop_type == 'title': - return property_data['title'][0]['plain_text'] if property_data.get('title') else "" - elif prop_type == 'rich_text': - return "\n".join([text['plain_text'] for text in property_data.get('rich_text', [])]) - elif prop_type == 'select' and property_data.get('select'): - return property_data['select']['name'] - elif prop_type == 'date' and property_data.get('date'): - return property_data['date']['start'] - return None - - - -def create_project_from_page(api_key: str, page_id: str, project_name: str, projects_dir: Path): - """根據單一 Notion 頁面建立一個新的本地專案資料夾。""" - sanitized_name = re.sub(r'[\\/*?:"<>|]', "", project_name).replace(" ", "_").lower() - project_path = projects_dir / sanitized_name - output_json_path = project_path / "data.json" - - if project_path.exists(): - return False, f"⚠️ 專案 '{sanitized_name}' 已存在。", None - - project_path.mkdir(parents=True, exist_ok=True) - - try: - client = Client(auth=api_key) - page_data = client.pages.retrieve(page_id=page_id) - properties = page_data['properties'] - page_entry = {"id": page_data['id']} - for prop_name, prop_data in properties.items(): - page_entry[prop_name] = extract_property_value(prop_data) - - with open(output_json_path, 'w', encoding='utf-8') as f: - json.dump(page_entry, f, ensure_ascii=False, indent=2) - return True, f"✅ 專案 '{sanitized_name}' 建立成功!", sanitized_name - except Exception as e: - return False, f"❌ 處理頁面 (ID: {page_id}) 時出錯: {e}", None - -# --- 新增的更新函式 --- -def update_project_from_notion(api_key: str, project_path: Path): - """ - 從 Notion 更新單一專案的 data.json 檔案。 - 它會讀取本地 data.json 以取得 page_id,然後抓取最新資料。 - """ - data_file = project_path / "data.json" - if not data_file.exists(): - return False, f"❌ 錯誤:在專案 '{project_path.name}' 中找不到 data.json。" - - try: - with open(data_file, 'r', encoding='utf-8') as f: - local_data = json.load(f) - - page_id = local_data.get('id') - if not page_id: - return False, "❌ 錯誤:data.json 檔案中缺少 Notion page_id。" - - # 使用 page_id 從 Notion API 抓取最新資料 [4][6] - logging.info(f"正在從 Notion 更新頁面 ID: {page_id}") - client = Client(auth=api_key) - page_data = client.pages.retrieve(page_id=page_id) - - properties = page_data['properties'] - updated_entry = {"id": page_data['id']} - for prop_name, prop_data in properties.items(): - updated_entry[prop_name] = extract_property_value(prop_data) - - # 覆寫本地的 data.json 檔案 - with open(data_file, 'w', encoding='utf-8') as f: - json.dump(updated_entry, f, ensure_ascii=False, indent=2) - - return True, f"✅ 專案 '{project_path.name}' 已成功從 Notion 同步更新!" - - except Exception as e: - logging.error(f"更新專案 '{project_path.name}' 時發生錯誤: {e}") - return False, f"❌ 更新失敗: {e}" diff --git a/scripts/step2_translate_ipa.py b/scripts/step2_translate_ipa.py deleted file mode 100644 index f0aa100..0000000 --- a/scripts/step2_translate_ipa.py +++ /dev/null @@ -1,155 +0,0 @@ -# scripts/step2_translate_ipa.py - -import os -import json -from pathlib import Path -from google.cloud import translate_v2 as translate -from notion_client import Client -from notion_client.errors import APIResponseError -import eng_to_ipa as e2i -import logging - -def _update_single_notion_page(client, page_id: str, zh_text: str, ipa_text: str): - """ - 將翻譯和 IPA 結果更新回指定的 Notion 頁面。 - - Args: - client: 已初始化的 Notion Client。 - page_id (str): 要更新的 Notion 頁面 ID。 - zh_text (str): 中文翻譯文字。 - ipa_text (str): IPA 音標文字。 - """ - try: - # --- ✨ 核心修改處:建立符合 Notion API 規範的 properties 物件 --- - properties_payload = { - # 假設您在 Notion 中的欄位名稱就是 "zh" - # 如果不是,請修改 "zh" 為您實際的欄位名稱,例如 "中文翻譯" - "zh": { - "rich_text": [ - { - "type": "text", - "text": { - "content": zh_text - } - } - ] - }, - # 假設您在 Notion 中的欄位名稱就是 "ipa" - # 如果不是,請修改 "ipa" 為您實際的欄位名稱,例如 "IPA" - "ipa": { - "rich_text": [ - { - "type": "text", - "text": { - "content": ipa_text - } - } - ] - } - } - - logging.info(f"正在更新 Notion 頁面 ID: {page_id}") - # 發送更新請求 - client.pages.update(page_id=page_id, properties=properties_payload) - logging.info(f"✅ 成功將結果寫回 Notion 頁面 {page_id}") - - except Exception as e: - # 捕捉並印出詳細錯誤,這對於除錯至關重要 - logging.error(f"❌ 更新 Notion 頁面 {page_id} 失敗: {e}") - - -def run_step2_translate_ipa( - project_path: Path, - google_creds_path: str, - notion_api_key: str = None, - notion_database_id: str = None -): - """ - 讀取專案中的 data.json,為其添加中文翻譯和 IPA 音標, - 並將結果寫回 data.json,同時可選擇性地更新回 Notion。 - Args: - project_path (Path): 專案資料夾的路徑。 - google_creds_path (str): Google Cloud 認證 JSON 檔案的路徑。 - notion_api_key (str, optional): Notion API Key. Defaults to None. - notion_database_id (str, optional): Notion Database ID. Defaults to None. - Returns: - tuple[bool, str]: (操作是否成功, 附帶的訊息)。 - """ - data_file = project_path / "data.json" - if not data_file.exists(): - return False, f"錯誤:在專案 '{project_path.name}' 中找不到 data.json 檔案。" - - # 1. 初始化 API 客戶端 - try: - os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = google_creds_path - translate_client = translate.Client() - except Exception as e: - return False, f"初始化 Google Translate 客戶端失敗: {e}" - - notion_client = None - if notion_api_key and notion_database_id: - try: - notion_client = Client(auth=notion_api_key) - except Exception as e: - return False, f"初始化 Notion 客戶端失敗: {e}" - - # 2. 讀取並處理資料 - try: - with data_file.open("r", encoding="utf-8") as f: - data = json.load(f) - except Exception as e: - return False, f"讀取或解析 data.json 失敗: {e}" - - # --- ✨ 核心修改處:移除迴圈,直接處理 data 物件 --- - - # 使用 .get() 安全地獲取英文原文,避免 KeyError [7][14] - # 'en' 是您在 data.json 中定義的鍵 - english_text = data.get("en") - - if not english_text or not isinstance(english_text, str): - return False, "在 data.json 中找不到 'en' 鍵或其內容不是文字。" - - # --- 翻譯成中文 --- - # 將英文原文按行切分,並過濾掉空行 - english_lines = [line.strip() for line in english_text.strip().split('\n') if line.strip()] - translated_lines = [] - - # 迴圈遍歷每一行英文,單獨進行翻譯 - for line in english_lines: - try: - result = translate_client.translate(line, target_language="zh-TW") - translated_lines.append(result.get("translatedText", "")) - except Exception as e: - print(f"翻譯 '{line}' 時出錯: {e}") - translated_lines.append("") # 如果單行出錯,則添加空字串 - # 將翻譯好的各行重新用換行符組合起來 - translated_text = '\n'.join(translated_lines) - - # --- 生成 IPA 音標 --- - try: - english_lines = [line.strip() for line in english_text.strip().split('\n') if line.strip()] - ipa_lines = [e2i.convert(line) for line in english_lines] - ipa_text = '\n'.join(ipa_lines) - except Exception as e: - print(f"獲取 '{english_text}' 的 IPA 時出錯: {e}") - ipa_text = "" - - # 將結果存回主 data 字典中 - data["zh"] = translated_text - data["ipa"] = ipa_text - - print(f"已處理: '{english_text[:30]}...' -> '{translated_text[:30]}...' | '{ipa_text[:30]}...'") - - # --- 如果提供了 Notion 憑證,則更新回 Notion --- - page_id = data.get("id") - if notion_client and page_id: - _update_single_notion_page(notion_client, page_id, translated_text, ipa_text) - - # 3. 將更新後的單一物件寫回 JSON 檔案 - try: - with data_file.open("w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) - except Exception as e: - return False, f"寫回更新後的 data.json 失敗: {e}" - - return True, f"✅ 專案 '{project_path.name}' 的翻譯與 IPA 已成功處理!" diff --git a/scripts/step3_generate_audio.py b/scripts/step3_generate_audio.py deleted file mode 100644 index 781b968..0000000 --- a/scripts/step3_generate_audio.py +++ /dev/null @@ -1,104 +0,0 @@ -import json -import html -from pathlib import Path -from google.cloud import texttospeech -from google.oauth2 import service_account - -def generate_ssml_content(item_en, item_zh, english_voice_1, english_voice_2, chinese_voice): - return f""" - - - - {item_en} - - - - {item_en} - - - - {item_zh} - - - - {item_en} - - - - """ -def run_step3_generate_audio( - project_path: Path, - google_creds_path, - english_voice_1: str = "en-US-Wavenet-I", - english_voice_2: str = "en-US-Wavenet-F", - chinese_voice: str = "cmn-TW-Wavenet-B", -): - """ - 為每個詞彙項目生成獨立的音訊檔案。 - """ - try: - # 1. 定義路徑 - json_file_path = project_path / "data.json" - output_audio_folder = project_path / "audio" - output_audio_folder.mkdir(parents=True, exist_ok=True) - - # 2. 從 JSON 檔案載入資料 - if not json_file_path.exists(): - return False, f"錯誤:找不到 JSON 檔案 {json_file_path}" - - with open(json_file_path, 'r', encoding='utf-8') as f: - data = json.load(f) - - # --- ✨ 核心修改處:將字串分割成列表 --- - - # 首先獲取完整的字串,如果鍵不存在則返回空字串 - en_text = data.get("en", "") - zh_text = data.get("zh", "") - - # 使用換行符 '\n' 將字串分割成列表,並過濾掉空行 - en_lines = [line.strip() for line in en_text.split('\n') if line.strip()] - zh_lines = [line.strip() for line in zh_text.split('\n') if line.strip()] - - # 現在 en_lines 和 zh_lines 是我們期望的列表格式了 - - # 進行驗證 - if not en_lines or not zh_lines or len(en_lines) != len(zh_lines): - return False, "錯誤:JSON 檔案中的英文和中文句子列表為空或長度不匹配。" - - # 3. 初始化 Google Text-to-Speech 客戶端 - creds = service_account.Credentials.from_service_account_file(google_creds_path) - client = texttospeech.TextToSpeechClient(credentials=creds) - - # 4. 迴圈遍歷每個詞彙項目並合成音訊 - total_files = len(en_lines) - for i, (item_en, item_zh) in enumerate(zip(en_lines, zh_lines)): - print(f"正在處理第 {i+1}/{total_files} 個檔案: {item_en}") - - safe_item_en = html.escape(item_en) - safe_item_zh = html.escape(item_zh) - - ssml_content = generate_ssml_content(safe_item_en, safe_item_zh, english_voice_1, english_voice_2, chinese_voice) - synthesis_input = texttospeech.SynthesisInput(ssml=ssml_content) - - voice_params = texttospeech.VoiceSelectionParams(language_code="en-US") - audio_config = texttospeech.AudioConfig( - audio_encoding=texttospeech.AudioEncoding.LINEAR16, - sample_rate_hertz=24000 - ) - - response = client.synthesize_speech( - input=synthesis_input, - voice=voice_params, - audio_config=audio_config - ) - - output_file = output_audio_folder / f"vocab_{i:02d}.wav" - with open(output_file, "wb") as out: - out.write(response.audio_content) - - return True, f"成功!已在 '{output_audio_folder}' 資料夾中生成 {total_files} 個音訊檔案。" - - except Exception as e: - error_message = f"生成音訊時發生未預期的錯誤: {e}" - print(error_message) - return False, error_message \ No newline at end of file diff --git a/scripts/step4_concatenate_audio.py b/scripts/step4_concatenate_audio.py deleted file mode 100644 index 4d9a823..0000000 --- a/scripts/step4_concatenate_audio.py +++ /dev/null @@ -1,78 +0,0 @@ -# scripts/step4_concatenate_audio.py -import re -from pathlib import Path -from pydub import AudioSegment - -def _concatenate_audio_files(audio_folder: Path, file_pattern: str, output_path: Path, delay_ms: int = 500): - """ - Internal helper function to find, sort, and concatenate audio files based on a pattern. - This is based on the script you provided [1]. - """ - if not audio_folder.is_dir(): - raise FileNotFoundError(f"Audio source folder '{audio_folder}' does not exist.") - - # Find all files matching the pattern and extract the number for sorting - compiled_pattern = re.compile(file_pattern) - matching_files = [] - for filepath in audio_folder.iterdir(): - if filepath.is_file(): - match = compiled_pattern.match(filepath.name) - if match and match.group(1).isdigit(): - matching_files.append((filepath, int(match.group(1)))) - - if not matching_files: - raise FileNotFoundError(f"No files matching pattern '{file_pattern}' found in '{audio_folder}'.") - - # Sort files numerically based on the extracted number - matching_files.sort(key=lambda x: x[1]) - - print("Found and sorted the following files for concatenation:") - for file_path, _ in matching_files: - print(f"- {file_path.name}") - - # Start with a silent segment (delay) - combined_audio = AudioSegment.silent(duration=delay_ms) - - # Concatenate all sorted audio files - for audio_file_path, _ in matching_files: - try: - segment = AudioSegment.from_file(audio_file_path) - combined_audio += segment - except Exception as e: - print(f"Warning: Could not process file '{audio_file_path.name}'. Skipping. Error: {e}") - - # End with a silent segment (delay) - combined_audio += AudioSegment.silent(duration=2000) - # Export the final combined audio file - output_path.parent.mkdir(parents=True, exist_ok=True) - combined_audio.export(output_path, format="mp3") - print(f"Successfully concatenated audio to '{output_path}'") - - -def run_step4_concatenate_audio(project_path: Path): - """ - Main function for Step 4. Finds all 'vocab_xx.wav' files in the project's - audio folder, concatenates them, and saves the result as a single MP3. - """ - try: - audio_folder = project_path / "audio" - output_dir = project_path / "output" - output_wav_path = output_dir / "combined_audio.wav" - - # Define the pattern for the audio files created in Step 3 - file_pattern = r"vocab_(\d{2})\.wav" - - _concatenate_audio_files( - audio_folder=audio_folder, - file_pattern=file_pattern, - output_path=output_wav_path, - delay_ms=0 # Start with a 1-second delay - ) - - return True, f"✅ Audio successfully concatenated and saved to '{output_wav_path}'" - - except FileNotFoundError as e: - return False, f"❌ Error: {e}", None - except Exception as e: - return False, f"❌ An unexpected error occurred during audio concatenation: {e}", None - diff --git a/scripts/step5_generate_ass.py b/scripts/step5_generate_ass.py deleted file mode 100644 index 8227006..0000000 --- a/scripts/step5_generate_ass.py +++ /dev/null @@ -1,107 +0,0 @@ -# scripts/step5_generate_ass.py -import json -import re -import librosa -from pathlib import Path -import pysubs2 # 使用 pysubs2 函式庫 - -def number_to_unicode_circled(num): - """ - 將數字 1-10 轉換為 Unicode 帶圈數字符號。 - """ - if num < 0: - return None - if num == 0: - return '\u24ea' - result = '' - digits = [] - while num > 0: - digits.append(num % 10) - num //= 10 - digits.reverse() - for d in digits: - if d == 0: - result += '\u24ea' - else: - result += chr(0x2460 + d - 1) - return result - -def run_step5_generate_ass(project_path: Path): - """ - 根據專案中的音訊檔案和 JSON 文本生成一個多層的 .ass 字幕檔。 - - Args: - project_path (Path): 專案的根目錄路徑。 - - Returns: - tuple[bool, str, Path | None]: (操作是否成功, 附帶的訊息, 輸出檔案的路徑或 None)。 - """ - try: - # 1. 定義路徑 - json_file_path = project_path / "data.json" - audio_folder = project_path / "audio" - output_dir = project_path / "output" - final_ass_path = output_dir / "subtitles.ass" - - # 確保輸出資料夾存在 - output_dir.mkdir(parents=True, exist_ok=True) - - # 2. 載入 JSON 資料並分割成行 - with open(json_file_path, 'r', encoding='utf-8') as f: - data = json.load(f) - - en_lines = [line.strip() for line in data.get("en", "").split('\n') if line.strip()] - zh_lines = [line.strip() for line in data.get("zh", "").split('\n') if line.strip()] - ipa_lines = [line.strip() for line in data.get("ipa", "").split('\n') if line.strip()] - number_list = [number_to_unicode_circled(i + 1) for i in range(len(en_lines))] - - # 3. 獲取所有音訊檔案並排序 - file_pattern = r"vocab_(\d{2})\.wav" - pattern = re.compile(file_pattern) - wav_files = sorted( - [p for p in audio_folder.iterdir() if p.is_file() and pattern.fullmatch(p.name)], - key=lambda p: int(pattern.fullmatch(p.name).group(1)) - ) - - # 4. 檢查數量是否一致 - if not (len(wav_files) == len(en_lines) == len(zh_lines) == len(ipa_lines)): - msg = f"錯誤:音訊({len(wav_files)}), EN({len(en_lines)}), ZH({len(zh_lines)}), IPA({len(ipa_lines)}) 數量不一致!" - return False, msg, None - - # 5. 使用 pysubs2 建立 .ass 檔案 - subs = pysubs2.SSAFile() - # 設定畫布解析度(與影片一致) - subs.info["PlayResX"] = "1920" - subs.info["PlayResY"] = "1080" - - # 從您的 gen_ass.py 中複製樣式定義 [1] - subs.styles["EN"] = pysubs2.SSAStyle(fontname="Noto Sans", fontsize=140, primarycolor=pysubs2.Color (255, 248, 231), outlinecolor=pysubs2.Color (255, 248, 231),outline=2, borderstyle=1, alignment=pysubs2.Alignment.TOP_CENTER, marginv=280) - subs.styles["IPA"] = pysubs2.SSAStyle(fontname="Noto Sans", fontsize=110, primarycolor=pysubs2.Color(255,140,0), outlinecolor=pysubs2.Color(255,140,0), outline=1, borderstyle=1, alignment=pysubs2.Alignment.TOP_CENTER, marginv=340) - subs.styles["ZH"] = pysubs2.SSAStyle(fontname="Noto Sans TC", fontsize=140, primarycolor=pysubs2.Color(102,128,153), outlinecolor=pysubs2.Color(102,128,153), outline=1, borderstyle=1, alignment=pysubs2.Alignment.TOP_CENTER, marginv=440) - subs.styles["NUMBER"] = pysubs2.SSAStyle(fontname="Segoe UI Symbol", fontsize=120, primarycolor=pysubs2.Color(204, 136, 0), outlinecolor=pysubs2.Color(204, 136, 0), bold=True, scalex=120, outline=1, borderstyle=1, alignment=pysubs2.Alignment.TOP_RIGHT, marginl=0, marginr=260, marginv=160) - - # 6. 遍歷音訊檔,生成字幕事件 - current_time_ms = 0 - for i, wav_path in enumerate(wav_files): - duration_s = librosa.get_duration(path=str(wav_path)) - duration_ms = int(duration_s * 1000) - - start_time = current_time_ms - end_time = current_time_ms + duration_ms - - # 建立四層字幕事件 - subs.append(pysubs2.SSAEvent(start=start_time, end=end_time, text=en_lines[i], style="EN")) - subs.append(pysubs2.SSAEvent(start=start_time, end=end_time, text=f"[{ipa_lines[i]}]", style="IPA")) - subs.append(pysubs2.SSAEvent(start=start_time, end=end_time, text=zh_lines[i], style="ZH")) - subs.append(pysubs2.SSAEvent(start=start_time, end=end_time, text=number_list[i], style="NUMBER")) - - current_time_ms = end_time - - # 7. 儲存 .ass 檔案 - subs.save(str(final_ass_path)) - - return True, f"✅ ASS 字幕檔已成功生成並儲存至 '{final_ass_path}'", final_ass_path - - except Exception as e: - return False, f"❌ 在生成 ASS 字幕時發生未預期的錯誤: {e}", None - diff --git a/scripts/step6_assemble_video.py b/scripts/step6_assemble_video.py deleted file mode 100644 index 89f7d52..0000000 --- a/scripts/step6_assemble_video.py +++ /dev/null @@ -1,166 +0,0 @@ -import subprocess -from pathlib import Path -import os -from utils.helpers import get_media_info, get_media_duration - -# --- 標準化設定 --- -TARGET_WIDTH = 1920 -TARGET_HEIGHT = 1080 -TARGET_FPS = 30 - -def escape_ffmpeg_path_for_filter(path: Path) -> str: - """為在 FFmpeg 濾鏡圖中安全使用而轉義路徑。""" - path_str = str(path.resolve()) - if os.name == 'nt': - return path_str.replace('\\', '\\\\').replace(':', '\\:') - else: - return path_str.replace("'", "'\\\\\\''") - -def run_step6_assemble_video( - project_path: Path, - logo_video: Path, - open_video: Path, - end_video: Path, - p_loop_count: int, - transition_duration: float = 1.0 -): - """ - 使用 FFmpeg 的 xfade 濾鏡組裝最終影片,並在過程中統一影片屬性與進行色彩校正。 - """ - try: - # --- 1. 路徑與檔案檢查 --- - output_dir = project_path / "output" - test_dir = output_dir / "test" - audio_path = output_dir / "combined_audio.wav" - ass_path = output_dir / "subtitles.ass" - bg_final_path = output_dir / "bg_final.mp4" - final_video_path = output_dir / "final_video_full_sequence.mp4" - - for file_path in [logo_video, open_video, end_video, audio_path, ass_path]: - if not file_path or not file_path.exists(): - return False, f"❌ 找不到必要的輸入檔案: {file_path}", None - - base_videos = sorted([p for p in test_dir.iterdir() if p.is_file() and p.suffix.lower() in ['.mp4', '.mov']]) - if not base_videos: - return False, "❌ `test` 資料夾中沒有影片可供合成。", None - - # --- 2. 一步式生成主要內容影片 (bg_final.mp4) --- - if not bg_final_path.exists(): - print(f"⚙️ 準備一步式生成主要內容影片 (循環 {p_loop_count} 次)...") - - looped_video_list = base_videos * p_loop_count - inputs_cmd = [] - for video_path in looped_video_list: - inputs_cmd.extend(["-i", str(video_path)]) - - inputs_cmd.extend(["-i", str(audio_path)]) - ass_path_str = escape_ffmpeg_path_for_filter(ass_path) - - filter_parts = [] - stream_count = len(looped_video_list) - - # 【核心修改】定義色彩校正濾鏡 - color_correction_filter = "eq=brightness=-0.05:contrast=0.95:saturation=0.7" - - # 將標準化 (尺寸、影格率) 與色彩校正合併 - for i in range(stream_count): - filter_parts.append( - f"[{i}:v]scale={TARGET_WIDTH}:{TARGET_HEIGHT}:force_original_aspect_ratio=decrease,pad={TARGET_WIDTH}:{TARGET_HEIGHT}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={TARGET_FPS},{color_correction_filter}[v_scaled_{i}]" - ) - - last_v_out = "[v_scaled_0]" - for i in range(stream_count - 1): - offset = sum(get_media_duration(v) for v in looped_video_list[:i+1]) - (i + 1) * transition_duration - v_in_next = f"[v_scaled_{i+1}]" - v_out_current = f"[v_out{i+1}]" if i < stream_count - 2 else "[bg_video_stream]" - filter_parts.append(f"{last_v_out}{v_in_next}xfade=transition=fade:duration={transition_duration}:offset={offset:.4f}{v_out_current}") - last_v_out = v_out_current - - drawbox_filter = "drawbox=w=iw*0.8:h=ih*0.8:x=(iw-iw*0.8)/2:y=(ih-ih*0.8)/2:color=0x808080@0.7:t=fill" - filter_parts.append(f"[bg_video_stream]{drawbox_filter}[v_with_mask]") - filter_parts.append(f"[v_with_mask]ass=filename='{ass_path_str}'[final_v]") - filter_complex_str = ";".join(filter_parts) - - ffmpeg_main_content_cmd = [ - "ffmpeg", "-y", *inputs_cmd, - "-filter_complex", filter_complex_str, - "-map", "[final_v]", - "-map", f"{stream_count}:a:0", - "-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", "fast", - "-c:a", "aac", "-b:a", "192k", - "-shortest", - str(bg_final_path) - ] - - print("🚀 正在執行 FFmpeg 主要內容合成指令 (含色彩校正)...") - result = subprocess.run(ffmpeg_main_content_cmd, check=True, capture_output=True, text=True, encoding='utf-8') - if result.returncode != 0: - raise subprocess.CalledProcessError(result.returncode, result.args, stderr=result.stderr) - print(f"✅ 主要內容影片已生成: {bg_final_path}") - - # --- 3. 合成最終的影片序列 (logo -> open -> bg_final -> end) --- - if final_video_path.exists(): - print(f"✅ 最終影片已存在,跳過組裝: {final_video_path}") - return True, f"✅ 影片已存在於 {final_video_path}", final_video_path - - print("🎬 開始動態生成 xfade 合成指令...") - videos_to_concat = [logo_video, open_video, bg_final_path, end_video] - final_inputs_cmd, final_filter_parts, video_infos = [], [], [] - - for video_path in videos_to_concat: - final_inputs_cmd.extend(["-i", str(video_path)]) - info = get_media_info(video_path) - video_infos.append(info) - - for i, info in enumerate(video_infos): - if not info["has_audio"]: - duration = info['duration'] - final_filter_parts.append(f"anullsrc=r=44100:cl=stereo:d={duration:.4f}[silent_a_{i}]") - - for i in range(len(videos_to_concat)): - # 注意:這裡我們不對 logo/open/end 影片進行調色,以保留它們的原始風格 - final_filter_parts.append( - f"[{i}:v]scale={TARGET_WIDTH}:{TARGET_HEIGHT}:force_original_aspect_ratio=decrease,pad={TARGET_WIDTH}:{TARGET_HEIGHT}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={TARGET_FPS}[v_final_scaled_{i}]" - ) - - last_v_out = "[v_final_scaled_0]" - last_a_out = "[0:a]" if video_infos[0]["has_audio"] else "[silent_a_0]" - - for i in range(len(videos_to_concat) - 1): - offset = sum(info['duration'] for info in video_infos[:i+1]) - (i + 1) * transition_duration - v_in_next = f"[v_final_scaled_{i+1}]" - a_in_next = f"[{i+1}:a]" if video_infos[i+1]["has_audio"] else f"[silent_a_{i+1}]" - - v_out_current = f"[v_out{i+1}]" if i < len(videos_to_concat) - 2 else "[video]" - a_out_current = f"[a_out{i+1}]" if i < len(videos_to_concat) - 2 else "[audio]" - - final_filter_parts.append(f"{last_v_out}{v_in_next}xfade=transition=fade:duration={transition_duration}:offset={offset:.4f}{v_out_current}") - final_filter_parts.append(f"{last_a_out}{a_in_next}acrossfade=d={transition_duration}{a_out_current}") - - last_v_out, last_a_out = v_out_current, a_out_current - - final_filter_complex_str = ";".join(final_filter_parts) - - ffmpeg_concat_cmd = [ - "ffmpeg", "-y", *final_inputs_cmd, - "-filter_complex", final_filter_complex_str, - "-map", "[video]", "-map", "[audio]", - "-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", "fast", - "-movflags", "+faststart", - "-c:a", "aac", "-b:a", "192k", - str(final_video_path) - ] - - print("🚀 執行 FFmpeg 最終序列合成指令...") - result = subprocess.run(ffmpeg_concat_cmd, check=True, capture_output=True, text=True, encoding='utf-8') - if result.returncode != 0: - raise subprocess.CalledProcessError(result.returncode, result.args, stderr=result.stderr) - - print(f"✅ 影片已成功組裝並儲存至 {final_video_path}") - return True, f"✅ 影片已成功組裝並儲存至 {final_video_path}", final_video_path - - except subprocess.CalledProcessError as e: - error_output = e.stderr if e.stderr else str(e) - return False, f"❌ FFmpeg 執行失敗:\n{error_output}", None - except Exception as e: - return False, f"❌ 發生未預期錯誤: {e}", None diff --git a/ui_fragments/audio_manager.py b/ui_fragments/audio_manager.py deleted file mode 100644 index 433bb39..0000000 --- a/ui_fragments/audio_manager.py +++ /dev/null @@ -1,43 +0,0 @@ -import streamlit as st -from callbacks import toggle_all_audio_checkboxes, callback_delete_selected_audios - -@st.fragment -def audio_management_fragment(paths: dict): - """A self-contained fragment for managing and deleting project audio files.""" - with st.expander("🎧 管理專案音訊素材"): - 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=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: - callback_delete_selected_audios(paths) - st.rerun(scope="fragment") \ No newline at end of file diff --git a/ui_fragments/tab0_data_processing.py b/ui_fragments/tab0_data_processing.py index 3eb0d2c..25ffa8d 100644 --- a/ui_fragments/tab0_data_processing.py +++ b/ui_fragments/tab0_data_processing.py @@ -3,19 +3,19 @@ import streamlit as st import callbacks from project_model import Project # 引入 Project 以便進行型別註記 +from utils.helpers import display_operation_status @st.fragment def render_tab(project: Project): """ - 負責顯示「1. 資料處理 & AI 加註」Tab 的所有 UI 元件和邏輯。 + 負責顯示「資料處理 & AI 加註」Tab 的所有 UI 元件和邏輯。 """ + if "operation_status" in st.session_state: + display_operation_status() # 根據 project.data 的狀態來決定顯示哪個 UI if project.data: # --- 狀態一:專案「已同步」--- - st.session_state.operation_status = { - "success": True, - "message": "✅ 專案資料已就緒。" - } + # st.toast("✅ 專案資料已就緒。") with st.container(border=True): col1, col2 = st.columns([3, 1]) with col1: diff --git a/ui_fragments/tab1_asset_generation.py b/ui_fragments/tab1_asset_generation.py index 9457024..f269fe1 100644 --- a/ui_fragments/tab1_asset_generation.py +++ b/ui_fragments/tab1_asset_generation.py @@ -1,6 +1,7 @@ # utils/tab1_asset_generation.py import streamlit as st +from utils.helpers import display_operation_status @st.fragment @@ -14,22 +15,24 @@ def render_tab(project, callbacks): project: 存放專案狀態和路徑的物件。 callbacks: 存放按鈕回呼函式的物件。 """ + display_operation_status() + st.subheader("素材生成") # --- 音效生成區塊 --- with st.container(border=True): st.subheader("音效生成") - # 步驟 3.1 - st.markdown("##### 步驟 3.1: 生成單句音訊") + + st.markdown("生成單句音訊") st.button("執行生成", on_click=callbacks.callback_generate_sentence_audio) st.divider() # 步驟 3.2 - st.markdown("##### 步驟 3.2: 組合完整音訊") + st.markdown("組合完整音訊") can_concatenate = project.has_sentence_audio() if not can_concatenate: - st.info("請先執行步驟 3.1 以生成單句音訊。") + st.info("請先執行生成單句音訊。") st.button("執行組合", on_click=callbacks.callback_concatenate_audio, disabled=not can_concatenate) @@ -46,11 +49,11 @@ def render_tab(project, callbacks): # --- 字幕生成區塊 --- with st.container(border=True): - st.subheader("步驟 4: 生成 ASS 字幕檔") + st.subheader("生成 ASS 字幕檔") can_generate_ass = project.has_sentence_audio() if not can_generate_ass: - st.info("請先執行步驟 3.1 以生成字幕所需的時間戳。") + st.info("請先執行生成單句音訊 以生成字幕所需的時間戳。") st.button("📝 生成 .ass 字幕檔", on_click=callbacks.callback_generate_subtitles, disabled=not can_generate_ass) diff --git a/ui_fragments/tab2_online_video_search.py b/ui_fragments/tab2_online_video_search.py index 08bee38..0f4d9a6 100644 --- a/ui_fragments/tab2_online_video_search.py +++ b/ui_fragments/tab2_online_video_search.py @@ -4,12 +4,14 @@ import streamlit as st import os from pathlib import Path from utils.helpers import analyze_ass_for_keywords, search_pixabay_videos, search_pexels_videos +from utils.helpers import display_operation_status @st.fragment def render_tab(project,callbacks): """一個用於從多個來源搜尋並下載影片的獨立UI片段。""" + display_operation_status paths = project.paths - st.header("步驟 5: 從線上圖庫搜尋影片素材") + st.header("從線上圖庫搜尋影片素材") pixabay_api_key = st.secrets.get("PIXABAY_API_KEY") pexels_api_key = st.secrets.get("PEXELS_API_KEY") @@ -17,7 +19,7 @@ def render_tab(project,callbacks): st.warning("請至少在 Streamlit secrets 中設定 `PIXABAY_API_KEY` 或 `PEXELS_API_KEY` 以使用此功能。") return - st.subheader("5.1 搜尋影片") + st.subheader("搜尋影片") # 【新增】影片來源選擇 available_sources = [] @@ -113,8 +115,8 @@ def render_tab(project,callbacks): 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` 資料夾,並自動循序命名。") + st.subheader("選擇影片並下載") + st.button("📥 下載選取的影片到專案素材庫", on_click=callbacks.callback_download_videos, args=(paths,), help="將下方勾選的影片下載至目前專案的 `output/temp_video` 資料夾,並自動循序命名。") num_cols = 5 cols = st.columns(num_cols) diff --git a/ui_fragments/tab3_video_composition.py b/ui_fragments/tab3_video_composition.py index 415e9ab..3a17b98 100644 --- a/ui_fragments/tab3_video_composition.py +++ b/ui_fragments/tab3_video_composition.py @@ -2,11 +2,12 @@ import streamlit as st from pathlib import Path from config import SHARED_ASSETS_DIR +from utils.helpers import display_operation_status @st.fragment def render_tab(project, callbacks): """渲染「最終影片合成」標籤頁的 UI。""" - + display_operation_status _render_video_management_section(project, callbacks) _render_shared_asset_uploader(callbacks) _render_shared_asset_selection(callbacks) @@ -102,9 +103,6 @@ def _render_shared_asset_selection(callbacks): disabled=not all_videos_selected, use_container_width=True ) - - - # 將選擇的結果回傳給主函式 return logo_selection, open_selection, end_selection def _render_shared_asset_uploader(callbacks): diff --git a/ui_fragments/video_manager.py b/ui_fragments/video_manager.py deleted file mode 100644 index 19cd429..0000000 --- a/ui_fragments/video_manager.py +++ /dev/null @@ -1,45 +0,0 @@ -import streamlit as st -from callbacks import toggle_all_video_checkboxes, callback_delete_selected_videos - -@st.fragment -def video_management_fragment(paths): - """A self-contained fragment for managing and deleting project videos.""" - with st.expander("🎬 管理專案影片素材"): - output_dir = paths["output"] - 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: - all_selected = all(st.session_state.get(f"delete_cb_{f.name}", False) for f in video_files) - if st.session_state.get('select_all_videos', False) != all_selected: - st.session_state.select_all_videos = all_selected - - st.markdown("勾選您想要刪除的影片,然後點擊下方的按鈕。") - - st.checkbox( - "全選/取消全選", - key="select_all_videos", - on_change=toggle_all_video_checkboxes, - args=(video_files,) - ) - - with st.form("delete_videos_form"): - 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}" - ) - - submitted = st.form_submit_button("🟥 確認刪除選取的影片", use_container_width=True, type="primary") - - if submitted: - callback_delete_selected_videos(paths) - st.rerun(scope="fragment") \ No newline at end of file diff --git a/ui_fragments/video_search.py b/ui_fragments/video_search.py deleted file mode 100644 index 290a2e4..0000000 --- a/ui_fragments/video_search.py +++ /dev/null @@ -1,136 +0,0 @@ -import streamlit as st -from utils.helpers import analyze_ass_for_keywords, search_pixabay_videos, search_pexels_videos -from callbacks import callback_download_videos, callback_toggle_preview - - -@st.fragment -def video_search_fragment(paths: dict): - """一個用於從多個來源搜尋並下載影片的獨立UI片段。""" - 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.error("沒有可用的影片來源 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=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=callback_toggle_preview, args=(video_id_str,), use_container_width=True, type=preview_button_type) \ No newline at end of file diff --git a/utils/helpers.py b/utils/helpers.py index 1a532dc..1a25986 100644 --- a/utils/helpers.py +++ b/utils/helpers.py @@ -52,7 +52,7 @@ def display_operation_status(): status = st.session_state.operation_status message_type = status.get("type", "info") message = status.get("message", "") - + print(f"{message}") if message_type == "success": st.toast(f"✅ {message}") elif message_type == "error": @@ -92,6 +92,7 @@ def search_pixabay_videos(api_key, query, target_count=20, buffer=2): if not data.get("hits"): break for video in data["hits"]: try: + if video.get('duration', 0) < 12: break video_details = video.get('videos', {}).get('large', {}) width, height = video_details.get('width', 0), video_details.get('height', 0) if width > 0 and height > 0 and width >= height: @@ -128,7 +129,8 @@ def search_pexels_videos(api_key: str, query: str, target_count: int = 20) -> tu params = { "query": query, "per_page": target_count + 5, # 多取一些以過濾 - "orientation": 'landscape' + "orientation": 'landscape', + "min_duration": 12 } try: @@ -139,10 +141,17 @@ def search_pexels_videos(api_key: str, query: str, target_count: int = 20) -> tu videos = data.get("videos", []) if not videos: return True, "在 Pexels 找不到符合條件的影片,請嘗試其他關鍵字。", [] - - final_results = videos[:target_count] - return True, f"成功從 Pexels 找到 {len(final_results)} 個橫式影片。", final_results + filtered_videos = [ + v for v in videos if v.get("duration", 0) >= 12 + ] + + if not filtered_videos: + return True, "找到影片,但沒有任何一部長度超過 12 秒。", [] + # 取回目標數量的結果 + final_results = filtered_videos[:target_count] + + return True, f"成功找到 {len(final_results)} 個符合條件的影片。", final_results except requests.RequestException as e: # Pexels 的錯誤訊息通常在 response body 中 error_info = ""