| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675 |
- # 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.
- """
- Persistence Service
- Handles task metadata and storyboard persistence to filesystem.
- """
- import json
- from pathlib import Path
- from typing import List, Optional, Dict, Any
- from datetime import datetime
- from loguru import logger
- from pixelle_video.models.storyboard import Storyboard, StoryboardFrame, StoryboardConfig, ContentMetadata
- class PersistenceService:
- """
- Task persistence service using filesystem (JSON)
-
- File structure:
- output/
- └── {task_id}/
- ├── metadata.json # Task metadata (input, result, config)
- ├── storyboard.json # Storyboard data (frames, prompts)
- ├── final.mp4
- └── frames/
- ├── 01_audio.mp3
- ├── 01_image.png
- └── ...
-
- Usage:
- persistence = PersistenceService()
-
- # Save metadata
- await persistence.save_task_metadata(task_id, metadata)
-
- # Save storyboard
- await persistence.save_storyboard(task_id, storyboard)
-
- # Load task
- metadata = await persistence.load_task_metadata(task_id)
- storyboard = await persistence.load_storyboard(task_id)
-
- # List all tasks
- tasks = await persistence.list_tasks(status="completed", limit=50)
- """
-
- def __init__(self, output_dir: str = "output"):
- """
- Initialize persistence service
-
- Args:
- output_dir: Base output directory (default: "output")
- """
- 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"""
- return self.output_dir / task_id
-
- def get_metadata_path(self, task_id: str) -> Path:
- """Get metadata.json path"""
- return self.get_task_dir(task_id) / "metadata.json"
-
- def get_storyboard_path(self, task_id: str) -> Path:
- """Get storyboard.json path"""
- return self.get_task_dir(task_id) / "storyboard.json"
-
- # ========================================================================
- # Metadata Operations
- # ========================================================================
-
- async def save_task_metadata(
- self,
- task_id: str,
- metadata: Dict[str, Any]
- ):
- """
- Save task metadata to filesystem
-
- Args:
- task_id: Task ID
- metadata: Metadata dict with structure:
- {
- "task_id": str,
- "created_at": str,
- "completed_at": str (optional),
- "status": str,
- "input": dict,
- "result": dict (optional),
- "config": dict
- }
- """
- try:
- task_dir = self.get_task_dir(task_id)
- task_dir.mkdir(parents=True, exist_ok=True)
-
- metadata_path = self.get_metadata_path(task_id)
-
- # Ensure task_id is set
- metadata["task_id"] = task_id
-
- # Convert datetime objects to ISO format strings
- if "created_at" in metadata and isinstance(metadata["created_at"], datetime):
- metadata["created_at"] = metadata["created_at"].isoformat()
- if "completed_at" in metadata and isinstance(metadata["completed_at"], datetime):
- metadata["completed_at"] = metadata["completed_at"].isoformat()
-
- with open(metadata_path, "w", encoding="utf-8") as f:
- json.dump(metadata, f, indent=2, ensure_ascii=False)
-
- 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
-
- async def load_task_metadata(self, task_id: str) -> Optional[Dict[str, Any]]:
- """
- Load task metadata from filesystem
-
- Args:
- task_id: Task ID
-
- Returns:
- Metadata dict or None if not found
- """
- try:
- metadata_path = self.get_metadata_path(task_id)
-
- if not metadata_path.exists():
- return None
-
- with open(metadata_path, "r", encoding="utf-8") as f:
- metadata = json.load(f)
-
- return metadata
-
- except Exception as e:
- logger.error(f"Failed to load task metadata {task_id}: {e}")
- return None
-
- async def update_task_status(
- self,
- task_id: str,
- status: str,
- error: Optional[str] = None
- ):
- """
- Update task status in metadata
-
- Args:
- task_id: Task ID
- status: New status (pending, running, completed, failed, cancelled)
- error: Error message (optional, for failed status)
- """
- try:
- metadata = await self.load_task_metadata(task_id)
- if not metadata:
- logger.warning(f"Cannot update status: task {task_id} not found")
- return
-
- metadata["status"] = status
-
- if status in ["completed", "failed", "cancelled"]:
- metadata["completed_at"] = datetime.now().isoformat()
-
- if error:
- metadata["error"] = error
-
- await self.save_task_metadata(task_id, metadata)
-
- except Exception as e:
- logger.error(f"Failed to update task status {task_id}: {e}")
-
- # ========================================================================
- # Storyboard Operations
- # ========================================================================
-
- async def save_storyboard(
- self,
- task_id: str,
- storyboard: Storyboard
- ):
- """
- Save storyboard to filesystem
-
- Args:
- task_id: Task ID
- storyboard: Storyboard instance
- """
- try:
- task_dir = self.get_task_dir(task_id)
- task_dir.mkdir(parents=True, exist_ok=True)
-
- storyboard_path = self.get_storyboard_path(task_id)
-
- # Convert storyboard to dict
- storyboard_dict = self._storyboard_to_dict(storyboard)
-
- with open(storyboard_path, "w", encoding="utf-8") as f:
- json.dump(storyboard_dict, f, indent=2, ensure_ascii=False)
-
- logger.debug(f"Saved storyboard: {task_id}")
-
- except Exception as e:
- logger.error(f"Failed to save storyboard {task_id}: {e}")
- raise
-
- async def load_storyboard(self, task_id: str) -> Optional[Storyboard]:
- """
- Load storyboard from filesystem
-
- Args:
- task_id: Task ID
-
- Returns:
- Storyboard instance or None if not found
- """
- try:
- storyboard_path = self.get_storyboard_path(task_id)
-
- if not storyboard_path.exists():
- return None
-
- with open(storyboard_path, "r", encoding="utf-8") as f:
- storyboard_dict = json.load(f)
-
- # Convert dict to storyboard
- storyboard = self._dict_to_storyboard(storyboard_dict)
-
- return storyboard
-
- except Exception as e:
- logger.error(f"Failed to load storyboard {task_id}: {e}")
- return None
-
- # ========================================================================
- # Task Listing & Querying
- # ========================================================================
-
- async def list_tasks(
- self,
- status: Optional[str] = None,
- limit: int = 50,
- offset: int = 0
- ) -> List[Dict[str, Any]]:
- """
- List tasks with optional filtering
-
- Args:
- status: Filter by status (pending, running, completed, failed, cancelled)
- limit: Maximum number of tasks to return
- offset: Number of tasks to skip
-
- Returns:
- List of metadata dicts, sorted by created_at descending
- """
- try:
- 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 by created_at descending
- tasks.sort(key=lambda t: t.get("created_at", ""), reverse=True)
- # Apply pagination
- return tasks[offset:offset + limit]
- except Exception as e:
- logger.error(f"Failed to list tasks: {e}")
- return []
-
- async def task_exists(self, task_id: str) -> bool:
- """Check if task exists"""
- return self.get_task_dir(task_id).exists()
- # ========================================================================
- # Serialization Helpers
- # ========================================================================
-
- def _storyboard_to_dict(self, storyboard: Storyboard) -> Dict[str, Any]:
- """Convert Storyboard to dict for JSON serialization"""
- return {
- "title": storyboard.title,
- "config": self._config_to_dict(storyboard.config),
- "frames": [self._frame_to_dict(frame) for frame in storyboard.frames],
- "content_metadata": self._content_metadata_to_dict(storyboard.content_metadata) if storyboard.content_metadata else None,
- "final_video_path": storyboard.final_video_path,
- "total_duration": storyboard.total_duration,
- "created_at": storyboard.created_at.isoformat() if storyboard.created_at else None,
- "completed_at": storyboard.completed_at.isoformat() if storyboard.completed_at else None,
- }
-
- def _dict_to_storyboard(self, data: Dict[str, Any]) -> Storyboard:
- """Convert dict to Storyboard instance"""
- return Storyboard(
- title=data["title"],
- config=self._dict_to_config(data["config"]),
- frames=[self._dict_to_frame(frame_data) for frame_data in data["frames"]],
- content_metadata=self._dict_to_content_metadata(data["content_metadata"]) if data.get("content_metadata") else None,
- final_video_path=data.get("final_video_path"),
- total_duration=data.get("total_duration", 0.0),
- created_at=datetime.fromisoformat(data["created_at"]) if data.get("created_at") else None,
- completed_at=datetime.fromisoformat(data["completed_at"]) if data.get("completed_at") else None,
- )
-
- def _config_to_dict(self, config: StoryboardConfig) -> Dict[str, Any]:
- """Convert StoryboardConfig to dict"""
- return {
- "task_id": config.task_id,
- "n_storyboard": config.n_storyboard,
- "min_narration_words": config.min_narration_words,
- "max_narration_words": config.max_narration_words,
- "min_image_prompt_words": config.min_image_prompt_words,
- "max_image_prompt_words": config.max_image_prompt_words,
- "video_fps": config.video_fps,
- "tts_inference_mode": config.tts_inference_mode,
- "voice_id": config.voice_id,
- "tts_workflow": config.tts_workflow,
- "tts_speed": config.tts_speed,
- "ref_audio": config.ref_audio,
- "media_width": config.media_width,
- "media_height": config.media_height,
- "media_workflow": config.media_workflow,
- "frame_template": config.frame_template,
- "template_params": config.template_params,
- }
-
- def _dict_to_config(self, data: Dict[str, Any]) -> StoryboardConfig:
- """Convert dict to StoryboardConfig"""
- return StoryboardConfig(
- task_id=data.get("task_id"),
- n_storyboard=data.get("n_storyboard", 5),
- min_narration_words=data.get("min_narration_words", 5),
- max_narration_words=data.get("max_narration_words", 20),
- min_image_prompt_words=data.get("min_image_prompt_words", 30),
- max_image_prompt_words=data.get("max_image_prompt_words", 60),
- video_fps=data.get("video_fps", 30),
- tts_inference_mode=data.get("tts_inference_mode", "local"),
- voice_id=data.get("voice_id"),
- tts_workflow=data.get("tts_workflow"),
- tts_speed=data.get("tts_speed"),
- ref_audio=data.get("ref_audio"),
- media_width=data.get("media_width", data.get("image_width", 1024)), # Backward compatibility
- media_height=data.get("media_height", data.get("image_height", 1024)), # Backward compatibility
- media_workflow=data.get("media_workflow", data.get("image_workflow")), # Backward compatibility
- frame_template=data.get("frame_template", "1080x1920/default.html"),
- template_params=data.get("template_params"),
- )
-
- def _frame_to_dict(self, frame: StoryboardFrame) -> Dict[str, Any]:
- """Convert StoryboardFrame to dict"""
- return {
- "index": frame.index,
- "narration": frame.narration,
- "image_prompt": frame.image_prompt,
- "audio_path": frame.audio_path,
- "media_type": frame.media_type,
- "image_path": frame.image_path,
- "video_path": frame.video_path,
- "composed_image_path": frame.composed_image_path,
- "video_segment_path": frame.video_segment_path,
- "duration": frame.duration,
- "created_at": frame.created_at.isoformat() if frame.created_at else None,
- }
-
- def _dict_to_frame(self, data: Dict[str, Any]) -> StoryboardFrame:
- """Convert dict to StoryboardFrame"""
- return StoryboardFrame(
- index=data["index"],
- narration=data["narration"],
- image_prompt=data["image_prompt"],
- audio_path=data.get("audio_path"),
- media_type=data.get("media_type"),
- image_path=data.get("image_path"),
- video_path=data.get("video_path"),
- composed_image_path=data.get("composed_image_path"),
- video_segment_path=data.get("video_segment_path"),
- duration=data.get("duration", 0.0),
- created_at=datetime.fromisoformat(data["created_at"]) if data.get("created_at") else None,
- )
-
- def _content_metadata_to_dict(self, metadata: ContentMetadata) -> Dict[str, Any]:
- """Convert ContentMetadata to dict"""
- return {
- "title": metadata.title,
- "author": metadata.author,
- "subtitle": metadata.subtitle,
- "genre": metadata.genre,
- "summary": metadata.summary,
- "publication_year": metadata.publication_year,
- "cover_url": metadata.cover_url,
- }
-
- def _dict_to_content_metadata(self, data: Dict[str, Any]) -> ContentMetadata:
- """Convert dict to ContentMetadata"""
- return ContentMetadata(
- title=data["title"],
- author=data.get("author"),
- subtitle=data.get("subtitle"),
- genre=data.get("genre"),
- summary=data.get("summary"),
- 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
|