add project
This commit is contained in:
0
utils/__init__.py
Normal file
0
utils/__init__.py
Normal file
251
utils/callbacks.py
Normal file
251
utils/callbacks.py
Normal file
@ -0,0 +1,251 @@
|
||||
import streamlit as st
|
||||
from pathlib import Path
|
||||
from scripts.step1_notion_sync import create_project_from_page
|
||||
from utils.helpers import calculate_loop_count
|
||||
from scripts.step6_assemble_video import run_step6_assemble_video
|
||||
from utils.paths import PROJECTS_DIR
|
||||
import requests
|
||||
|
||||
# --- 專案與流程控制 Callbacks ---
|
||||
def callback_set_project():
|
||||
st.session_state.current_project = st.session_state.project_selector
|
||||
st.session_state.final_video_path = None
|
||||
st.session_state.show_video = False
|
||||
st.session_state.operation_status = {}
|
||||
st.session_state.active_preview_id = None
|
||||
|
||||
def callback_create_project():
|
||||
page_id = st.session_state.page_titles_map[st.session_state.selected_title]
|
||||
with st.spinner("正在建立專案..."):
|
||||
success, msg, new_project_name = create_project_from_page(api_key=st.secrets.get("NOTION_API_KEY"), page_id=page_id, project_name=st.session_state.selected_title, projects_dir=PROJECTS_DIR)
|
||||
if success:
|
||||
st.session_state.current_project = new_project_name
|
||||
st.session_state.selected_title = ""
|
||||
st.session_state.operation_status = {"success": success, "message": msg, "source": "create_project"}
|
||||
|
||||
def callback_assemble_final_video(paths, **kwargs):
|
||||
"""
|
||||
影片合成的專用「準備」回呼。
|
||||
它會先計算循環次數 p,然後將任務轉交給通用的 callback_run_step。
|
||||
"""
|
||||
# 步驟 1: 執行前置計算
|
||||
st.write("⏳ 正在計算影片循環次數...") # 提供即時回饋
|
||||
audio_path = paths["combined_audio"]
|
||||
video_folder = paths["output"] / "test"
|
||||
|
||||
p, error_msg = calculate_loop_count(audio_path, video_folder, 1.0)
|
||||
|
||||
# 步驟 2: 檢查前置計算的結果
|
||||
if error_msg:
|
||||
st.session_state.operation_status = {"success": False, "message": f"❌ 無法合成影片: {error_msg}", "source": "assemble_video"}
|
||||
return # 如果計算失敗,直接終止流程
|
||||
|
||||
# 步驟 3: 將計算結果加入到參數中,並轉交給通用執行器
|
||||
kwargs["p_loop_count"] = p
|
||||
|
||||
# 現在,我們呼叫標準的執行回呼,讓它來處理 spinner 和後續流程
|
||||
callback_run_step(run_step6_assemble_video, **kwargs)
|
||||
|
||||
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 callback_delete_selected_videos(paths: dict):
|
||||
output_dir = paths["output"]
|
||||
deleted_files_count = 0
|
||||
errors = []
|
||||
|
||||
files_to_delete = []
|
||||
for key, value in st.session_state.items():
|
||||
if key.startswith("delete_cb_") and value:
|
||||
filename = key.replace("delete_cb_", "", 1)
|
||||
files_to_delete.append(filename)
|
||||
|
||||
if not files_to_delete:
|
||||
st.session_state.operation_status = {"success": True, "message": "🤔 沒有選擇任何要刪除的影片。", "source": "delete_videos"}
|
||||
return
|
||||
|
||||
for filename in files_to_delete:
|
||||
try:
|
||||
file_path = output_dir / filename
|
||||
if file_path.exists():
|
||||
if st.session_state.get("final_video_path") and Path(st.session_state.final_video_path) == file_path:
|
||||
st.session_state.final_video_path = None
|
||||
st.session_state.show_video = False
|
||||
|
||||
file_path.unlink()
|
||||
deleted_files_count += 1
|
||||
del st.session_state[f"delete_cb_{filename}"]
|
||||
except Exception as e:
|
||||
errors.append(f"刪除 {filename} 時出錯: {e}")
|
||||
|
||||
if errors:
|
||||
message = f"❌ 刪除操作有誤。成功刪除 {deleted_files_count} 個檔案,但發生以下錯誤: " + ", ".join(errors)
|
||||
st.session_state.operation_status = {"success": False, "message": message, "source": "delete_videos"}
|
||||
else:
|
||||
message = f"✅ 成功刪除 {deleted_files_count} 個影片。"
|
||||
st.session_state.operation_status = {"success": True, "message": message, "source": "delete_videos"}
|
||||
|
||||
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 callback_delete_selected_audios(paths: dict):
|
||||
deleted_files_count = 0
|
||||
errors = []
|
||||
|
||||
files_to_delete_names = []
|
||||
for key, value in st.session_state.items():
|
||||
if key.startswith("delete_audio_cb_") and value:
|
||||
filename = key.replace("delete_audio_cb_", "", 1)
|
||||
files_to_delete_names.append(filename)
|
||||
|
||||
if not files_to_delete_names:
|
||||
st.session_state.operation_status = {"success": True, "message": "🤔 沒有選擇任何要刪除的音訊。", "source": "delete_audios"}
|
||||
return
|
||||
|
||||
audio_dir = paths["audio"]
|
||||
output_dir = paths["output"]
|
||||
|
||||
for filename in files_to_delete_names:
|
||||
try:
|
||||
file_path_audio = audio_dir / filename
|
||||
file_path_output = output_dir / filename
|
||||
|
||||
file_path_to_delete = None
|
||||
if file_path_audio.exists():
|
||||
file_path_to_delete = file_path_audio
|
||||
elif file_path_output.exists():
|
||||
file_path_to_delete = file_path_output
|
||||
|
||||
if file_path_to_delete:
|
||||
file_path_to_delete.unlink()
|
||||
deleted_files_count += 1
|
||||
del st.session_state[f"delete_audio_cb_{filename}"]
|
||||
else:
|
||||
errors.append(f"找不到檔案 {filename}。")
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"刪除 {filename} 時出錯: {e}")
|
||||
|
||||
if errors:
|
||||
message = f"❌ 刪除操作有誤。成功刪除 {deleted_files_count} 個檔案,但發生以下錯誤: " + ", ".join(errors)
|
||||
st.session_state.operation_status = {"success": False, "message": message, "source": "delete_audios"}
|
||||
else:
|
||||
message = f"✅ 成功刪除 {deleted_files_count} 個音訊。"
|
||||
st.session_state.operation_status = {"success": True, "message": message, "source": "delete_audios"}
|
||||
|
||||
def toggle_all_audio_checkboxes(audio_files):
|
||||
select_all_state = st.session_state.get('select_all_audios', False)
|
||||
for audio_file in audio_files:
|
||||
st.session_state[f"delete_audio_cb_{audio_file.name}"] = select_all_state
|
||||
|
||||
def callback_toggle_preview(video_id):
|
||||
if st.session_state.active_preview_id == video_id: st.session_state.active_preview_id = None
|
||||
else: st.session_state.active_preview_id = video_id
|
||||
|
||||
def callback_download_videos(paths: dict):
|
||||
with st.spinner("正在下載影片,請稍候..."):
|
||||
videos_to_download = {vid: info for vid, info in st.session_state.selected_videos.items() if info['selected']}
|
||||
if not videos_to_download:
|
||||
st.session_state.operation_status = {"success": False, "message": "尚未選擇任何影片。", "source": "download_videos"}
|
||||
return
|
||||
|
||||
download_dir = paths["output"] / "test"
|
||||
download_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# --- 循序命名邏輯 START ---
|
||||
existing_files = list(download_dir.glob('*.mp4')) + list(download_dir.glob('*.mov'))
|
||||
existing_numbers = []
|
||||
for f in existing_files:
|
||||
try:
|
||||
# 從檔名 (不含副檔名) 中提取數字
|
||||
existing_numbers.append(int(f.stem))
|
||||
except ValueError:
|
||||
# 忽略那些不是純數字的檔名
|
||||
continue
|
||||
|
||||
# 決定起始編號
|
||||
start_counter = max(existing_numbers) + 1 if existing_numbers else 1
|
||||
counter = start_counter
|
||||
# --- 循序命名邏輯 END ---
|
||||
|
||||
total, download_count, errors = len(videos_to_download), 0, []
|
||||
|
||||
for i, (video_id, info) in enumerate(videos_to_download.items()):
|
||||
url = info['url']
|
||||
original_path = Path(url)
|
||||
|
||||
# 使用循序命名
|
||||
new_video_name = f"{counter}{original_path.suffix}"
|
||||
save_path = download_dir / new_video_name
|
||||
|
||||
try:
|
||||
if not save_path.exists():
|
||||
response = requests.get(url, stream=True)
|
||||
response.raise_for_status()
|
||||
with open(save_path, 'wb') as f:
|
||||
f.write(response.content)
|
||||
download_count += 1
|
||||
counter += 1 # 只有在成功下載後才增加計數器
|
||||
except Exception as e:
|
||||
errors.append(f"下載影片 {original_path.name} 失敗: {e}")
|
||||
|
||||
if errors:
|
||||
final_message = f"任務完成,但有 {len(errors)} 個錯誤。成功處理 {download_count}/{total} 個影片。\n" + "\n".join(errors)
|
||||
st.session_state.operation_status = {"success": False, "message": final_message, "source": "download_videos"}
|
||||
else:
|
||||
final_message = f"任務完成!成功下載 {download_count}/{total} 個影片到專案的 `output/test` 資料夾,並已自動循序命名。"
|
||||
st.session_state.operation_status = {"success": True, "message": final_message, "source": "download_videos"}
|
||||
|
||||
@st.fragment
|
||||
def video_management_fragment(paths: dict):
|
||||
"""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")
|
||||
189
utils/helpers.py
Normal file
189
utils/helpers.py
Normal file
@ -0,0 +1,189 @@
|
||||
import streamlit as st
|
||||
import json
|
||||
import requests
|
||||
import time
|
||||
from pathlib import Path
|
||||
from notion_client import Client
|
||||
from notion_client.helpers import iterate_paginated_api
|
||||
import subprocess
|
||||
import math
|
||||
import librosa
|
||||
|
||||
def get_media_info(media_path: Path) -> dict:
|
||||
"""使用 ffprobe 獲取媒體檔案的詳細資訊 (時長、是否有音訊)。"""
|
||||
cmd = [
|
||||
"ffprobe", "-v", "error", "-show_entries", "format=duration:stream=codec_type",
|
||||
"-of", "json", str(media_path)
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
info = json.loads(result.stdout)
|
||||
duration = float(info.get("format", {}).get("duration", 0))
|
||||
has_audio = any(s.get("codec_type") == "audio" for s in info.get("streams", []))
|
||||
if duration == 0: raise ValueError("無法獲取或時長為 0")
|
||||
return {"duration": duration, "has_audio": has_audio}
|
||||
except (subprocess.CalledProcessError, json.JSONDecodeError, ValueError) as e:
|
||||
print(f"❌ 無法獲取媒體資訊 {media_path}: {e}")
|
||||
raise
|
||||
|
||||
def get_media_duration(file_path: Path) -> float | None:
|
||||
"""使用 ffprobe 或 librosa 獲取媒體檔案的時長(秒)。"""
|
||||
try:
|
||||
if file_path.suffix.lower() in ['.wav', '.mp3', '.aac']:
|
||||
return librosa.get_duration(path=file_path)
|
||||
else:
|
||||
cmd = [
|
||||
"ffprobe", "-v", "error", "-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1", str(file_path)
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||
return float(result.stdout.strip())
|
||||
except Exception as e:
|
||||
print(f"無法讀取檔案 {file_path} 的時長: {e}")
|
||||
return None
|
||||
|
||||
# 【新增】計算循環次數 p 的函式
|
||||
def calculate_loop_count(audio_path: Path, video_folder: Path, transition_duration: float) -> tuple[int | None, str | None]:
|
||||
"""
|
||||
根據音訊長度和一系列影片,計算所需的最小循環次數 p。
|
||||
成功時回傳 (p, None),失敗時回傳 (None, error_message)。
|
||||
"""
|
||||
m_prime = get_media_duration(audio_path)
|
||||
if m_prime is None:
|
||||
return None, "無法讀取音訊檔案長度。"
|
||||
|
||||
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]
|
||||
|
||||
if not video_lengths:
|
||||
return None, "在 `test` 資料夾中找不到有效的影片檔案。"
|
||||
|
||||
n = len(video_lengths)
|
||||
m = sum(video_lengths)
|
||||
tr = transition_duration
|
||||
|
||||
denominator = m - (n - 1) * tr
|
||||
|
||||
if denominator <= 0:
|
||||
return None, f"影片的有效長度 ({denominator:.2f}s) 小於或等於零,無法進行循環。請增加影片時長或減少轉場時間。"
|
||||
|
||||
p = math.ceil(m_prime / denominator)
|
||||
return p, None
|
||||
def display_global_status_message():
|
||||
"""
|
||||
檢查會話狀態中是否存在任何操作狀態,如果存在則顯示它。
|
||||
顯示後會立即清除狀態,以確保訊息只顯示一次。
|
||||
"""
|
||||
if 'operation_status' in st.session_state and st.session_state.operation_status:
|
||||
status = st.session_state.operation_status
|
||||
# 根據成功與否,選擇對應的訊息元件
|
||||
if status.get("success"):
|
||||
st.success(status["message"], icon="✅")
|
||||
else:
|
||||
st.error(status["message"], icon="❌")
|
||||
# 清除狀態,避免在下一次刷新時重複顯示
|
||||
st.session_state.operation_status = {}
|
||||
|
||||
|
||||
def analyze_ass_for_keywords(paths: dict) -> list:
|
||||
data_file = paths["data"]
|
||||
if not data_file.exists(): return ["", "", ""]
|
||||
try:
|
||||
data = json.loads(data_file.read_text(encoding="utf-8"))
|
||||
full_text = " ".join([item.get('english', '') for item in data.get('script', [])])
|
||||
words = [word.strip(".,!?") for word in full_text.lower().split() if len(word) > 4]
|
||||
if not words: return ["nature", "technology", "business"]
|
||||
unique_words = list(dict.fromkeys(words))
|
||||
suggestions = unique_words[:3]
|
||||
while len(suggestions) < 3: suggestions.append("")
|
||||
return suggestions
|
||||
except Exception: return ["nature", "technology", "business"]
|
||||
|
||||
def search_pixabay_videos(api_key, query, target_count=20, buffer=2):
|
||||
if not api_key: return False, "請在 Streamlit secrets 中設定 PIXABAY_API_KEY。", []
|
||||
if not query.strip(): return False, "請輸入搜尋關鍵字。", []
|
||||
url = "https://pixabay.com/api/videos/"
|
||||
valid_hits, page, per_page_request, max_pages = [], 1, 50, 5
|
||||
while len(valid_hits) < (target_count + buffer) and page <= max_pages:
|
||||
params = {"key": api_key, "q": query, "per_page": per_page_request, "safesearch": "true", "page": page}
|
||||
try:
|
||||
response = requests.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if not data.get("hits"): break
|
||||
for video in data["hits"]:
|
||||
try:
|
||||
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:
|
||||
valid_hits.append(video)
|
||||
if len(valid_hits) >= (target_count + buffer): break
|
||||
except (KeyError, TypeError): continue
|
||||
if len(valid_hits) >= (target_count + buffer): break
|
||||
page += 1
|
||||
time.sleep(1)
|
||||
except requests.RequestException as e: return False, f"API 請求失敗: {e}", []
|
||||
|
||||
final_results = valid_hits[:target_count]
|
||||
if len(final_results) > 0: return True, f"成功找到並過濾出 {len(final_results)} 個橫式影片。", final_results
|
||||
else: return True, "找不到符合條件的橫式影片,請嘗試其他關鍵字。", []
|
||||
def search_pexels_videos(api_key: str, query: str, target_count: int = 20) -> tuple[bool, str, list]:
|
||||
"""
|
||||
從 Pexels API 搜尋橫向影片。
|
||||
|
||||
Args:
|
||||
api_key (str): Pexels API 金鑰。
|
||||
query (str): 搜尋關鍵字。
|
||||
target_count (int): 目標搜尋結果數量。
|
||||
|
||||
Returns:
|
||||
tuple[bool, str, list]: (成功狀態, 訊息, 影片結果列表)
|
||||
"""
|
||||
if not api_key:
|
||||
return False, "請在 Streamlit secrets 中設定 PEXELS_API_KEY。", []
|
||||
if not query.strip():
|
||||
return False, "請輸入搜尋關鍵字。", []
|
||||
|
||||
url = "https://api.pexels.com/v1/videos/search"
|
||||
headers = {"Authorization": api_key}
|
||||
params = {
|
||||
"query": query,
|
||||
"per_page": target_count + 5, # 多取一些以過濾
|
||||
"orientation": 'landscape'
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
videos = data.get("videos", [])
|
||||
if not videos:
|
||||
return True, "在 Pexels 找不到符合條件的影片,請嘗試其他關鍵字。", []
|
||||
|
||||
final_results = videos[:target_count]
|
||||
return True, f"成功從 Pexels 找到 {len(final_results)} 個橫式影片。", final_results
|
||||
|
||||
except requests.RequestException as e:
|
||||
# Pexels 的錯誤訊息通常在 response body 中
|
||||
error_info = ""
|
||||
if e.response is not None:
|
||||
try:
|
||||
error_info = e.response.json().get('error', str(e))
|
||||
except json.JSONDecodeError:
|
||||
error_info = e.response.text
|
||||
return False, f"Pexels API 請求失敗: {error_info}", []
|
||||
|
||||
def get_notion_page_titles(api_key: str, database_id: str) -> dict:
|
||||
"""獲取 Notion 資料庫中所有頁面的標題和對應的 page_id。"""
|
||||
client = Client(auth=api_key)
|
||||
pages = list(iterate_paginated_api(client.databases.query, database_id=database_id))
|
||||
|
||||
title_map = {}
|
||||
for page in pages:
|
||||
title_property = page['properties'].get('Name', {})
|
||||
if title_property.get('title'):
|
||||
title = title_property['title'][0]['plain_text']
|
||||
title_map[title] = page['id']
|
||||
return title_map
|
||||
21
utils/paths.py
Normal file
21
utils/paths.py
Normal file
@ -0,0 +1,21 @@
|
||||
from pathlib import Path
|
||||
|
||||
PROJECTS_DIR = Path("projects")
|
||||
SHARED_ASSETS_DIR = Path("shared_assets")
|
||||
|
||||
PROJECTS_DIR.mkdir(exist_ok=True)
|
||||
SHARED_ASSETS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
def get_project_paths(project_name: str) -> dict:
|
||||
"""為給定專案回傳一個包含所有重要路徑的字典。"""
|
||||
if not project_name:
|
||||
return {}
|
||||
project_root = PROJECTS_DIR / project_name
|
||||
return {
|
||||
"root": project_root,
|
||||
"data": project_root / "data.json",
|
||||
"audio": project_root / "audio",
|
||||
"output": project_root / "output",
|
||||
"combined_audio": project_root / "output" / "combined_audio.wav",
|
||||
"ass_file": project_root / "output" / f"{project_name}.ass"
|
||||
}
|
||||
Reference in New Issue
Block a user