489 lines
17 KiB
Python
489 lines
17 KiB
Python
# Copyright (C) 2025 AIDC-AI
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""
|
|
History Page - View generation history and manage tasks
|
|
"""
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
import os
|
|
|
|
# Add project root to sys.path
|
|
_script_dir = Path(__file__).resolve().parent
|
|
_project_root = _script_dir.parent.parent
|
|
if str(_project_root) not in sys.path:
|
|
sys.path.insert(0, str(_project_root))
|
|
|
|
import streamlit as st
|
|
from loguru import logger
|
|
|
|
from web.state.session import init_session_state, init_i18n, get_pixelle_video
|
|
from web.components.header import render_header
|
|
from web.i18n import tr
|
|
from web.utils.async_helpers import run_async
|
|
|
|
# Page config
|
|
st.set_page_config(
|
|
page_title="History - Pixelle-Video",
|
|
page_icon="📚",
|
|
layout="wide",
|
|
)
|
|
|
|
|
|
def format_duration(seconds: float) -> str:
|
|
"""Format duration in seconds to readable string"""
|
|
if seconds < 60:
|
|
return f"{seconds:.1f}s"
|
|
elif seconds < 3600:
|
|
minutes = int(seconds / 60)
|
|
secs = int(seconds % 60)
|
|
return f"{minutes}m {secs}s"
|
|
else:
|
|
hours = int(seconds / 3600)
|
|
minutes = int((seconds % 3600) / 60)
|
|
return f"{hours}h {minutes}m"
|
|
|
|
|
|
def format_file_size(bytes_size: int) -> str:
|
|
"""Format file size in bytes to readable string"""
|
|
if bytes_size < 1024:
|
|
return f"{bytes_size}B"
|
|
elif bytes_size < 1024 * 1024:
|
|
return f"{bytes_size / 1024:.1f}KB"
|
|
elif bytes_size < 1024 * 1024 * 1024:
|
|
return f"{bytes_size / 1024 / 1024:.1f}MB"
|
|
else:
|
|
return f"{bytes_size / 1024 / 1024 / 1024:.2f}GB"
|
|
|
|
|
|
def format_datetime(iso_string: str) -> str:
|
|
"""Format ISO datetime string to readable format"""
|
|
try:
|
|
dt = datetime.fromisoformat(iso_string)
|
|
return dt.strftime("%m-%d %H:%M")
|
|
except:
|
|
return iso_string
|
|
|
|
|
|
def truncate_text(text: str, max_length: int = 60) -> str:
|
|
"""Truncate text to max length"""
|
|
if len(text) <= max_length:
|
|
return text
|
|
return text[:max_length] + "..."
|
|
|
|
|
|
def render_sidebar_controls(pixelle_video):
|
|
"""Render sidebar with statistics and filters"""
|
|
with st.sidebar:
|
|
# Statistics
|
|
st.markdown(f"**📊 {tr('history.total_tasks')}**")
|
|
stats = run_async(pixelle_video.history.get_statistics())
|
|
|
|
col1, col2 = st.columns(2)
|
|
with col1:
|
|
st.metric(tr("history.completed_count"), stats.get("completed", 0))
|
|
with col2:
|
|
st.metric(tr("history.failed_count"), stats.get("failed", 0))
|
|
|
|
st.divider()
|
|
|
|
# Filters
|
|
st.markdown(f"**🔍 {tr('history.filter_status')}**")
|
|
status_options = {
|
|
"all": tr("history.status_all"),
|
|
"completed": tr("history.status_completed"),
|
|
"failed": tr("history.status_failed"),
|
|
"running": tr("history.status_running"),
|
|
"pending": tr("history.status_pending"),
|
|
}
|
|
|
|
selected_status = st.selectbox(
|
|
tr("history.filter_status"),
|
|
options=list(status_options.keys()),
|
|
format_func=lambda x: status_options[x],
|
|
key="filter_status",
|
|
label_visibility="collapsed"
|
|
)
|
|
|
|
filter_status = None if selected_status == "all" else selected_status
|
|
|
|
# Sort
|
|
st.markdown(f"**📊 {tr('history.sort_by')}**")
|
|
|
|
sort_options = {
|
|
"created_at": tr("history.sort_created_at"),
|
|
"completed_at": tr("history.sort_completed_at"),
|
|
"title": tr("history.sort_title"),
|
|
"duration": tr("history.sort_duration"),
|
|
}
|
|
|
|
sort_by = st.selectbox(
|
|
tr("history.sort_by"),
|
|
options=list(sort_options.keys()),
|
|
format_func=lambda x: sort_options[x],
|
|
key="sort_by",
|
|
label_visibility="collapsed"
|
|
)
|
|
|
|
sort_order_options = {
|
|
"desc": tr("history.sort_order_desc"),
|
|
"asc": tr("history.sort_order_asc"),
|
|
}
|
|
|
|
sort_order = st.radio(
|
|
"Sort Order",
|
|
options=list(sort_order_options.keys()),
|
|
format_func=lambda x: sort_order_options[x],
|
|
key="sort_order",
|
|
label_visibility="collapsed",
|
|
horizontal=True
|
|
)
|
|
|
|
# Page size
|
|
page_size = st.selectbox(
|
|
tr("history.page_size"),
|
|
options=[15, 30, 60],
|
|
index=0,
|
|
key="page_size"
|
|
)
|
|
|
|
return filter_status, sort_by, sort_order, page_size
|
|
|
|
|
|
def render_grid_task_card(task: dict, pixelle_video):
|
|
"""Render a compact grid task card"""
|
|
task_id = task["task_id"]
|
|
title = task.get("title", "Untitled")
|
|
status = task.get("status", "unknown")
|
|
created_at = task.get("created_at", "")
|
|
duration = task.get("duration", 0)
|
|
n_frames = task.get("n_frames", 0)
|
|
video_path = task.get("video_path", "")
|
|
|
|
# Status badge
|
|
status_map = {
|
|
"completed": "✅",
|
|
"failed": "❌",
|
|
"running": "⏳",
|
|
"pending": "⏸️",
|
|
}
|
|
status_icon = status_map.get(status, "❓")
|
|
|
|
# Get input text
|
|
detail = run_async(pixelle_video.history.get_task_detail(task_id))
|
|
input_text = ""
|
|
if detail and detail.get("metadata"):
|
|
input_params = detail["metadata"].get("input", {})
|
|
input_text = input_params.get("text", "")
|
|
|
|
# Card container
|
|
with st.container():
|
|
# Video preview at top
|
|
if video_path and os.path.exists(video_path):
|
|
st.video(video_path, autoplay=False, loop=False, muted=False)
|
|
else:
|
|
st.markdown(
|
|
f"<div style='background: #f0f0f0; height: 180px; display: flex; align-items: center; "
|
|
f"justify-content: center; border-radius: 4px; font-size: 48px;'>📹</div>",
|
|
unsafe_allow_html=True
|
|
)
|
|
|
|
# Title + Status (compact) - show actual title from task
|
|
st.markdown(f"**{status_icon} {truncate_text(title, 50)}**")
|
|
|
|
# Input content (very short)
|
|
if input_text:
|
|
st.caption(truncate_text(input_text, 60))
|
|
|
|
# Meta info (one line)
|
|
st.caption(f"🕒 {format_datetime(created_at)} | ⏱️ {format_duration(duration)} | 🎬 {n_frames}")
|
|
|
|
# Action buttons (compact, 3 columns)
|
|
col1, col2, col3 = st.columns(3)
|
|
|
|
with col1:
|
|
if st.button("👁️", key=f"view_{task_id}", help=tr("history.task_card.view_detail"), use_container_width=True):
|
|
st.session_state[f"detail_{task_id}"] = True
|
|
st.rerun()
|
|
|
|
with col2:
|
|
if video_path and os.path.exists(video_path):
|
|
with open(video_path, "rb") as f:
|
|
st.download_button(
|
|
"⬇️",
|
|
data=f,
|
|
file_name=f"{title}.mp4",
|
|
mime="video/mp4",
|
|
key=f"download_{task_id}",
|
|
help=tr("history.task_card.download"),
|
|
use_container_width=True
|
|
)
|
|
else:
|
|
st.button("⬇️", key=f"download_disabled_{task_id}", disabled=True, use_container_width=True)
|
|
|
|
with col3:
|
|
if st.button("🗑️", key=f"delete_{task_id}", help=tr("history.task_card.delete"), use_container_width=True):
|
|
st.session_state[f"confirm_delete_{task_id}"] = True
|
|
st.rerun()
|
|
|
|
# Delete confirmation (show in modal-like way)
|
|
if st.session_state.get(f"confirm_delete_{task_id}", False):
|
|
st.warning("⚠️ 确认删除?")
|
|
col1, col2 = st.columns(2)
|
|
with col1:
|
|
if st.button("✅", key=f"confirm_yes_{task_id}", use_container_width=True):
|
|
try:
|
|
success = run_async(pixelle_video.history.delete_task(task_id))
|
|
if success:
|
|
st.success(tr("history.action.delete_success"))
|
|
st.session_state[f"confirm_delete_{task_id}"] = False
|
|
st.rerun()
|
|
else:
|
|
st.error("删除失败")
|
|
except Exception as e:
|
|
st.error(f"删除失败: {str(e)}")
|
|
with col2:
|
|
if st.button("❌", key=f"confirm_no_{task_id}", use_container_width=True):
|
|
st.session_state[f"confirm_delete_{task_id}"] = False
|
|
st.rerun()
|
|
|
|
|
|
def render_task_detail_modal(task_id: str, pixelle_video):
|
|
"""Render task detail in three-column layout"""
|
|
detail = run_async(pixelle_video.history.get_task_detail(task_id))
|
|
|
|
if not detail:
|
|
st.error("Task not found")
|
|
return
|
|
|
|
metadata = detail["metadata"]
|
|
storyboard = detail["storyboard"]
|
|
|
|
# Close button at the top
|
|
if st.button("❌ " + tr("history.detail.close"), key=f"close_detail_top_{task_id}"):
|
|
st.session_state[f"detail_{task_id}"] = False
|
|
st.rerun()
|
|
|
|
st.markdown(f"**{tr('history.detail.modal_title')}**")
|
|
st.caption(f"{tr('history.detail.task_id')}: {task_id}")
|
|
|
|
# Three-column layout
|
|
col_input, col_storyboard, col_video = st.columns([1, 1, 1])
|
|
|
|
# Left column: Input and config
|
|
with col_input:
|
|
st.markdown(f"**📝 {tr('history.detail.input_params')}**")
|
|
|
|
input_params = metadata.get("input", {})
|
|
|
|
# Display input parameters
|
|
st.markdown(f"**{tr('history.detail.mode')}:** {input_params.get('mode', 'N/A')}")
|
|
st.markdown(f"**{tr('history.detail.n_scenes')}:** {input_params.get('n_scenes', 'N/A')}")
|
|
st.markdown(f"**{tr('history.detail.tts_mode')}:** {input_params.get('tts_inference_mode', 'N/A')}")
|
|
st.markdown(f"**{tr('history.detail.voice')}:** {input_params.get('tts_voice', 'N/A')}")
|
|
|
|
# Input text
|
|
with st.expander(tr("history.detail.text"), expanded=True):
|
|
st.text_area(
|
|
"Input Text",
|
|
value=input_params.get('text', 'N/A'),
|
|
height=200,
|
|
disabled=True,
|
|
label_visibility="collapsed"
|
|
)
|
|
|
|
# Middle column: Storyboard frames
|
|
with col_storyboard:
|
|
st.markdown(f"**🎬 {tr('history.detail.storyboard')}**")
|
|
|
|
if storyboard and storyboard.frames:
|
|
for frame in storyboard.frames:
|
|
with st.expander(f"{tr('history.detail.frame')} {frame.index + 1}", expanded=False):
|
|
st.markdown(f"**{tr('history.detail.narration')}:**")
|
|
st.caption(frame.narration)
|
|
|
|
if frame.image_prompt:
|
|
st.markdown(f"**{tr('history.detail.image_prompt')}:**")
|
|
st.caption(frame.image_prompt)
|
|
|
|
# Show frame preview (small)
|
|
col1, col2 = st.columns(2)
|
|
with col1:
|
|
if frame.composed_image_path and os.path.exists(frame.composed_image_path):
|
|
st.image(frame.composed_image_path)
|
|
elif frame.image_path and os.path.exists(frame.image_path):
|
|
st.image(frame.image_path)
|
|
with col2:
|
|
if frame.video_segment_path and os.path.exists(frame.video_segment_path):
|
|
st.video(frame.video_segment_path)
|
|
|
|
# Audio player (compact)
|
|
if frame.audio_path and os.path.exists(frame.audio_path):
|
|
st.audio(frame.audio_path)
|
|
else:
|
|
st.info("No storyboard data")
|
|
|
|
# Right column: Final video
|
|
with col_video:
|
|
st.markdown(f"**🎥 {tr('info.video_information')}**")
|
|
|
|
video_path = metadata.get("result", {}).get("video_path")
|
|
if video_path and os.path.exists(video_path):
|
|
st.video(video_path)
|
|
|
|
# Video info
|
|
result = metadata.get("result", {})
|
|
st.markdown(f"**{tr('info.duration')}:** {format_duration(result.get('duration', 0))}")
|
|
st.markdown(f"**{tr('info.frames')}:** {result.get('n_frames', 0)}")
|
|
st.markdown(f"**{tr('info.file_size')}:** {format_file_size(result.get('file_size', 0))}")
|
|
|
|
# Download button
|
|
with open(video_path, "rb") as f:
|
|
# Get title from input (which now includes the generated title)
|
|
title = metadata.get("input", {}).get("title", "video")
|
|
if not title:
|
|
title = "video"
|
|
st.download_button(
|
|
tr("history.detail.download_video"),
|
|
data=f,
|
|
file_name=f"{title}.mp4",
|
|
mime="video/mp4",
|
|
use_container_width=True
|
|
)
|
|
|
|
# Open in Editor button
|
|
editor_url = f"http://localhost:3000/editor?storyboard_id={task_id}"
|
|
st.markdown(
|
|
f'''
|
|
<a href="{editor_url}" target="_blank" style="text-decoration: none;">
|
|
<button style="
|
|
width: 100%;
|
|
padding: 0.5rem 1rem;
|
|
background-color: #262730;
|
|
color: white;
|
|
border: 1px solid #262730;
|
|
border-radius: 0.5rem;
|
|
cursor: pointer;
|
|
margin-top: 0.5rem;
|
|
">
|
|
✏️ 在编辑器中打开
|
|
</button>
|
|
</a>
|
|
''',
|
|
unsafe_allow_html=True
|
|
)
|
|
else:
|
|
st.warning("Video file not found")
|
|
|
|
st.divider()
|
|
|
|
# Close button at the bottom
|
|
if st.button("❌ " + tr("history.detail.close"), key=f"close_detail_bottom_{task_id}"):
|
|
st.session_state[f"detail_{task_id}"] = False
|
|
st.rerun()
|
|
|
|
|
|
def main():
|
|
"""Main entry point for History page"""
|
|
# Initialize
|
|
init_session_state()
|
|
init_i18n()
|
|
|
|
# Render header
|
|
render_header()
|
|
|
|
# Initialize Pixelle-Video
|
|
pixelle_video = get_pixelle_video()
|
|
|
|
# Sidebar: Statistics + Filters
|
|
filter_status, sort_by, sort_order, page_size = render_sidebar_controls(pixelle_video)
|
|
|
|
# Initialize pagination in session state
|
|
if "history_page" not in st.session_state:
|
|
st.session_state.history_page = 1
|
|
|
|
# Check if we need to show a detail view
|
|
show_detail_for = None
|
|
for key in st.session_state.keys():
|
|
if key.startswith("detail_") and st.session_state[key]:
|
|
show_detail_for = key.replace("detail_", "")
|
|
break
|
|
|
|
# If showing detail, render it
|
|
if show_detail_for:
|
|
render_task_detail_modal(show_detail_for, pixelle_video)
|
|
return
|
|
|
|
# Otherwise, show the grid list
|
|
# Get task list
|
|
result = run_async(pixelle_video.history.get_task_list(
|
|
page=st.session_state.history_page,
|
|
page_size=page_size,
|
|
status=filter_status,
|
|
sort_by=sort_by,
|
|
sort_order=sort_order
|
|
))
|
|
|
|
tasks = result["tasks"]
|
|
total = result["total"]
|
|
total_pages = result["total_pages"]
|
|
|
|
# Page title with count
|
|
st.markdown(f"##### 📚 {tr('history.page_title')} ({total})")
|
|
|
|
# Show task cards in grid layout (4 columns)
|
|
if not tasks:
|
|
st.info(tr("history.no_tasks"))
|
|
else:
|
|
# Grid layout: 4 cards per row
|
|
CARDS_PER_ROW = 4
|
|
|
|
# Process tasks in batches of CARDS_PER_ROW
|
|
for i in range(0, len(tasks), CARDS_PER_ROW):
|
|
cols = st.columns(CARDS_PER_ROW)
|
|
|
|
# Fill each column with a task card
|
|
for j in range(CARDS_PER_ROW):
|
|
task_idx = i + j
|
|
if task_idx < len(tasks):
|
|
with cols[j]:
|
|
render_grid_task_card(tasks[task_idx], pixelle_video)
|
|
|
|
# Pagination
|
|
if total_pages > 1:
|
|
st.divider()
|
|
col1, col2, col3 = st.columns([1, 2, 1])
|
|
|
|
with col1:
|
|
if st.button("⬅️ Previous", disabled=st.session_state.history_page == 1, use_container_width=True):
|
|
st.session_state.history_page -= 1
|
|
st.rerun()
|
|
|
|
with col2:
|
|
st.markdown(
|
|
f"<div style='text-align: center; padding-top: 8px;'>"
|
|
f"{tr('history.page_info').format(page=st.session_state.history_page, total_pages=total_pages)}"
|
|
f"</div>",
|
|
unsafe_allow_html=True
|
|
)
|
|
|
|
with col3:
|
|
if st.button("Next ➡️", disabled=st.session_state.history_page == total_pages, use_container_width=True):
|
|
st.session_state.history_page += 1
|
|
st.rerun()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|