支持历史生成结果的呈现逻辑
This commit is contained in:
@@ -397,13 +397,18 @@ class CustomPipeline(BasePipeline):
|
||||
return
|
||||
|
||||
# Build metadata
|
||||
# If user didn't provide a title, use the generated one from storyboard
|
||||
input_with_title = input_params.copy()
|
||||
if not input_with_title.get("title"):
|
||||
input_with_title["title"] = storyboard.title
|
||||
|
||||
metadata = {
|
||||
"task_id": task_id,
|
||||
"created_at": storyboard.created_at.isoformat() if storyboard.created_at else None,
|
||||
"completed_at": storyboard.completed_at.isoformat() if storyboard.completed_at else None,
|
||||
"status": "completed",
|
||||
|
||||
"input": input_params,
|
||||
"input": input_with_title,
|
||||
|
||||
"result": {
|
||||
"video_path": result.video_path,
|
||||
|
||||
@@ -556,13 +556,18 @@ class StandardPipeline(BasePipeline):
|
||||
return
|
||||
|
||||
# Build metadata
|
||||
# If user didn't provide a title, use the generated one from storyboard
|
||||
input_with_title = input_params.copy()
|
||||
if not input_with_title.get("title"):
|
||||
input_with_title["title"] = storyboard.title
|
||||
|
||||
metadata = {
|
||||
"task_id": task_id,
|
||||
"created_at": storyboard.created_at.isoformat() if storyboard.created_at else None,
|
||||
"completed_at": storyboard.completed_at.isoformat() if storyboard.completed_at else None,
|
||||
"status": "completed",
|
||||
|
||||
"input": input_params,
|
||||
"input": input_with_title,
|
||||
|
||||
"result": {
|
||||
"video_path": result.video_path,
|
||||
|
||||
@@ -27,6 +27,7 @@ from pixelle_video.services.media import MediaService
|
||||
from pixelle_video.services.video import VideoService
|
||||
from pixelle_video.services.frame_processor import FrameProcessor
|
||||
from pixelle_video.services.persistence import PersistenceService
|
||||
from pixelle_video.services.history_manager import HistoryManager
|
||||
from pixelle_video.pipelines.standard import StandardPipeline
|
||||
from pixelle_video.pipelines.custom import CustomPipeline
|
||||
|
||||
@@ -82,6 +83,7 @@ class PixelleVideoCore:
|
||||
self.video: Optional[VideoService] = None
|
||||
self.frame_processor: Optional[FrameProcessor] = None
|
||||
self.persistence: Optional[PersistenceService] = None
|
||||
self.history: Optional[HistoryManager] = None
|
||||
|
||||
# Video generation pipelines (dictionary of pipeline_name -> pipeline_instance)
|
||||
self.pipelines = {}
|
||||
@@ -111,6 +113,7 @@ class PixelleVideoCore:
|
||||
self.video = VideoService()
|
||||
self.frame_processor = FrameProcessor(self)
|
||||
self.persistence = PersistenceService(output_dir="output")
|
||||
self.history = HistoryManager(self.persistence)
|
||||
|
||||
# 2. Register video generation pipelines
|
||||
self.pipelines = {
|
||||
|
||||
@@ -22,6 +22,7 @@ Services:
|
||||
- VideoService: Video processing
|
||||
- FrameProcessor: Frame processing orchestrator
|
||||
- PersistenceService: Task metadata and storyboard persistence
|
||||
- HistoryManager: History management business logic
|
||||
- ComfyBaseService: Base class for ComfyUI-based services
|
||||
"""
|
||||
|
||||
@@ -32,6 +33,7 @@ from pixelle_video.services.media import MediaService
|
||||
from pixelle_video.services.video import VideoService
|
||||
from pixelle_video.services.frame_processor import FrameProcessor
|
||||
from pixelle_video.services.persistence import PersistenceService
|
||||
from pixelle_video.services.history_manager import HistoryManager
|
||||
|
||||
# Backward compatibility alias
|
||||
ImageService = MediaService
|
||||
@@ -45,5 +47,6 @@ __all__ = [
|
||||
"VideoService",
|
||||
"FrameProcessor",
|
||||
"PersistenceService",
|
||||
"HistoryManager",
|
||||
]
|
||||
|
||||
|
||||
224
pixelle_video/services/history_manager.py
Normal file
224
pixelle_video/services/history_manager.py
Normal file
@@ -0,0 +1,224 @@
|
||||
# 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 Manager Service
|
||||
|
||||
Business logic for history management (UI-agnostic).
|
||||
Provides high-level operations on top of PersistenceService.
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Optional, Any
|
||||
from pathlib import Path
|
||||
from loguru import logger
|
||||
|
||||
from pixelle_video.services.persistence import PersistenceService
|
||||
|
||||
|
||||
class HistoryManager:
|
||||
"""
|
||||
History management service
|
||||
|
||||
Provides business logic for:
|
||||
- Task listing and filtering
|
||||
- Task detail retrieval
|
||||
- Task duplication (for re-generation)
|
||||
- Task deletion
|
||||
- Future: Frame regeneration, export, etc.
|
||||
"""
|
||||
|
||||
def __init__(self, persistence: PersistenceService):
|
||||
"""
|
||||
Initialize history manager
|
||||
|
||||
Args:
|
||||
persistence: PersistenceService instance
|
||||
"""
|
||||
self.persistence = persistence
|
||||
|
||||
async def get_task_list(
|
||||
self,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
status: Optional[str] = None,
|
||||
sort_by: str = "created_at",
|
||||
sort_order: str = "desc"
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get paginated task list
|
||||
|
||||
Args:
|
||||
page: Page number (1-indexed)
|
||||
page_size: Items per page
|
||||
status: Filter by status (optional)
|
||||
sort_by: Sort field (created_at, completed_at, title, duration)
|
||||
sort_order: Sort order (asc, desc)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"tasks": [...],
|
||||
"total": 100,
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"total_pages": 5
|
||||
}
|
||||
"""
|
||||
return await self.persistence.list_tasks_paginated(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
status=status,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order
|
||||
)
|
||||
|
||||
async def get_task_detail(self, task_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get full task detail including storyboard
|
||||
|
||||
Args:
|
||||
task_id: Task ID
|
||||
|
||||
Returns:
|
||||
{
|
||||
"metadata": {...}, # Task metadata
|
||||
"storyboard": {...} # Storyboard data (if available)
|
||||
}
|
||||
or None if task not found
|
||||
"""
|
||||
metadata = await self.persistence.load_task_metadata(task_id)
|
||||
if not metadata:
|
||||
return None
|
||||
|
||||
storyboard = await self.persistence.load_storyboard(task_id)
|
||||
|
||||
return {
|
||||
"metadata": metadata,
|
||||
"storyboard": storyboard,
|
||||
}
|
||||
|
||||
async def get_statistics(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get statistics about all tasks
|
||||
|
||||
Returns:
|
||||
{
|
||||
"total_tasks": 100,
|
||||
"completed": 95,
|
||||
"failed": 5,
|
||||
"total_duration": 3600.5, # seconds
|
||||
"total_size": 1024000000, # bytes
|
||||
}
|
||||
"""
|
||||
return await self.persistence.get_statistics()
|
||||
|
||||
async def delete_task(self, task_id: str) -> bool:
|
||||
"""
|
||||
Delete a task and all its files
|
||||
|
||||
Args:
|
||||
task_id: Task ID to delete
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
return await self.persistence.delete_task(task_id)
|
||||
|
||||
async def duplicate_task(self, task_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Duplicate a task (get input parameters for new generation)
|
||||
|
||||
This allows users to:
|
||||
1. Copy all generation parameters from a previous task
|
||||
2. Pre-fill the generation form
|
||||
3. Regenerate with same/modified parameters
|
||||
|
||||
Args:
|
||||
task_id: Task ID to duplicate
|
||||
|
||||
Returns:
|
||||
Input parameters dict or None if task not found
|
||||
{
|
||||
"text": "...",
|
||||
"mode": "generate",
|
||||
"title": "...",
|
||||
"n_scenes": 5,
|
||||
"tts_inference_mode": "local",
|
||||
"tts_voice": "...",
|
||||
...
|
||||
}
|
||||
"""
|
||||
metadata = await self.persistence.load_task_metadata(task_id)
|
||||
if not metadata:
|
||||
logger.warning(f"Task {task_id} not found for duplication")
|
||||
return None
|
||||
|
||||
# Extract input parameters
|
||||
input_params = metadata.get("input", {})
|
||||
logger.info(f"Duplicated task {task_id} parameters")
|
||||
|
||||
return input_params
|
||||
|
||||
async def rebuild_index(self):
|
||||
"""Rebuild task index (useful for maintenance or after manual changes)"""
|
||||
await self.persistence.rebuild_index()
|
||||
|
||||
# ========================================================================
|
||||
# Future Extensions (Phase 3)
|
||||
# ========================================================================
|
||||
|
||||
async def regenerate_frame(
|
||||
self,
|
||||
task_id: str,
|
||||
frame_index: int,
|
||||
**override_params
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Regenerate a specific frame (FUTURE FEATURE)
|
||||
|
||||
Args:
|
||||
task_id: Original task ID
|
||||
frame_index: Frame index to regenerate (0-based)
|
||||
**override_params: Parameters to override (image_prompt, style, etc.)
|
||||
|
||||
Returns:
|
||||
New frame image path or None if failed
|
||||
|
||||
TODO: Implement in Phase 3
|
||||
- Load original storyboard
|
||||
- Get frame parameters
|
||||
- Override with new parameters
|
||||
- Call image generation service
|
||||
- Update storyboard
|
||||
- Re-composite video
|
||||
"""
|
||||
logger.warning("regenerate_frame is not implemented yet (Phase 3 feature)")
|
||||
return None
|
||||
|
||||
async def export_task(self, task_id: str, export_path: str) -> Optional[str]:
|
||||
"""
|
||||
Export task as a package (metadata + video + frames) (FUTURE FEATURE)
|
||||
|
||||
Args:
|
||||
task_id: Task ID to export
|
||||
export_path: Export file path (e.g., "exports/task.zip")
|
||||
|
||||
Returns:
|
||||
Export file path or None if failed
|
||||
|
||||
TODO: Implement in Phase 3
|
||||
- Collect all task files
|
||||
- Create ZIP archive
|
||||
- Include metadata.json, storyboard.json, video, frames
|
||||
"""
|
||||
logger.warning("export_task is not implemented yet (Phase 3 feature)")
|
||||
return None
|
||||
|
||||
@@ -66,6 +66,10 @@ class PersistenceService:
|
||||
"""
|
||||
self.output_dir = Path(output_dir)
|
||||
self.output_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Index file for fast listing
|
||||
self.index_file = self.output_dir / ".index.json"
|
||||
self._ensure_index()
|
||||
|
||||
def get_task_dir(self, task_id: str) -> Path:
|
||||
"""Get task directory path"""
|
||||
@@ -124,6 +128,9 @@ class PersistenceService:
|
||||
|
||||
logger.debug(f"Saved task metadata: {task_id}")
|
||||
|
||||
# Update index
|
||||
await self._update_index_for_task(task_id, metadata)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save task metadata {task_id}: {e}")
|
||||
raise
|
||||
@@ -457,4 +464,249 @@ class PersistenceService:
|
||||
publication_year=data.get("publication_year"),
|
||||
cover_url=data.get("cover_url"),
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
# Index Management (for fast listing)
|
||||
# ========================================================================
|
||||
|
||||
def _ensure_index(self):
|
||||
"""Ensure index file exists, create if not"""
|
||||
if not self.index_file.exists():
|
||||
self._save_index({"version": "1.0", "tasks": []})
|
||||
|
||||
def _load_index(self) -> Dict[str, Any]:
|
||||
"""Load index from file"""
|
||||
try:
|
||||
with open(self.index_file, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load index: {e}")
|
||||
return {"version": "1.0", "tasks": []}
|
||||
|
||||
def _save_index(self, index_data: Dict[str, Any]):
|
||||
"""Save index to file"""
|
||||
try:
|
||||
index_data["last_updated"] = datetime.now().isoformat()
|
||||
with open(self.index_file, "w", encoding="utf-8") as f:
|
||||
json.dump(index_data, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save index: {e}")
|
||||
|
||||
async def _update_index_for_task(self, task_id: str, metadata: Dict[str, Any]):
|
||||
"""Update index entry for a specific task"""
|
||||
index = self._load_index()
|
||||
|
||||
# Try to get title from multiple sources
|
||||
title = metadata.get("input", {}).get("title")
|
||||
if not title or title == "":
|
||||
# Try to get title from storyboard if input title is empty
|
||||
storyboard = await self.load_storyboard(task_id)
|
||||
if storyboard and storyboard.title:
|
||||
title = storyboard.title
|
||||
else:
|
||||
# Fall back to using input text preview
|
||||
input_text = metadata.get("input", {}).get("text", "")
|
||||
if input_text:
|
||||
# Use first 30 characters of input text as title
|
||||
title = input_text[:30] + ("..." if len(input_text) > 30 else "")
|
||||
else:
|
||||
title = "Untitled"
|
||||
|
||||
# Extract key info for index
|
||||
index_entry = {
|
||||
"task_id": task_id,
|
||||
"created_at": metadata.get("created_at"),
|
||||
"completed_at": metadata.get("completed_at"),
|
||||
"status": metadata.get("status", "unknown"),
|
||||
"title": title,
|
||||
"duration": metadata.get("result", {}).get("duration", 0),
|
||||
"n_frames": metadata.get("result", {}).get("n_frames", 0),
|
||||
"file_size": metadata.get("result", {}).get("file_size", 0),
|
||||
"video_path": metadata.get("result", {}).get("video_path"),
|
||||
}
|
||||
|
||||
# Update or append
|
||||
tasks = index.get("tasks", [])
|
||||
existing_idx = next((i for i, t in enumerate(tasks) if t["task_id"] == task_id), None)
|
||||
|
||||
if existing_idx is not None:
|
||||
tasks[existing_idx] = index_entry
|
||||
else:
|
||||
tasks.append(index_entry)
|
||||
|
||||
index["tasks"] = tasks
|
||||
self._save_index(index)
|
||||
|
||||
async def rebuild_index(self):
|
||||
"""Rebuild index by scanning all task directories"""
|
||||
logger.info("Rebuilding task index...")
|
||||
index = {"version": "1.0", "tasks": []}
|
||||
|
||||
# Scan all directories
|
||||
for task_dir in self.output_dir.iterdir():
|
||||
if not task_dir.is_dir() or task_dir.name.startswith("."):
|
||||
continue
|
||||
|
||||
task_id = task_dir.name
|
||||
metadata = await self.load_task_metadata(task_id)
|
||||
|
||||
if metadata:
|
||||
# Try to get title from multiple sources
|
||||
title = metadata.get("input", {}).get("title")
|
||||
if not title or title == "":
|
||||
# Try to get title from storyboard if input title is empty
|
||||
storyboard = await self.load_storyboard(task_id)
|
||||
if storyboard and storyboard.title:
|
||||
title = storyboard.title
|
||||
else:
|
||||
# Fall back to using input text preview
|
||||
input_text = metadata.get("input", {}).get("text", "")
|
||||
if input_text:
|
||||
# Use first 30 characters of input text as title
|
||||
title = input_text[:30] + ("..." if len(input_text) > 30 else "")
|
||||
else:
|
||||
title = "Untitled"
|
||||
|
||||
# Add to index
|
||||
index["tasks"].append({
|
||||
"task_id": task_id,
|
||||
"created_at": metadata.get("created_at"),
|
||||
"completed_at": metadata.get("completed_at"),
|
||||
"status": metadata.get("status", "unknown"),
|
||||
"title": title,
|
||||
"duration": metadata.get("result", {}).get("duration", 0),
|
||||
"n_frames": metadata.get("result", {}).get("n_frames", 0),
|
||||
"file_size": metadata.get("result", {}).get("file_size", 0),
|
||||
"video_path": metadata.get("result", {}).get("video_path"),
|
||||
})
|
||||
|
||||
self._save_index(index)
|
||||
logger.info(f"Index rebuilt: {len(index['tasks'])} tasks")
|
||||
|
||||
# ========================================================================
|
||||
# Paginated Listing
|
||||
# ========================================================================
|
||||
|
||||
async def list_tasks_paginated(
|
||||
self,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
status: Optional[str] = None,
|
||||
sort_by: str = "created_at",
|
||||
sort_order: str = "desc"
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
List tasks with pagination
|
||||
|
||||
Args:
|
||||
page: Page number (1-indexed)
|
||||
page_size: Items per page
|
||||
status: Filter by status (optional)
|
||||
sort_by: Sort field (created_at, completed_at, title, duration)
|
||||
sort_order: Sort order (asc, desc)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"tasks": [...], # List of task summaries
|
||||
"total": 100, # Total matching tasks
|
||||
"page": 1, # Current page
|
||||
"page_size": 20, # Items per page
|
||||
"total_pages": 5 # Total pages
|
||||
}
|
||||
"""
|
||||
index = self._load_index()
|
||||
tasks = index.get("tasks", [])
|
||||
|
||||
# Filter by status
|
||||
if status:
|
||||
tasks = [t for t in tasks if t.get("status") == status]
|
||||
|
||||
# Sort
|
||||
reverse = (sort_order == "desc")
|
||||
if sort_by in ["created_at", "completed_at"]:
|
||||
tasks.sort(
|
||||
key=lambda t: datetime.fromisoformat(t.get(sort_by, "1970-01-01T00:00:00")),
|
||||
reverse=reverse
|
||||
)
|
||||
elif sort_by in ["title", "duration", "n_frames"]:
|
||||
tasks.sort(key=lambda t: t.get(sort_by, ""), reverse=reverse)
|
||||
|
||||
# Paginate
|
||||
total = len(tasks)
|
||||
total_pages = (total + page_size - 1) // page_size
|
||||
start_idx = (page - 1) * page_size
|
||||
end_idx = start_idx + page_size
|
||||
page_tasks = tasks[start_idx:end_idx]
|
||||
|
||||
return {
|
||||
"tasks": page_tasks,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": total_pages,
|
||||
}
|
||||
|
||||
# ========================================================================
|
||||
# Statistics
|
||||
# ========================================================================
|
||||
|
||||
async def get_statistics(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get statistics about all tasks
|
||||
|
||||
Returns:
|
||||
{
|
||||
"total_tasks": 100,
|
||||
"completed": 95,
|
||||
"failed": 5,
|
||||
"total_duration": 3600.5, # seconds
|
||||
"total_size": 1024000000, # bytes
|
||||
}
|
||||
"""
|
||||
index = self._load_index()
|
||||
tasks = index.get("tasks", [])
|
||||
|
||||
stats = {
|
||||
"total_tasks": len(tasks),
|
||||
"completed": len([t for t in tasks if t.get("status") == "completed"]),
|
||||
"failed": len([t for t in tasks if t.get("status") == "failed"]),
|
||||
"total_duration": sum(t.get("duration", 0) for t in tasks),
|
||||
"total_size": sum(t.get("file_size", 0) for t in tasks),
|
||||
}
|
||||
|
||||
return stats
|
||||
|
||||
# ========================================================================
|
||||
# Delete Task
|
||||
# ========================================================================
|
||||
|
||||
async def delete_task(self, task_id: str) -> bool:
|
||||
"""
|
||||
Delete a task and all its files
|
||||
|
||||
Args:
|
||||
task_id: Task ID to delete
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
import shutil
|
||||
|
||||
task_dir = self.get_task_dir(task_id)
|
||||
if task_dir.exists():
|
||||
shutil.rmtree(task_dir)
|
||||
logger.info(f"Deleted task directory: {task_dir}")
|
||||
|
||||
# Update index
|
||||
index = self._load_index()
|
||||
tasks = index.get("tasks", [])
|
||||
tasks = [t for t in tasks if t["task_id"] != task_id]
|
||||
index["tasks"] = tasks
|
||||
self._save_index(index)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete task {task_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
78
web/app.py
78
web/app.py
@@ -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__":
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
102
web/pages/1_🎬_Home.py
Normal 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
466
web/pages/2_📚_History.py
Normal 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()
|
||||
Reference in New Issue
Block a user