2_📚_History.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. # Copyright (C) 2025 AIDC-AI
  2. #
  3. # Licensed under the Apache License, Version 2.0 (the "License");
  4. # you may not use this file except in compliance with the License.
  5. # You may obtain a copy of the License at
  6. # http://www.apache.org/licenses/LICENSE-2.0
  7. # Unless required by applicable law or agreed to in writing, software
  8. # distributed under the License is distributed on an "AS IS" BASIS,
  9. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. # See the License for the specific language governing permissions and
  11. # limitations under the License.
  12. """
  13. History Page - View generation history and manage tasks
  14. """
  15. import sys
  16. from pathlib import Path
  17. from datetime import datetime
  18. import os
  19. # Add project root to sys.path
  20. _script_dir = Path(__file__).resolve().parent
  21. _project_root = _script_dir.parent.parent
  22. if str(_project_root) not in sys.path:
  23. sys.path.insert(0, str(_project_root))
  24. import streamlit as st
  25. from loguru import logger
  26. from web.state.session import init_session_state, init_i18n, get_pixelle_video
  27. from web.components.header import render_header
  28. from web.i18n import tr
  29. from web.utils.async_helpers import run_async
  30. # Page config
  31. st.set_page_config(
  32. page_title="History - Pixelle-Video",
  33. page_icon="📚",
  34. layout="wide",
  35. )
  36. def format_duration(seconds: float) -> str:
  37. """Format duration in seconds to readable string"""
  38. if seconds < 60:
  39. return f"{seconds:.1f}s"
  40. elif seconds < 3600:
  41. minutes = int(seconds / 60)
  42. secs = int(seconds % 60)
  43. return f"{minutes}m {secs}s"
  44. else:
  45. hours = int(seconds / 3600)
  46. minutes = int((seconds % 3600) / 60)
  47. return f"{hours}h {minutes}m"
  48. def format_file_size(bytes_size: int) -> str:
  49. """Format file size in bytes to readable string"""
  50. if bytes_size < 1024:
  51. return f"{bytes_size}B"
  52. elif bytes_size < 1024 * 1024:
  53. return f"{bytes_size / 1024:.1f}KB"
  54. elif bytes_size < 1024 * 1024 * 1024:
  55. return f"{bytes_size / 1024 / 1024:.1f}MB"
  56. else:
  57. return f"{bytes_size / 1024 / 1024 / 1024:.2f}GB"
  58. def format_datetime(iso_string: str) -> str:
  59. """Format ISO datetime string to readable format"""
  60. try:
  61. dt = datetime.fromisoformat(iso_string)
  62. return dt.strftime("%m-%d %H:%M")
  63. except:
  64. return iso_string
  65. def truncate_text(text: str, max_length: int = 60) -> str:
  66. """Truncate text to max length"""
  67. if len(text) <= max_length:
  68. return text
  69. return text[:max_length] + "..."
  70. def render_sidebar_controls(pixelle_video):
  71. """Render sidebar with statistics and filters"""
  72. with st.sidebar:
  73. # Statistics
  74. st.markdown(f"**📊 {tr('history.total_tasks')}**")
  75. stats = run_async(pixelle_video.history.get_statistics())
  76. col1, col2 = st.columns(2)
  77. with col1:
  78. st.metric(tr("history.completed_count"), stats.get("completed", 0))
  79. with col2:
  80. st.metric(tr("history.failed_count"), stats.get("failed", 0))
  81. st.divider()
  82. # Filters
  83. st.markdown(f"**🔍 {tr('history.filter_status')}**")
  84. status_options = {
  85. "all": tr("history.status_all"),
  86. "completed": tr("history.status_completed"),
  87. "failed": tr("history.status_failed"),
  88. "running": tr("history.status_running"),
  89. "pending": tr("history.status_pending"),
  90. }
  91. selected_status = st.selectbox(
  92. tr("history.filter_status"),
  93. options=list(status_options.keys()),
  94. format_func=lambda x: status_options[x],
  95. key="filter_status",
  96. label_visibility="collapsed"
  97. )
  98. filter_status = None if selected_status == "all" else selected_status
  99. # Sort
  100. st.markdown(f"**📊 {tr('history.sort_by')}**")
  101. sort_options = {
  102. "created_at": tr("history.sort_created_at"),
  103. "completed_at": tr("history.sort_completed_at"),
  104. "title": tr("history.sort_title"),
  105. "duration": tr("history.sort_duration"),
  106. }
  107. sort_by = st.selectbox(
  108. tr("history.sort_by"),
  109. options=list(sort_options.keys()),
  110. format_func=lambda x: sort_options[x],
  111. key="sort_by",
  112. label_visibility="collapsed"
  113. )
  114. sort_order_options = {
  115. "desc": tr("history.sort_order_desc"),
  116. "asc": tr("history.sort_order_asc"),
  117. }
  118. sort_order = st.radio(
  119. "Sort Order",
  120. options=list(sort_order_options.keys()),
  121. format_func=lambda x: sort_order_options[x],
  122. key="sort_order",
  123. label_visibility="collapsed",
  124. horizontal=True
  125. )
  126. # Page size
  127. page_size = st.selectbox(
  128. tr("history.page_size"),
  129. options=[15, 30, 60],
  130. index=0,
  131. key="page_size"
  132. )
  133. return filter_status, sort_by, sort_order, page_size
  134. def render_grid_task_card(task: dict, pixelle_video):
  135. """Render a compact grid task card"""
  136. task_id = task["task_id"]
  137. title = task.get("title", "Untitled")
  138. status = task.get("status", "unknown")
  139. created_at = task.get("created_at", "")
  140. duration = task.get("duration", 0)
  141. n_frames = task.get("n_frames", 0)
  142. video_path = task.get("video_path", "")
  143. # Status badge
  144. status_map = {
  145. "completed": "✅",
  146. "failed": "❌",
  147. "running": "⏳",
  148. "pending": "⏸️",
  149. }
  150. status_icon = status_map.get(status, "❓")
  151. # Get input text
  152. detail = run_async(pixelle_video.history.get_task_detail(task_id))
  153. input_text = ""
  154. if detail and detail.get("metadata"):
  155. input_params = detail["metadata"].get("input", {})
  156. input_text = input_params.get("text", "")
  157. # Card container
  158. with st.container():
  159. # Video preview at top
  160. if video_path and os.path.exists(video_path):
  161. st.video(video_path, autoplay=False, loop=False, muted=False)
  162. else:
  163. st.markdown(
  164. f"<div style='background: #f0f0f0; height: 180px; display: flex; align-items: center; "
  165. f"justify-content: center; border-radius: 4px; font-size: 48px;'>📹</div>",
  166. unsafe_allow_html=True
  167. )
  168. # Title + Status (compact) - show actual title from task
  169. st.markdown(f"**{status_icon} {truncate_text(title, 50)}**")
  170. # Input content (very short)
  171. if input_text:
  172. st.caption(truncate_text(input_text, 60))
  173. # Meta info (one line)
  174. st.caption(f"🕒 {format_datetime(created_at)} | ⏱️ {format_duration(duration)} | 🎬 {n_frames}")
  175. # Action buttons (compact, 3 columns)
  176. col1, col2, col3 = st.columns(3)
  177. with col1:
  178. if st.button("👁️", key=f"view_{task_id}", help=tr("history.task_card.view_detail"), use_container_width=True):
  179. st.session_state[f"detail_{task_id}"] = True
  180. st.rerun()
  181. with col2:
  182. if video_path and os.path.exists(video_path):
  183. with open(video_path, "rb") as f:
  184. st.download_button(
  185. "⬇️",
  186. data=f,
  187. file_name=f"{title}.mp4",
  188. mime="video/mp4",
  189. key=f"download_{task_id}",
  190. help=tr("history.task_card.download"),
  191. use_container_width=True
  192. )
  193. else:
  194. st.button("⬇️", key=f"download_disabled_{task_id}", disabled=True, use_container_width=True)
  195. with col3:
  196. if st.button("🗑️", key=f"delete_{task_id}", help=tr("history.task_card.delete"), use_container_width=True):
  197. st.session_state[f"confirm_delete_{task_id}"] = True
  198. st.rerun()
  199. # Delete confirmation (show in modal-like way)
  200. if st.session_state.get(f"confirm_delete_{task_id}", False):
  201. st.warning("⚠️ 确认删除?")
  202. col1, col2 = st.columns(2)
  203. with col1:
  204. if st.button("✅", key=f"confirm_yes_{task_id}", use_container_width=True):
  205. try:
  206. success = run_async(pixelle_video.history.delete_task(task_id))
  207. if success:
  208. st.success(tr("history.action.delete_success"))
  209. st.session_state[f"confirm_delete_{task_id}"] = False
  210. st.rerun()
  211. else:
  212. st.error("删除失败")
  213. except Exception as e:
  214. st.error(f"删除失败: {str(e)}")
  215. with col2:
  216. if st.button("❌", key=f"confirm_no_{task_id}", use_container_width=True):
  217. st.session_state[f"confirm_delete_{task_id}"] = False
  218. st.rerun()
  219. def render_task_detail_modal(task_id: str, pixelle_video):
  220. """Render task detail in three-column layout"""
  221. detail = run_async(pixelle_video.history.get_task_detail(task_id))
  222. if not detail:
  223. st.error("Task not found")
  224. return
  225. metadata = detail["metadata"]
  226. storyboard = detail["storyboard"]
  227. # Close button at the top
  228. if st.button("❌ " + tr("history.detail.close"), key=f"close_detail_top_{task_id}"):
  229. st.session_state[f"detail_{task_id}"] = False
  230. st.rerun()
  231. st.markdown(f"**{tr('history.detail.modal_title')}**")
  232. st.caption(f"{tr('history.detail.task_id')}: {task_id}")
  233. # Three-column layout
  234. col_input, col_storyboard, col_video = st.columns([1, 1, 1])
  235. # Left column: Input and config
  236. with col_input:
  237. st.markdown(f"**📝 {tr('history.detail.input_params')}**")
  238. input_params = metadata.get("input", {})
  239. # Display input parameters
  240. st.markdown(f"**{tr('history.detail.mode')}:** {input_params.get('mode', 'N/A')}")
  241. st.markdown(f"**{tr('history.detail.n_scenes')}:** {input_params.get('n_scenes', 'N/A')}")
  242. st.markdown(f"**{tr('history.detail.tts_mode')}:** {input_params.get('tts_inference_mode', 'N/A')}")
  243. st.markdown(f"**{tr('history.detail.voice')}:** {input_params.get('tts_voice', 'N/A')}")
  244. # Input text
  245. with st.expander(tr("history.detail.text"), expanded=True):
  246. st.text_area(
  247. "Input Text",
  248. value=input_params.get('text', 'N/A'),
  249. height=200,
  250. disabled=True,
  251. label_visibility="collapsed"
  252. )
  253. # Middle column: Storyboard frames
  254. with col_storyboard:
  255. st.markdown(f"**🎬 {tr('history.detail.storyboard')}**")
  256. if storyboard and storyboard.frames:
  257. for frame in storyboard.frames:
  258. with st.expander(f"{tr('history.detail.frame')} {frame.index + 1}", expanded=False):
  259. st.markdown(f"**{tr('history.detail.narration')}:**")
  260. st.caption(frame.narration)
  261. if frame.image_prompt:
  262. st.markdown(f"**{tr('history.detail.image_prompt')}:**")
  263. st.caption(frame.image_prompt)
  264. # Show frame preview (small)
  265. col1, col2 = st.columns(2)
  266. with col1:
  267. if frame.composed_image_path and os.path.exists(frame.composed_image_path):
  268. st.image(frame.composed_image_path)
  269. elif frame.image_path and os.path.exists(frame.image_path):
  270. st.image(frame.image_path)
  271. with col2:
  272. if frame.video_segment_path and os.path.exists(frame.video_segment_path):
  273. st.video(frame.video_segment_path)
  274. # Audio player (compact)
  275. if frame.audio_path and os.path.exists(frame.audio_path):
  276. st.audio(frame.audio_path)
  277. else:
  278. st.info("No storyboard data")
  279. # Right column: Final video
  280. with col_video:
  281. st.markdown(f"**🎥 {tr('info.video_information')}**")
  282. video_path = metadata.get("result", {}).get("video_path")
  283. if video_path and os.path.exists(video_path):
  284. st.video(video_path)
  285. # Video info
  286. result = metadata.get("result", {})
  287. st.markdown(f"**{tr('info.duration')}:** {format_duration(result.get('duration', 0))}")
  288. st.markdown(f"**{tr('info.frames')}:** {result.get('n_frames', 0)}")
  289. st.markdown(f"**{tr('info.file_size')}:** {format_file_size(result.get('file_size', 0))}")
  290. # Download button
  291. with open(video_path, "rb") as f:
  292. # Get title from input (which now includes the generated title)
  293. title = metadata.get("input", {}).get("title", "video")
  294. if not title:
  295. title = "video"
  296. st.download_button(
  297. tr("history.detail.download_video"),
  298. data=f,
  299. file_name=f"{title}.mp4",
  300. mime="video/mp4",
  301. use_container_width=True
  302. )
  303. else:
  304. st.warning("Video file not found")
  305. st.divider()
  306. # Close button at the bottom
  307. if st.button("❌ " + tr("history.detail.close"), key=f"close_detail_bottom_{task_id}"):
  308. st.session_state[f"detail_{task_id}"] = False
  309. st.rerun()
  310. def main():
  311. """Main entry point for History page"""
  312. # Initialize
  313. init_session_state()
  314. init_i18n()
  315. # Render header
  316. render_header()
  317. # Initialize Pixelle-Video
  318. pixelle_video = get_pixelle_video()
  319. # Sidebar: Statistics + Filters
  320. filter_status, sort_by, sort_order, page_size = render_sidebar_controls(pixelle_video)
  321. # Initialize pagination in session state
  322. if "history_page" not in st.session_state:
  323. st.session_state.history_page = 1
  324. # Check if we need to show a detail view
  325. show_detail_for = None
  326. for key in st.session_state.keys():
  327. if key.startswith("detail_") and st.session_state[key]:
  328. show_detail_for = key.replace("detail_", "")
  329. break
  330. # If showing detail, render it
  331. if show_detail_for:
  332. render_task_detail_modal(show_detail_for, pixelle_video)
  333. return
  334. # Otherwise, show the grid list
  335. # Get task list
  336. result = run_async(pixelle_video.history.get_task_list(
  337. page=st.session_state.history_page,
  338. page_size=page_size,
  339. status=filter_status,
  340. sort_by=sort_by,
  341. sort_order=sort_order
  342. ))
  343. tasks = result["tasks"]
  344. total = result["total"]
  345. total_pages = result["total_pages"]
  346. # Page title with count
  347. st.markdown(f"##### 📚 {tr('history.page_title')} ({total})")
  348. # Show task cards in grid layout (4 columns)
  349. if not tasks:
  350. st.info(tr("history.no_tasks"))
  351. else:
  352. # Grid layout: 4 cards per row
  353. CARDS_PER_ROW = 4
  354. # Process tasks in batches of CARDS_PER_ROW
  355. for i in range(0, len(tasks), CARDS_PER_ROW):
  356. cols = st.columns(CARDS_PER_ROW)
  357. # Fill each column with a task card
  358. for j in range(CARDS_PER_ROW):
  359. task_idx = i + j
  360. if task_idx < len(tasks):
  361. with cols[j]:
  362. render_grid_task_card(tasks[task_idx], pixelle_video)
  363. # Pagination
  364. if total_pages > 1:
  365. st.divider()
  366. col1, col2, col3 = st.columns([1, 2, 1])
  367. with col1:
  368. if st.button("⬅️ Previous", disabled=st.session_state.history_page == 1, use_container_width=True):
  369. st.session_state.history_page -= 1
  370. st.rerun()
  371. with col2:
  372. st.markdown(
  373. f"<div style='text-align: center; padding-top: 8px;'>"
  374. f"{tr('history.page_info').format(page=st.session_state.history_page, total_pages=total_pages)}"
  375. f"</div>",
  376. unsafe_allow_html=True
  377. )
  378. with col3:
  379. if st.button("Next ➡️", disabled=st.session_state.history_page == total_pages, use_container_width=True):
  380. st.session_state.history_page += 1
  381. st.rerun()
  382. if __name__ == "__main__":
  383. main()