add project
This commit is contained in:
0
scripts/__init__.py
Normal file
0
scripts/__init__.py
Normal file
87
scripts/step1_notion_sync.py
Normal file
87
scripts/step1_notion_sync.py
Normal file
@ -0,0 +1,87 @@
|
||||
# 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}"
|
||||
155
scripts/step2_translate_ipa.py
Normal file
155
scripts/step2_translate_ipa.py
Normal file
@ -0,0 +1,155 @@
|
||||
# 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 已成功處理!"
|
||||
104
scripts/step3_generate_audio.py
Normal file
104
scripts/step3_generate_audio.py
Normal file
@ -0,0 +1,104 @@
|
||||
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"""
|
||||
<speak>
|
||||
<break time="2s"/>
|
||||
<voice name="{english_voice_1}">
|
||||
<prosody rate="medium" pitch="medium">{item_en}</prosody>
|
||||
</voice>
|
||||
<break time="2s"/>
|
||||
<voice name="{english_voice_2}">
|
||||
<prosody rate="70%" pitch="medium">{item_en}</prosody>
|
||||
</voice>
|
||||
<break time="2s"/>
|
||||
<voice name="{chinese_voice}">
|
||||
<prosody rate="medium" pitch="+2st">{item_zh}</prosody>
|
||||
</voice>
|
||||
<break time="1.5s"/>
|
||||
<voice name="{english_voice_2}">
|
||||
<prosody rate="110%" pitch="medium">{item_en}</prosody>
|
||||
</voice>
|
||||
<break time="1s"/>
|
||||
</speak>
|
||||
"""
|
||||
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
|
||||
78
scripts/step4_concatenate_audio.py
Normal file
78
scripts/step4_concatenate_audio.py
Normal file
@ -0,0 +1,78 @@
|
||||
# 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
|
||||
|
||||
107
scripts/step5_generate_ass.py
Normal file
107
scripts/step5_generate_ass.py
Normal file
@ -0,0 +1,107 @@
|
||||
# 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
|
||||
|
||||
166
scripts/step6_assemble_video.py
Normal file
166
scripts/step6_assemble_video.py
Normal file
@ -0,0 +1,166 @@
|
||||
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
|
||||
Reference in New Issue
Block a user