feat: Add comprehensive timeline editor with frame editing and regeneration capabilities
This commit is contained in:
@@ -53,8 +53,14 @@ def main():
|
||||
icon="📚"
|
||||
)
|
||||
|
||||
tasks_page = st.Page(
|
||||
"pages/3_📋_Tasks.py",
|
||||
title="Tasks",
|
||||
icon="📋"
|
||||
)
|
||||
|
||||
# Set up navigation and run
|
||||
pg = st.navigation([home_page, history_page])
|
||||
pg = st.navigation([home_page, history_page, tasks_page])
|
||||
pg.run()
|
||||
|
||||
|
||||
|
||||
163
web/components/character_memory.py
Normal file
163
web/components/character_memory.py
Normal file
@@ -0,0 +1,163 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Character Memory component for web UI
|
||||
"""
|
||||
|
||||
import streamlit as st
|
||||
from typing import List
|
||||
|
||||
from web.i18n import tr
|
||||
from web.utils.streamlit_helpers import safe_rerun
|
||||
from pixelle_video.services.quality import CharacterMemory, Character, CharacterType
|
||||
|
||||
|
||||
def render_active_characters_badge():
|
||||
"""Render a small badge showing active characters count (for Home page)"""
|
||||
if "character_memory" not in st.session_state:
|
||||
return
|
||||
|
||||
memory = st.session_state.character_memory
|
||||
active_count = sum(1 for c in memory.characters if getattr(c, 'is_active', True))
|
||||
|
||||
if active_count > 0:
|
||||
st.info(f"👤 {tr('character.registered_count', count=active_count)} (Active)", icon="✨")
|
||||
|
||||
|
||||
def render_character_memory():
|
||||
"""Render the character memory management interface"""
|
||||
st.markdown(f"### {tr('character.title')}")
|
||||
st.caption(tr("character.desc"))
|
||||
|
||||
# Initialize CharacterMemory in session state if not present
|
||||
if "character_memory" not in st.session_state:
|
||||
st.session_state.character_memory = CharacterMemory()
|
||||
|
||||
memory = st.session_state.character_memory
|
||||
|
||||
# --- Character Registration Form ---
|
||||
with st.expander(tr("character.add_button"), expanded=False):
|
||||
with st.form("add_character_form", clear_on_submit=True):
|
||||
col1, col2 = st.columns(2)
|
||||
|
||||
with col1:
|
||||
name = st.text_input(tr("character.name"), key="new_char_name")
|
||||
char_type = st.selectbox(
|
||||
tr("character.type"),
|
||||
options=[t.value for t in CharacterType],
|
||||
format_func=lambda x: tr(f"character.type.{x}"),
|
||||
key="new_char_type"
|
||||
)
|
||||
|
||||
with col2:
|
||||
features = st.text_input(tr("character.features"), placeholder="e.g., glasses, scar, red hat", key="new_char_features")
|
||||
# Image upload placeholder (Streamlit doesn't support persistent file paths easily in memory objects)
|
||||
ref_image = st.file_uploader(tr("character.ref_image"), type=["png", "jpg", "jpeg"], key="new_char_image")
|
||||
|
||||
appearance = st.text_area(tr("character.appearance"), placeholder="e.g., young man with short black hair", key="new_char_appearance")
|
||||
clothing = st.text_area(tr("character.clothing"), placeholder="e.g., blue t-shirt and jeans", key="new_char_clothing")
|
||||
|
||||
submit = st.form_submit_button(tr("character.add_button"), use_container_width=True)
|
||||
|
||||
if submit:
|
||||
if not name:
|
||||
st.error(tr("error.missing_field", field=tr("character.name")))
|
||||
else:
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
feature_list = [f.strip() for f in features.split(",") if f.strip()]
|
||||
|
||||
# Handle ref image upload
|
||||
ref_path = None
|
||||
if ref_image:
|
||||
# Save to a temporary storage or artifacts dir
|
||||
upload_dir = Path("/Users/yuanjiantsui/projects/Pixelle-Video/temp/characters")
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
ref_path = str(upload_dir / f"{name}_{ref_image.name}")
|
||||
with open(ref_path, "wb") as f:
|
||||
f.write(ref_image.getbuffer())
|
||||
|
||||
char = memory.register_character(
|
||||
name=name,
|
||||
appearance_description=appearance,
|
||||
clothing_description=clothing,
|
||||
distinctive_features=feature_list,
|
||||
character_type=CharacterType(char_type)
|
||||
)
|
||||
|
||||
if ref_path:
|
||||
char.add_reference_image(ref_path, set_as_primary=True)
|
||||
|
||||
st.success(f"✅ {name} registered!")
|
||||
safe_rerun()
|
||||
|
||||
# --- Character List ---
|
||||
st.markdown("---")
|
||||
characters = memory.characters
|
||||
|
||||
if not characters:
|
||||
st.info(tr("character.no_characters"))
|
||||
else:
|
||||
st.markdown(tr("character.registered_count", count=len(characters)))
|
||||
|
||||
# Display characters in a grid
|
||||
cols = st.columns(3)
|
||||
for i, char in enumerate(characters):
|
||||
with cols[i % 3]:
|
||||
with st.container(border=True):
|
||||
# Header with Status Toggle
|
||||
head_col, action_col = st.columns([3, 1])
|
||||
with head_col:
|
||||
st.markdown(f"**{char.name}**")
|
||||
st.caption(tr(f"character.type.{char.character_type.value}"))
|
||||
with action_col:
|
||||
# Toggle for activation (defensive access for cached objects)
|
||||
current_active = getattr(char, 'is_active', True)
|
||||
new_state = st.toggle("On", value=current_active, key=f"tog_{char.id}", label_visibility="collapsed")
|
||||
if new_state != current_active:
|
||||
char.is_active = new_state
|
||||
safe_rerun()
|
||||
|
||||
# Image Preview
|
||||
if char.primary_reference:
|
||||
st.image(char.primary_reference, use_container_width=True)
|
||||
else:
|
||||
st.empty()
|
||||
|
||||
if char.appearance_description:
|
||||
st.caption(f"📝 {char.appearance_description}")
|
||||
|
||||
if char.clothing_description:
|
||||
st.caption(f"👕 {char.clothing_description}")
|
||||
|
||||
if char.distinctive_features:
|
||||
st.markdown(", ".join([f"`{f}`" for f in char.distinctive_features]))
|
||||
|
||||
st.markdown("---")
|
||||
# Delete Button at bottom
|
||||
if st.button("🗑️ " + tr("history.task_card.delete"), key=f"del_{char.id}", use_container_width=True):
|
||||
# Basic delete logic
|
||||
del memory._characters[char.id]
|
||||
if char.name.lower() in memory._name_index:
|
||||
del memory._name_index[char.name.lower()]
|
||||
safe_rerun()
|
||||
|
||||
# Provide consistency prompt suggestion if characters exist
|
||||
if characters:
|
||||
with st.expander("💡 Character Consistency Prompt", expanded=False):
|
||||
st.code(
|
||||
", ".join([char.get_prompt_injection() for char in characters]),
|
||||
language="text"
|
||||
)
|
||||
@@ -69,8 +69,25 @@ def render_single_output(pixelle_video, video_params):
|
||||
if not config_manager.validate():
|
||||
st.warning(tr("settings.not_configured"))
|
||||
|
||||
# Generate Button
|
||||
if st.button(tr("btn.generate"), type="primary", use_container_width=True):
|
||||
# Mode selection: sync vs async
|
||||
col_mode, col_btn = st.columns([1, 2])
|
||||
|
||||
with col_mode:
|
||||
async_mode = st.checkbox(
|
||||
"🚀 后台生成",
|
||||
value=False,
|
||||
help="勾选后任务将提交到后台,可以继续创作其他视频"
|
||||
)
|
||||
|
||||
with col_btn:
|
||||
generate_clicked = st.button(
|
||||
tr("btn.generate") + (" (后台)" if async_mode else ""),
|
||||
type="primary",
|
||||
use_container_width=True
|
||||
)
|
||||
|
||||
# Generate Button clicked
|
||||
if generate_clicked:
|
||||
# Validate system configuration
|
||||
if not config_manager.validate():
|
||||
st.error(tr("settings.not_configured"))
|
||||
@@ -81,6 +98,76 @@ def render_single_output(pixelle_video, video_params):
|
||||
st.error(tr("error.input_required"))
|
||||
st.stop()
|
||||
|
||||
# ============================================
|
||||
# Async Mode: Submit to background queue
|
||||
# ============================================
|
||||
if async_mode:
|
||||
import requests
|
||||
|
||||
try:
|
||||
# Prepare request payload
|
||||
api_payload = {
|
||||
"text": text,
|
||||
"mode": mode,
|
||||
"title": title if title else None,
|
||||
"n_scenes": n_scenes,
|
||||
"split_mode": split_mode,
|
||||
"media_workflow": workflow_key,
|
||||
"frame_template": frame_template,
|
||||
"prompt_prefix": prompt_prefix,
|
||||
"bgm_path": bgm_path,
|
||||
"bgm_volume": bgm_volume if bgm_path else 0.2,
|
||||
"tts_inference_mode": tts_mode,
|
||||
}
|
||||
|
||||
# Add TTS parameters
|
||||
if tts_mode == "local":
|
||||
api_payload["tts_voice"] = selected_voice
|
||||
api_payload["tts_speed"] = tts_speed
|
||||
else:
|
||||
api_payload["tts_workflow"] = tts_workflow_key
|
||||
if ref_audio_path:
|
||||
api_payload["ref_audio"] = str(ref_audio_path)
|
||||
|
||||
# Add template params
|
||||
if custom_values_for_video:
|
||||
api_payload["template_params"] = custom_values_for_video
|
||||
|
||||
# Submit to async API
|
||||
response = requests.post(
|
||||
"http://localhost:8000/api/video/generate/async",
|
||||
json=api_payload,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
task_id = result.get("task_id", "unknown")
|
||||
|
||||
# Show success dialog
|
||||
st.success(f"✅ 任务已提交!任务ID: `{task_id}`")
|
||||
st.info("📋 可以在「任务中心」查看进度")
|
||||
|
||||
# Clear form button
|
||||
if st.button("🆕 开始新创作", type="primary", use_container_width=True):
|
||||
# Clear session state for form fields
|
||||
for key in ["input_text", "video_title"]:
|
||||
if key in st.session_state:
|
||||
del st.session_state[key]
|
||||
st.rerun()
|
||||
else:
|
||||
st.error(f"提交失败: {response.text}")
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
st.error("❌ 无法连接到 API 服务器,请确保后端已启动")
|
||||
except Exception as e:
|
||||
st.error(f"❌ 提交失败: {e}")
|
||||
|
||||
st.stop()
|
||||
|
||||
# ============================================
|
||||
# Sync Mode: Original blocking generation
|
||||
# ============================================
|
||||
# Show progress
|
||||
progress_bar = st.progress(0)
|
||||
status_text = st.empty()
|
||||
@@ -139,6 +226,8 @@ def render_single_output(pixelle_video, video_params):
|
||||
"progress_callback": update_progress,
|
||||
"media_width": st.session_state.get('template_media_width'),
|
||||
"media_height": st.session_state.get('template_media_height'),
|
||||
# Character memory for visual consistency
|
||||
"character_memory": st.session_state.get('character_memory'),
|
||||
}
|
||||
|
||||
# Add TTS parameters based on mode
|
||||
@@ -190,17 +279,57 @@ def render_single_output(pixelle_video, video_params):
|
||||
if os.path.exists(result.video_path):
|
||||
st.video(result.video_path)
|
||||
|
||||
# Buttons row
|
||||
col_download, col_editor = st.columns(2)
|
||||
|
||||
# Download button
|
||||
with open(result.video_path, "rb") as video_file:
|
||||
video_bytes = video_file.read()
|
||||
video_filename = os.path.basename(result.video_path)
|
||||
st.download_button(
|
||||
label="⬇️ 下载视频" if get_language() == "zh_CN" else "⬇️ Download Video",
|
||||
data=video_bytes,
|
||||
file_name=video_filename,
|
||||
mime="video/mp4",
|
||||
use_container_width=True
|
||||
)
|
||||
with col_download:
|
||||
with open(result.video_path, "rb") as video_file:
|
||||
video_bytes = video_file.read()
|
||||
video_filename = os.path.basename(result.video_path)
|
||||
st.download_button(
|
||||
label="⬇️ 下载视频" if get_language() == "zh_CN" else "⬇️ Download Video",
|
||||
data=video_bytes,
|
||||
file_name=video_filename,
|
||||
mime="video/mp4",
|
||||
use_container_width=True
|
||||
)
|
||||
|
||||
# Open in Editor button
|
||||
with col_editor:
|
||||
# Get task_id from result path (format: output/{task_id}/...)
|
||||
task_id = None
|
||||
try:
|
||||
path_parts = Path(result.video_path).parts
|
||||
if "output" in path_parts:
|
||||
output_idx = path_parts.index("output")
|
||||
if output_idx + 1 < len(path_parts):
|
||||
task_id = path_parts[output_idx + 1]
|
||||
except:
|
||||
pass
|
||||
|
||||
if task_id:
|
||||
editor_url = f"http://localhost:3001/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;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 400;
|
||||
">
|
||||
✏️ {"在编辑器中打开" if get_language() == "zh_CN" else "Open in Editor"}
|
||||
</button>
|
||||
</a>
|
||||
''',
|
||||
unsafe_allow_html=True
|
||||
)
|
||||
else:
|
||||
st.error(tr("status.video_not_found", path=result.video_path))
|
||||
|
||||
|
||||
@@ -389,6 +389,22 @@
|
||||
"asset_based.progress.concat_complete": "✅ Video concatenation complete",
|
||||
"faq.expand_to_view": "FAQ",
|
||||
"faq.load_error": "Failed to load FAQ content",
|
||||
"faq.more_help": "Need more help?"
|
||||
"faq.more_help": "Need more help?",
|
||||
"section.character_memory": "👤 Character Memory",
|
||||
"character.title": "Character Memory Management",
|
||||
"character.desc": "Maintain visual consistency for characters",
|
||||
"character.add_button": "➕ Add Character",
|
||||
"character.name": "Character Name",
|
||||
"character.type": "Type",
|
||||
"character.appearance": "Appearance",
|
||||
"character.clothing": "Clothing",
|
||||
"character.features": "Features (comma separated)",
|
||||
"character.ref_image": "Reference Image",
|
||||
"character.no_characters": "No characters registered",
|
||||
"character.registered_count": "{count} characters registered",
|
||||
"character.type.person": "Person",
|
||||
"character.type.animal": "Animal",
|
||||
"character.type.creature": "Creature",
|
||||
"character.type.object": "Object"
|
||||
}
|
||||
}
|
||||
@@ -389,6 +389,22 @@
|
||||
"asset_based.progress.concat_complete": "✅ 视频合成完成",
|
||||
"faq.expand_to_view": "常见问题",
|
||||
"faq.load_error": "无法加载常见问题内容",
|
||||
"faq.more_help": "需要更多帮助?"
|
||||
"faq.more_help": "需要更多帮助?",
|
||||
"section.character_memory": "👤 角色记忆",
|
||||
"character.title": "角色记忆管理",
|
||||
"character.desc": "维护视频中人物/角色外观一致性",
|
||||
"character.add_button": "➕ 添加角色",
|
||||
"character.name": "角色名称",
|
||||
"character.type": "角色类型",
|
||||
"character.appearance": "外观描述",
|
||||
"character.clothing": "服装描述",
|
||||
"character.features": "特征(逗号分隔)",
|
||||
"character.ref_image": "参考图片",
|
||||
"character.no_characters": "暂无已注册角色",
|
||||
"character.registered_count": "已注册 {count} 个角色",
|
||||
"character.type.person": "人类",
|
||||
"character.type.animal": "动物",
|
||||
"character.type.creature": "生物",
|
||||
"character.type.object": "拟人物体"
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ from web.state.session import init_session_state, init_i18n, get_pixelle_video
|
||||
from web.components.header import render_header
|
||||
from web.components.settings import render_advanced_settings
|
||||
from web.components.faq import render_faq_sidebar
|
||||
from web.i18n import tr
|
||||
|
||||
# Page config
|
||||
st.set_page_config(
|
||||
@@ -57,8 +58,9 @@ def main():
|
||||
# Initialize Pixelle-Video
|
||||
pixelle_video = get_pixelle_video()
|
||||
|
||||
# Render system configuration (LLM + ComfyUI)
|
||||
render_advanced_settings()
|
||||
# Render advanced settings in the sidebar
|
||||
with st.sidebar:
|
||||
render_advanced_settings()
|
||||
|
||||
# ========================================================================
|
||||
# Pipeline Selection & Delegation
|
||||
@@ -69,19 +71,26 @@ def main():
|
||||
pipelines = get_all_pipeline_uis()
|
||||
|
||||
# Use Tabs for pipeline selection
|
||||
# Note: st.tabs returns a list of containers, one for each tab
|
||||
tab_labels = [f"{p.icon} {p.display_name}" for p in pipelines]
|
||||
tab_labels = [f"{p.icon} {p.display_name}" for p in pipelines] + [tr("section.character_memory")]
|
||||
tabs = st.tabs(tab_labels)
|
||||
|
||||
# Render each pipeline in its corresponding tab
|
||||
from web.components.character_memory import render_active_characters_badge, render_character_memory
|
||||
for i, pipeline in enumerate(pipelines):
|
||||
with tabs[i]:
|
||||
# Show active characters status
|
||||
render_active_characters_badge()
|
||||
|
||||
# Show description if available
|
||||
if pipeline.description:
|
||||
st.caption(pipeline.description)
|
||||
|
||||
# Delegate rendering
|
||||
pipeline.render(pixelle_video)
|
||||
|
||||
# Render Character Memory tab
|
||||
with tabs[len(pipelines)]:
|
||||
render_character_memory()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -361,6 +361,28 @@ def render_task_detail_modal(task_id: str, pixelle_video):
|
||||
mime="video/mp4",
|
||||
use_container_width=True
|
||||
)
|
||||
|
||||
# Open in Editor button
|
||||
editor_url = f"http://localhost:3001/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")
|
||||
|
||||
|
||||
281
web/pages/3_📋_Tasks.py
Normal file
281
web/pages/3_📋_Tasks.py
Normal file
@@ -0,0 +1,281 @@
|
||||
# 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.
|
||||
|
||||
"""
|
||||
Task Center Page - View and manage video generation tasks
|
||||
|
||||
Features:
|
||||
- View all pending/running/completed tasks
|
||||
- Real-time progress updates (polling)
|
||||
- Jump to editor for completed tasks
|
||||
"""
|
||||
|
||||
import streamlit as st
|
||||
import requests
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from web.i18n import tr, get_language
|
||||
|
||||
# Page config
|
||||
st.set_page_config(
|
||||
page_title="任务中心" if get_language() == "zh_CN" else "Task Center",
|
||||
page_icon="📋",
|
||||
layout="wide",
|
||||
)
|
||||
|
||||
# API endpoint
|
||||
API_BASE = "http://localhost:8000/api"
|
||||
|
||||
|
||||
def get_all_tasks():
|
||||
"""Fetch all tasks from API"""
|
||||
try:
|
||||
response = requests.get(f"{API_BASE}/tasks", timeout=5)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
# API returns list directly, or dict with "tasks" key
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
elif isinstance(data, dict):
|
||||
return data.get("tasks", [])
|
||||
return []
|
||||
return []
|
||||
except Exception as e:
|
||||
st.error(f"无法连接到 API: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def get_task_status(task_id: str):
|
||||
"""Get status of a specific task"""
|
||||
try:
|
||||
response = requests.get(f"{API_BASE}/tasks/{task_id}", timeout=5)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
return None
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
def format_time(iso_string):
|
||||
"""Format ISO time string to readable format"""
|
||||
if not iso_string:
|
||||
return "-"
|
||||
try:
|
||||
dt = datetime.fromisoformat(iso_string.replace("Z", "+00:00"))
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except:
|
||||
return iso_string
|
||||
|
||||
|
||||
def render_task_card(task):
|
||||
"""Render a single task card"""
|
||||
task_id = task.get("task_id", "unknown")
|
||||
status = task.get("status", "unknown")
|
||||
progress = task.get("progress", {})
|
||||
result = task.get("result", {})
|
||||
params = task.get("request_params", {})
|
||||
|
||||
# Status badge colors
|
||||
status_colors = {
|
||||
"pending": "🟡",
|
||||
"running": "🔵",
|
||||
"completed": "🟢",
|
||||
"failed": "🔴",
|
||||
"cancelled": "⚪",
|
||||
}
|
||||
status_emoji = status_colors.get(status, "⚪")
|
||||
|
||||
with st.container(border=True):
|
||||
# Header row
|
||||
col1, col2, col3 = st.columns([3, 1, 1.5])
|
||||
|
||||
with col1:
|
||||
title = params.get("title") or f"任务 {task_id[:8]}"
|
||||
st.markdown(f"### {status_emoji} {title}")
|
||||
st.caption(f"ID: `{task_id}`")
|
||||
|
||||
with col2:
|
||||
st.markdown(f"**状态**\n\n{status}")
|
||||
|
||||
with col3:
|
||||
created_at = format_time(task.get("created_at"))
|
||||
st.markdown(f"**创建时间**\n\n{created_at}")
|
||||
|
||||
# Progress bar for running tasks
|
||||
if status == "running" and progress:
|
||||
percentage = progress.get("percentage", 0)
|
||||
message = progress.get("message", "")
|
||||
st.progress(percentage / 100, text=message)
|
||||
|
||||
# Details expander
|
||||
with st.expander("🔍 查看执行细节"):
|
||||
# Time statistics
|
||||
t_col1, t_col2, t_col3 = st.columns(3)
|
||||
with t_col1:
|
||||
st.write(f"⏱️ **开始**: {format_time(task.get('started_at'))}")
|
||||
with t_col2:
|
||||
st.write(f"🏁 **结束**: {format_time(task.get('completed_at'))}")
|
||||
with t_col3:
|
||||
# Calculate duration if possible
|
||||
try:
|
||||
start = task.get("started_at")
|
||||
end = task.get("completed_at")
|
||||
if start and end:
|
||||
s_dt = datetime.fromisoformat(start.replace("Z", "+00:00"))
|
||||
e_dt = datetime.fromisoformat(end.replace("Z", "+00:00"))
|
||||
duration = (e_dt - s_dt).total_seconds()
|
||||
st.write(f"⏳ **用时**: {duration:.1f}s")
|
||||
else:
|
||||
st.write("⏳ **用时**: -")
|
||||
except:
|
||||
st.write("⏳ **用时**: -")
|
||||
|
||||
st.divider()
|
||||
|
||||
# Input parameters
|
||||
st.markdown("**📝 输入参数**")
|
||||
p_col1, p_col2 = st.columns(2)
|
||||
with p_col1:
|
||||
st.write(f"- **文本**: {params.get('text', '')[:100]}...")
|
||||
st.write(f"- **场景数**: {params.get('n_scenes', 5)}")
|
||||
with p_col2:
|
||||
st.write(f"- **模板**: {params.get('frame_template', '默认')}")
|
||||
st.write(f"- **TTS 模式**: {params.get('tts_inference_mode', 'local')}")
|
||||
|
||||
# Execution logs
|
||||
logs = task.get("logs", [])
|
||||
if logs:
|
||||
st.divider()
|
||||
st.markdown("**📜 执行步骤**")
|
||||
for log in reversed(logs):
|
||||
t = format_time(log.get("timestamp"))
|
||||
m = log.get("message", "")
|
||||
p = log.get("percentage", 0)
|
||||
st.write(f"`{t}` | **{p}%** | {m}")
|
||||
|
||||
# Result information if completed
|
||||
if status == "completed" and result:
|
||||
st.divider()
|
||||
st.markdown("**📊 结果详情**")
|
||||
r_col1, r_col2, r_col3 = st.columns(3)
|
||||
with r_col1:
|
||||
st.write(f"📐 **视频长度**: {result.get('duration', 0):.1f}s")
|
||||
with r_col2:
|
||||
file_size = result.get('file_size', 0) / (1024 * 1024)
|
||||
st.write(f"📦 **文件大小**: {file_size:.2f} MB")
|
||||
with r_col3:
|
||||
st.write(f"🔗 [查看视频]({result.get('video_url', '#')})")
|
||||
|
||||
# Actions for completed task
|
||||
if status == "completed":
|
||||
st.markdown("---")
|
||||
col_a, col_b = st.columns(2)
|
||||
with col_a:
|
||||
st.success("✨ 视频生成成功")
|
||||
with col_b:
|
||||
editor_url = f"http://localhost:3001/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;
|
||||
">
|
||||
✏️ 在编辑器中打开
|
||||
</button>
|
||||
</a>
|
||||
''',
|
||||
unsafe_allow_html=True
|
||||
)
|
||||
|
||||
# Failed task error
|
||||
if status == "failed":
|
||||
error = task.get("error", "未知错误")
|
||||
st.error(f"❌ 执行失败: {error}")
|
||||
|
||||
|
||||
def main():
|
||||
st.title("📋 任务中心")
|
||||
st.caption("查看和管理视频生成任务")
|
||||
|
||||
# Fetch tasks first to check for running tasks
|
||||
tasks = get_all_tasks()
|
||||
running_count = sum(1 for t in tasks if t.get("status") == "running") if tasks else 0
|
||||
|
||||
# Auto-refresh toggle - default ON if there are running tasks
|
||||
col1, col2, col3 = st.columns([2, 1, 1])
|
||||
|
||||
with col1:
|
||||
auto_refresh = st.checkbox(
|
||||
f"🔄 自动刷新 (3秒)" + (f" - {running_count}个任务运行中" if running_count > 0 else ""),
|
||||
value=(running_count > 0), # Auto-enable if running tasks exist
|
||||
help="有运行中任务时建议开启"
|
||||
)
|
||||
|
||||
with col2:
|
||||
if st.button("🔄 刷新", use_container_width=True):
|
||||
st.rerun()
|
||||
|
||||
with col3:
|
||||
filter_status = st.selectbox(
|
||||
"筛选状态",
|
||||
["全部", "running", "completed", "failed", "pending"],
|
||||
label_visibility="collapsed"
|
||||
)
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
if not tasks:
|
||||
st.info("📭 暂无任务")
|
||||
st.caption("在首页生成视频后,任务会显示在这里")
|
||||
else:
|
||||
# Filter tasks
|
||||
if filter_status != "全部":
|
||||
tasks = [t for t in tasks if t.get("status") == filter_status]
|
||||
|
||||
# Sort by created_at (newest first)
|
||||
tasks = sorted(
|
||||
tasks,
|
||||
key=lambda x: x.get("created_at", ""),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
# Stats row
|
||||
completed_count = sum(1 for t in tasks if t.get("status") == "completed")
|
||||
failed_count = sum(1 for t in tasks if t.get("status") == "failed")
|
||||
|
||||
col1, col2, col3, col4 = st.columns(4)
|
||||
col1.metric("总任务", len(tasks))
|
||||
col2.metric("🔵 运行中", running_count)
|
||||
col3.metric("🟢 已完成", completed_count)
|
||||
col4.metric("🔴 失败", failed_count)
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
# Render task cards
|
||||
for task in tasks:
|
||||
render_task_card(task)
|
||||
|
||||
# Auto-refresh logic with shorter interval
|
||||
if auto_refresh:
|
||||
time.sleep(3)
|
||||
st.rerun()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user