支持历史生成结果的呈现逻辑

This commit is contained in:
puke
2025-11-18 15:52:27 +08:00
parent 9c01c3a9a5
commit dfa2f76c5b
11 changed files with 1198 additions and 64 deletions

View File

@@ -13,14 +13,14 @@
"""
Pixelle-Video Web UI - Main Entry Point
A modular web interface for generating short videos from content.
This is the entry point for the Streamlit multi-page application.
Uses st.navigation to define pages and set the default page to Home.
"""
import sys
from pathlib import Path
# Add project root to sys.path for module imports
# This ensures imports work correctly in both development and packaged environments
_script_dir = Path(__file__).resolve().parent
_project_root = _script_dir.parent
if str(_project_root) not in sys.path:
@@ -28,16 +28,6 @@ if str(_project_root) not in sys.path:
import streamlit as st
# Import state management
from web.state.session import init_session_state, init_i18n, get_pixelle_video
# Import components
from web.components.header import render_header
from web.components.settings import render_advanced_settings
from web.components.content_input import render_content_input, render_bgm_section, render_version_info
from web.components.style_config import render_style_config
from web.components.output_preview import render_output_preview
# Setup page config (must be first Streamlit command)
st.set_page_config(
page_title="Pixelle-Video - AI Video Generator",
@@ -48,56 +38,24 @@ st.set_page_config(
def main():
"""Main UI entry point"""
# Initialize session state and i18n
init_session_state()
init_i18n()
"""Main entry point with navigation"""
# Define pages using st.Page
home_page = st.Page(
"pages/1_🎬_Home.py",
title="Home",
icon="🎬",
default=True
)
# Render header (title + language selector)
render_header()
history_page = st.Page(
"pages/2_📚_History.py",
title="History",
icon="📚"
)
# Initialize Pixelle-Video
pixelle_video = get_pixelle_video()
# Render system configuration (LLM + ComfyUI)
render_advanced_settings()
# Three-column layout
left_col, middle_col, right_col = st.columns([1, 1, 1])
# ========================================================================
# Left Column: Content Input & BGM
# ========================================================================
with left_col:
# Content input (mode, text, title, n_scenes)
content_params = render_content_input()
# BGM selection (bgm_path, bgm_volume)
bgm_params = render_bgm_section()
# Version info & GitHub link
render_version_info()
# ========================================================================
# Middle Column: Style Configuration
# ========================================================================
with middle_col:
# Style configuration (TTS, template, workflow, etc.)
style_params = render_style_config(pixelle_video)
# ========================================================================
# Right Column: Output Preview
# ========================================================================
with right_col:
# Combine all parameters
video_params = {
**content_params,
**bgm_params,
**style_params
}
# Render output preview (generate button, progress, video preview)
render_output_preview(pixelle_video, video_params)
# Set up navigation and run
pg = st.navigation([home_page, history_page])
pg.run()
if __name__ == "__main__":

View File

@@ -262,7 +262,65 @@
"version.title": "📦 Version Info",
"version.current": "Current Version",
"github.title": "⭐ Open Source Support"
"github.title": "⭐ Open Source Support",
"history.page_title": "📚 Generation History",
"history.total_tasks": "Total Tasks",
"history.completed_count": "Completed",
"history.failed_count": "Failed",
"history.total_duration": "Total Duration",
"history.total_size": "Total Size",
"history.filter_status": "Filter Status",
"history.status_all": "All",
"history.status_completed": "Completed",
"history.status_failed": "Failed",
"history.status_running": "Running",
"history.status_pending": "Pending",
"history.sort_by": "Sort By",
"history.sort_created_at": "Created Time",
"history.sort_completed_at": "Completed Time",
"history.sort_title": "Title",
"history.sort_duration": "Duration",
"history.sort_order_desc": "Descending",
"history.sort_order_asc": "Ascending",
"history.page_size": "Page Size",
"history.no_tasks": "No tasks yet",
"history.task_card.title": "Title",
"history.task_card.created_at": "Created",
"history.task_card.duration": "Duration",
"history.task_card.frames": "Frames",
"history.task_card.view_detail": "View Detail",
"history.task_card.duplicate": "Duplicate",
"history.task_card.delete": "Delete",
"history.task_card.download": "Download",
"history.task_card.status_completed": "✅ Completed",
"history.task_card.status_failed": "❌ Failed",
"history.task_card.status_running": "⏳ Running",
"history.task_card.status_pending": "⏸️ Pending",
"history.detail.modal_title": "Task Detail",
"history.detail.task_id": "Task ID",
"history.detail.input_params": "Input Parameters",
"history.detail.text": "Text",
"history.detail.mode": "Mode",
"history.detail.n_scenes": "Scenes",
"history.detail.tts_mode": "TTS Mode",
"history.detail.voice": "Voice",
"history.detail.storyboard": "Storyboard",
"history.detail.frame_index": "Frame {index}",
"history.detail.frame": "Frame",
"history.detail.download_video": "Download Video",
"history.detail.narration": "Narration",
"history.detail.image_prompt": "Image Prompt",
"history.detail.audio_path": "Audio",
"history.detail.image_path": "Image",
"history.detail.video_segment_path": "Video Segment",
"history.detail.close": "Close",
"history.action.duplicate_success": "✅ Parameters duplicated, redirecting...",
"history.action.duplicate_failed": "❌ Duplication failed: {error}",
"history.action.delete_confirm": "Confirm deletion? This action cannot be undone!",
"history.action.delete_success": "✅ Task deleted",
"history.action.delete_failed": "❌ Deletion failed: {error}",
"history.page_info": "Page {page} / {total_pages}"
}
}

View File

@@ -262,7 +262,65 @@
"version.title": "📦 版本信息",
"version.current": "当前版本",
"github.title": "⭐ 开源支持"
"github.title": "⭐ 开源支持",
"history.page_title": "📚 生成历史",
"history.total_tasks": "总任务数",
"history.completed_count": "已完成",
"history.failed_count": "失败",
"history.total_duration": "总时长",
"history.total_size": "总大小",
"history.filter_status": "状态筛选",
"history.status_all": "全部",
"history.status_completed": "已完成",
"history.status_failed": "失败",
"history.status_running": "进行中",
"history.status_pending": "等待中",
"history.sort_by": "排序方式",
"history.sort_created_at": "创建时间",
"history.sort_completed_at": "完成时间",
"history.sort_title": "标题",
"history.sort_duration": "时长",
"history.sort_order_desc": "降序",
"history.sort_order_asc": "升序",
"history.page_size": "每页显示",
"history.no_tasks": "暂无任务记录",
"history.task_card.title": "标题",
"history.task_card.created_at": "创建时间",
"history.task_card.duration": "时长",
"history.task_card.frames": "分镜数",
"history.task_card.view_detail": "查看详情",
"history.task_card.duplicate": "复制参数",
"history.task_card.delete": "删除",
"history.task_card.download": "下载视频",
"history.task_card.status_completed": "✅ 已完成",
"history.task_card.status_failed": "❌ 失败",
"history.task_card.status_running": "⏳ 进行中",
"history.task_card.status_pending": "⏸️ 等待中",
"history.detail.modal_title": "任务详情",
"history.detail.task_id": "任务 ID",
"history.detail.input_params": "输入参数",
"history.detail.text": "文本",
"history.detail.mode": "模式",
"history.detail.n_scenes": "分镜数",
"history.detail.tts_mode": "TTS 模式",
"history.detail.voice": "语音",
"history.detail.storyboard": "故事板",
"history.detail.frame_index": "分镜 {index}",
"history.detail.frame": "分镜",
"history.detail.download_video": "下载视频",
"history.detail.narration": "旁白",
"history.detail.image_prompt": "图片提示词",
"history.detail.audio_path": "音频",
"history.detail.image_path": "图片",
"history.detail.video_segment_path": "视频片段",
"history.detail.close": "关闭",
"history.action.duplicate_success": "✅ 参数已复制,跳转至首页...",
"history.action.duplicate_failed": "❌ 复制失败:{error}",
"history.action.delete_confirm": "确认删除该任务?此操作无法撤销!",
"history.action.delete_success": "✅ 任务已删除",
"history.action.delete_failed": "❌ 删除失败:{error}",
"history.page_info": "第 {page} 页 / 共 {total_pages} 页"
}
}

102
web/pages/1_🎬_Home.py Normal file
View File

@@ -0,0 +1,102 @@
# 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.
"""
Home Page - Main video generation interface
"""
import sys
from pathlib import Path
# 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
# Import state management
from web.state.session import init_session_state, init_i18n, get_pixelle_video
# Import components
from web.components.header import render_header
from web.components.settings import render_advanced_settings
from web.components.content_input import render_content_input, render_bgm_section, render_version_info
from web.components.style_config import render_style_config
from web.components.output_preview import render_output_preview
# Page config
st.set_page_config(
page_title="Home - Pixelle-Video",
page_icon="🎬",
layout="wide",
initial_sidebar_state="collapsed",
)
def main():
"""Main UI entry point"""
# Initialize session state and i18n
init_session_state()
init_i18n()
# Render header (title + language selector)
render_header()
# Initialize Pixelle-Video
pixelle_video = get_pixelle_video()
# Render system configuration (LLM + ComfyUI)
render_advanced_settings()
# Three-column layout
left_col, middle_col, right_col = st.columns([1, 1, 1])
# ========================================================================
# Left Column: Content Input & BGM
# ========================================================================
with left_col:
# Content input (mode, text, title, n_scenes)
content_params = render_content_input()
# BGM selection (bgm_path, bgm_volume)
bgm_params = render_bgm_section()
# Version info & GitHub link
render_version_info()
# ========================================================================
# Middle Column: Style Configuration
# ========================================================================
with middle_col:
# Style configuration (TTS, template, workflow, etc.)
style_params = render_style_config(pixelle_video)
# ========================================================================
# Right Column: Output Preview
# ========================================================================
with right_col:
# Combine all parameters
video_params = {
**content_params,
**bgm_params,
**style_params
}
# Render output preview (generate button, progress, video preview)
render_output_preview(pixelle_video, video_params)
if __name__ == "__main__":
main()

466
web/pages/2_📚_History.py Normal file
View File

@@ -0,0 +1,466 @@
# 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
)
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()