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