返回 Pixelle-Video
2_📚_History.py
根目录 / web / pages / 2_📚_History.py
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
467 lines PYTHON