From dfa2f76c5b68f8fd7075333d1083490630113440 Mon Sep 17 00:00:00 2001 From: puke <1129090915@qq.com> Date: Tue, 18 Nov 2025 15:52:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=8E=86=E5=8F=B2=E7=94=9F?= =?UTF-8?q?=E6=88=90=E7=BB=93=E6=9E=9C=E7=9A=84=E5=91=88=E7=8E=B0=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pixelle_video/pipelines/custom.py | 7 +- pixelle_video/pipelines/standard.py | 7 +- pixelle_video/service.py | 3 + pixelle_video/services/__init__.py | 3 + pixelle_video/services/history_manager.py | 224 +++++++++++ pixelle_video/services/persistence.py | 252 ++++++++++++ web/app.py | 78 +--- web/i18n/locales/en_US.json | 60 ++- web/i18n/locales/zh_CN.json | 60 ++- web/pages/1_🎬_Home.py | 102 +++++ web/pages/2_📚_History.py | 466 ++++++++++++++++++++++ 11 files changed, 1198 insertions(+), 64 deletions(-) create mode 100644 pixelle_video/services/history_manager.py create mode 100644 web/pages/1_🎬_Home.py create mode 100644 web/pages/2_📚_History.py diff --git a/pixelle_video/pipelines/custom.py b/pixelle_video/pipelines/custom.py index 01d81dc..f31ca07 100644 --- a/pixelle_video/pipelines/custom.py +++ b/pixelle_video/pipelines/custom.py @@ -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, diff --git a/pixelle_video/pipelines/standard.py b/pixelle_video/pipelines/standard.py index a850c38..972ed7f 100644 --- a/pixelle_video/pipelines/standard.py +++ b/pixelle_video/pipelines/standard.py @@ -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, diff --git a/pixelle_video/service.py b/pixelle_video/service.py index bd02279..8f2b9d9 100644 --- a/pixelle_video/service.py +++ b/pixelle_video/service.py @@ -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 = { diff --git a/pixelle_video/services/__init__.py b/pixelle_video/services/__init__.py index 66e15ab..19029f7 100644 --- a/pixelle_video/services/__init__.py +++ b/pixelle_video/services/__init__.py @@ -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", ] diff --git a/pixelle_video/services/history_manager.py b/pixelle_video/services/history_manager.py new file mode 100644 index 0000000..dfdfde5 --- /dev/null +++ b/pixelle_video/services/history_manager.py @@ -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 + diff --git a/pixelle_video/services/persistence.py b/pixelle_video/services/persistence.py index cd017fa..d739f55 100644 --- a/pixelle_video/services/persistence.py +++ b/pixelle_video/services/persistence.py @@ -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 diff --git a/web/app.py b/web/app.py index 4db3a9c..cde68b6 100644 --- a/web/app.py +++ b/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__": diff --git a/web/i18n/locales/en_US.json b/web/i18n/locales/en_US.json index 30bd987..5211914 100644 --- a/web/i18n/locales/en_US.json +++ b/web/i18n/locales/en_US.json @@ -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}" } } diff --git a/web/i18n/locales/zh_CN.json b/web/i18n/locales/zh_CN.json index a0abb42..b824c7e 100644 --- a/web/i18n/locales/zh_CN.json +++ b/web/i18n/locales/zh_CN.json @@ -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} 页" } } diff --git a/web/pages/1_🎬_Home.py b/web/pages/1_🎬_Home.py new file mode 100644 index 0000000..3fede45 --- /dev/null +++ b/web/pages/1_🎬_Home.py @@ -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() + diff --git a/web/pages/2_📚_History.py b/web/pages/2_📚_History.py new file mode 100644 index 0000000..3a8c832 --- /dev/null +++ b/web/pages/2_📚_History.py @@ -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"
📹
", + 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"
" + f"{tr('history.page_info').format(page=st.session_state.history_page, total_pages=total_pages)}" + f"
", + 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()