persistence.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675
  1. # Copyright (C) 2025 AIDC-AI
  2. #
  3. # Licensed under the Apache License, Version 2.0 (the "License");
  4. # you may not use this file except in compliance with the License.
  5. # You may obtain a copy of the License at
  6. # http://www.apache.org/licenses/LICENSE-2.0
  7. # Unless required by applicable law or agreed to in writing, software
  8. # distributed under the License is distributed on an "AS IS" BASIS,
  9. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. # See the License for the specific language governing permissions and
  11. # limitations under the License.
  12. """
  13. Persistence Service
  14. Handles task metadata and storyboard persistence to filesystem.
  15. """
  16. import json
  17. from pathlib import Path
  18. from typing import List, Optional, Dict, Any
  19. from datetime import datetime
  20. from loguru import logger
  21. from pixelle_video.models.storyboard import Storyboard, StoryboardFrame, StoryboardConfig, ContentMetadata
  22. class PersistenceService:
  23. """
  24. Task persistence service using filesystem (JSON)
  25. File structure:
  26. output/
  27. └── {task_id}/
  28. ├── metadata.json # Task metadata (input, result, config)
  29. ├── storyboard.json # Storyboard data (frames, prompts)
  30. ├── final.mp4
  31. └── frames/
  32. ├── 01_audio.mp3
  33. ├── 01_image.png
  34. └── ...
  35. Usage:
  36. persistence = PersistenceService()
  37. # Save metadata
  38. await persistence.save_task_metadata(task_id, metadata)
  39. # Save storyboard
  40. await persistence.save_storyboard(task_id, storyboard)
  41. # Load task
  42. metadata = await persistence.load_task_metadata(task_id)
  43. storyboard = await persistence.load_storyboard(task_id)
  44. # List all tasks
  45. tasks = await persistence.list_tasks(status="completed", limit=50)
  46. """
  47. def __init__(self, output_dir: str = "output"):
  48. """
  49. Initialize persistence service
  50. Args:
  51. output_dir: Base output directory (default: "output")
  52. """
  53. self.output_dir = Path(output_dir)
  54. self.output_dir.mkdir(exist_ok=True)
  55. # Index file for fast listing
  56. self.index_file = self.output_dir / ".index.json"
  57. self._ensure_index()
  58. def get_task_dir(self, task_id: str) -> Path:
  59. """Get task directory path"""
  60. return self.output_dir / task_id
  61. def get_metadata_path(self, task_id: str) -> Path:
  62. """Get metadata.json path"""
  63. return self.get_task_dir(task_id) / "metadata.json"
  64. def get_storyboard_path(self, task_id: str) -> Path:
  65. """Get storyboard.json path"""
  66. return self.get_task_dir(task_id) / "storyboard.json"
  67. # ========================================================================
  68. # Metadata Operations
  69. # ========================================================================
  70. async def save_task_metadata(
  71. self,
  72. task_id: str,
  73. metadata: Dict[str, Any]
  74. ):
  75. """
  76. Save task metadata to filesystem
  77. Args:
  78. task_id: Task ID
  79. metadata: Metadata dict with structure:
  80. {
  81. "task_id": str,
  82. "created_at": str,
  83. "completed_at": str (optional),
  84. "status": str,
  85. "input": dict,
  86. "result": dict (optional),
  87. "config": dict
  88. }
  89. """
  90. try:
  91. task_dir = self.get_task_dir(task_id)
  92. task_dir.mkdir(parents=True, exist_ok=True)
  93. metadata_path = self.get_metadata_path(task_id)
  94. # Ensure task_id is set
  95. metadata["task_id"] = task_id
  96. # Convert datetime objects to ISO format strings
  97. if "created_at" in metadata and isinstance(metadata["created_at"], datetime):
  98. metadata["created_at"] = metadata["created_at"].isoformat()
  99. if "completed_at" in metadata and isinstance(metadata["completed_at"], datetime):
  100. metadata["completed_at"] = metadata["completed_at"].isoformat()
  101. with open(metadata_path, "w", encoding="utf-8") as f:
  102. json.dump(metadata, f, indent=2, ensure_ascii=False)
  103. logger.debug(f"Saved task metadata: {task_id}")
  104. # Update index
  105. await self._update_index_for_task(task_id, metadata)
  106. except Exception as e:
  107. logger.error(f"Failed to save task metadata {task_id}: {e}")
  108. raise
  109. async def load_task_metadata(self, task_id: str) -> Optional[Dict[str, Any]]:
  110. """
  111. Load task metadata from filesystem
  112. Args:
  113. task_id: Task ID
  114. Returns:
  115. Metadata dict or None if not found
  116. """
  117. try:
  118. metadata_path = self.get_metadata_path(task_id)
  119. if not metadata_path.exists():
  120. return None
  121. with open(metadata_path, "r", encoding="utf-8") as f:
  122. metadata = json.load(f)
  123. return metadata
  124. except Exception as e:
  125. logger.error(f"Failed to load task metadata {task_id}: {e}")
  126. return None
  127. async def update_task_status(
  128. self,
  129. task_id: str,
  130. status: str,
  131. error: Optional[str] = None
  132. ):
  133. """
  134. Update task status in metadata
  135. Args:
  136. task_id: Task ID
  137. status: New status (pending, running, completed, failed, cancelled)
  138. error: Error message (optional, for failed status)
  139. """
  140. try:
  141. metadata = await self.load_task_metadata(task_id)
  142. if not metadata:
  143. logger.warning(f"Cannot update status: task {task_id} not found")
  144. return
  145. metadata["status"] = status
  146. if status in ["completed", "failed", "cancelled"]:
  147. metadata["completed_at"] = datetime.now().isoformat()
  148. if error:
  149. metadata["error"] = error
  150. await self.save_task_metadata(task_id, metadata)
  151. except Exception as e:
  152. logger.error(f"Failed to update task status {task_id}: {e}")
  153. # ========================================================================
  154. # Storyboard Operations
  155. # ========================================================================
  156. async def save_storyboard(
  157. self,
  158. task_id: str,
  159. storyboard: Storyboard
  160. ):
  161. """
  162. Save storyboard to filesystem
  163. Args:
  164. task_id: Task ID
  165. storyboard: Storyboard instance
  166. """
  167. try:
  168. task_dir = self.get_task_dir(task_id)
  169. task_dir.mkdir(parents=True, exist_ok=True)
  170. storyboard_path = self.get_storyboard_path(task_id)
  171. # Convert storyboard to dict
  172. storyboard_dict = self._storyboard_to_dict(storyboard)
  173. with open(storyboard_path, "w", encoding="utf-8") as f:
  174. json.dump(storyboard_dict, f, indent=2, ensure_ascii=False)
  175. logger.debug(f"Saved storyboard: {task_id}")
  176. except Exception as e:
  177. logger.error(f"Failed to save storyboard {task_id}: {e}")
  178. raise
  179. async def load_storyboard(self, task_id: str) -> Optional[Storyboard]:
  180. """
  181. Load storyboard from filesystem
  182. Args:
  183. task_id: Task ID
  184. Returns:
  185. Storyboard instance or None if not found
  186. """
  187. try:
  188. storyboard_path = self.get_storyboard_path(task_id)
  189. if not storyboard_path.exists():
  190. return None
  191. with open(storyboard_path, "r", encoding="utf-8") as f:
  192. storyboard_dict = json.load(f)
  193. # Convert dict to storyboard
  194. storyboard = self._dict_to_storyboard(storyboard_dict)
  195. return storyboard
  196. except Exception as e:
  197. logger.error(f"Failed to load storyboard {task_id}: {e}")
  198. return None
  199. # ========================================================================
  200. # Task Listing & Querying
  201. # ========================================================================
  202. async def list_tasks(
  203. self,
  204. status: Optional[str] = None,
  205. limit: int = 50,
  206. offset: int = 0
  207. ) -> List[Dict[str, Any]]:
  208. """
  209. List tasks with optional filtering
  210. Args:
  211. status: Filter by status (pending, running, completed, failed, cancelled)
  212. limit: Maximum number of tasks to return
  213. offset: Number of tasks to skip
  214. Returns:
  215. List of metadata dicts, sorted by created_at descending
  216. """
  217. try:
  218. index = self._load_index()
  219. tasks = index.get("tasks", [])
  220. # Filter by status
  221. if status:
  222. tasks = [t for t in tasks if t.get("status") == status]
  223. # Sort by created_at descending
  224. tasks.sort(key=lambda t: t.get("created_at", ""), reverse=True)
  225. # Apply pagination
  226. return tasks[offset:offset + limit]
  227. except Exception as e:
  228. logger.error(f"Failed to list tasks: {e}")
  229. return []
  230. async def task_exists(self, task_id: str) -> bool:
  231. """Check if task exists"""
  232. return self.get_task_dir(task_id).exists()
  233. # ========================================================================
  234. # Serialization Helpers
  235. # ========================================================================
  236. def _storyboard_to_dict(self, storyboard: Storyboard) -> Dict[str, Any]:
  237. """Convert Storyboard to dict for JSON serialization"""
  238. return {
  239. "title": storyboard.title,
  240. "config": self._config_to_dict(storyboard.config),
  241. "frames": [self._frame_to_dict(frame) for frame in storyboard.frames],
  242. "content_metadata": self._content_metadata_to_dict(storyboard.content_metadata) if storyboard.content_metadata else None,
  243. "final_video_path": storyboard.final_video_path,
  244. "total_duration": storyboard.total_duration,
  245. "created_at": storyboard.created_at.isoformat() if storyboard.created_at else None,
  246. "completed_at": storyboard.completed_at.isoformat() if storyboard.completed_at else None,
  247. }
  248. def _dict_to_storyboard(self, data: Dict[str, Any]) -> Storyboard:
  249. """Convert dict to Storyboard instance"""
  250. return Storyboard(
  251. title=data["title"],
  252. config=self._dict_to_config(data["config"]),
  253. frames=[self._dict_to_frame(frame_data) for frame_data in data["frames"]],
  254. content_metadata=self._dict_to_content_metadata(data["content_metadata"]) if data.get("content_metadata") else None,
  255. final_video_path=data.get("final_video_path"),
  256. total_duration=data.get("total_duration", 0.0),
  257. created_at=datetime.fromisoformat(data["created_at"]) if data.get("created_at") else None,
  258. completed_at=datetime.fromisoformat(data["completed_at"]) if data.get("completed_at") else None,
  259. )
  260. def _config_to_dict(self, config: StoryboardConfig) -> Dict[str, Any]:
  261. """Convert StoryboardConfig to dict"""
  262. return {
  263. "task_id": config.task_id,
  264. "n_storyboard": config.n_storyboard,
  265. "min_narration_words": config.min_narration_words,
  266. "max_narration_words": config.max_narration_words,
  267. "min_image_prompt_words": config.min_image_prompt_words,
  268. "max_image_prompt_words": config.max_image_prompt_words,
  269. "video_fps": config.video_fps,
  270. "tts_inference_mode": config.tts_inference_mode,
  271. "voice_id": config.voice_id,
  272. "tts_workflow": config.tts_workflow,
  273. "tts_speed": config.tts_speed,
  274. "ref_audio": config.ref_audio,
  275. "media_width": config.media_width,
  276. "media_height": config.media_height,
  277. "media_workflow": config.media_workflow,
  278. "frame_template": config.frame_template,
  279. "template_params": config.template_params,
  280. }
  281. def _dict_to_config(self, data: Dict[str, Any]) -> StoryboardConfig:
  282. """Convert dict to StoryboardConfig"""
  283. return StoryboardConfig(
  284. task_id=data.get("task_id"),
  285. n_storyboard=data.get("n_storyboard", 5),
  286. min_narration_words=data.get("min_narration_words", 5),
  287. max_narration_words=data.get("max_narration_words", 20),
  288. min_image_prompt_words=data.get("min_image_prompt_words", 30),
  289. max_image_prompt_words=data.get("max_image_prompt_words", 60),
  290. video_fps=data.get("video_fps", 30),
  291. tts_inference_mode=data.get("tts_inference_mode", "local"),
  292. voice_id=data.get("voice_id"),
  293. tts_workflow=data.get("tts_workflow"),
  294. tts_speed=data.get("tts_speed"),
  295. ref_audio=data.get("ref_audio"),
  296. media_width=data.get("media_width", data.get("image_width", 1024)), # Backward compatibility
  297. media_height=data.get("media_height", data.get("image_height", 1024)), # Backward compatibility
  298. media_workflow=data.get("media_workflow", data.get("image_workflow")), # Backward compatibility
  299. frame_template=data.get("frame_template", "1080x1920/default.html"),
  300. template_params=data.get("template_params"),
  301. )
  302. def _frame_to_dict(self, frame: StoryboardFrame) -> Dict[str, Any]:
  303. """Convert StoryboardFrame to dict"""
  304. return {
  305. "index": frame.index,
  306. "narration": frame.narration,
  307. "image_prompt": frame.image_prompt,
  308. "audio_path": frame.audio_path,
  309. "media_type": frame.media_type,
  310. "image_path": frame.image_path,
  311. "video_path": frame.video_path,
  312. "composed_image_path": frame.composed_image_path,
  313. "video_segment_path": frame.video_segment_path,
  314. "duration": frame.duration,
  315. "created_at": frame.created_at.isoformat() if frame.created_at else None,
  316. }
  317. def _dict_to_frame(self, data: Dict[str, Any]) -> StoryboardFrame:
  318. """Convert dict to StoryboardFrame"""
  319. return StoryboardFrame(
  320. index=data["index"],
  321. narration=data["narration"],
  322. image_prompt=data["image_prompt"],
  323. audio_path=data.get("audio_path"),
  324. media_type=data.get("media_type"),
  325. image_path=data.get("image_path"),
  326. video_path=data.get("video_path"),
  327. composed_image_path=data.get("composed_image_path"),
  328. video_segment_path=data.get("video_segment_path"),
  329. duration=data.get("duration", 0.0),
  330. created_at=datetime.fromisoformat(data["created_at"]) if data.get("created_at") else None,
  331. )
  332. def _content_metadata_to_dict(self, metadata: ContentMetadata) -> Dict[str, Any]:
  333. """Convert ContentMetadata to dict"""
  334. return {
  335. "title": metadata.title,
  336. "author": metadata.author,
  337. "subtitle": metadata.subtitle,
  338. "genre": metadata.genre,
  339. "summary": metadata.summary,
  340. "publication_year": metadata.publication_year,
  341. "cover_url": metadata.cover_url,
  342. }
  343. def _dict_to_content_metadata(self, data: Dict[str, Any]) -> ContentMetadata:
  344. """Convert dict to ContentMetadata"""
  345. return ContentMetadata(
  346. title=data["title"],
  347. author=data.get("author"),
  348. subtitle=data.get("subtitle"),
  349. genre=data.get("genre"),
  350. summary=data.get("summary"),
  351. publication_year=data.get("publication_year"),
  352. cover_url=data.get("cover_url"),
  353. )
  354. # ========================================================================
  355. # Index Management (for fast listing)
  356. # ========================================================================
  357. def _ensure_index(self):
  358. """Ensure index file exists, create if not"""
  359. if not self.index_file.exists():
  360. self._save_index({"version": "1.0", "tasks": []})
  361. def _load_index(self) -> Dict[str, Any]:
  362. """Load index from file"""
  363. try:
  364. with open(self.index_file, "r", encoding="utf-8") as f:
  365. return json.load(f)
  366. except Exception as e:
  367. logger.error(f"Failed to load index: {e}")
  368. return {"version": "1.0", "tasks": []}
  369. def _save_index(self, index_data: Dict[str, Any]):
  370. """Save index to file"""
  371. try:
  372. index_data["last_updated"] = datetime.now().isoformat()
  373. with open(self.index_file, "w", encoding="utf-8") as f:
  374. json.dump(index_data, f, ensure_ascii=False, indent=2)
  375. except Exception as e:
  376. logger.error(f"Failed to save index: {e}")
  377. async def _update_index_for_task(self, task_id: str, metadata: Dict[str, Any]):
  378. """Update index entry for a specific task"""
  379. index = self._load_index()
  380. # Try to get title from multiple sources
  381. title = metadata.get("input", {}).get("title")
  382. if not title or title == "":
  383. # Try to get title from storyboard if input title is empty
  384. storyboard = await self.load_storyboard(task_id)
  385. if storyboard and storyboard.title:
  386. title = storyboard.title
  387. else:
  388. # Fall back to using input text preview
  389. input_text = metadata.get("input", {}).get("text", "")
  390. if input_text:
  391. # Use first 30 characters of input text as title
  392. title = input_text[:30] + ("..." if len(input_text) > 30 else "")
  393. else:
  394. title = "Untitled"
  395. # Extract key info for index
  396. index_entry = {
  397. "task_id": task_id,
  398. "created_at": metadata.get("created_at"),
  399. "completed_at": metadata.get("completed_at"),
  400. "status": metadata.get("status", "unknown"),
  401. "title": title,
  402. "duration": metadata.get("result", {}).get("duration", 0),
  403. "n_frames": metadata.get("result", {}).get("n_frames", 0),
  404. "file_size": metadata.get("result", {}).get("file_size", 0),
  405. "video_path": metadata.get("result", {}).get("video_path"),
  406. }
  407. # Update or append
  408. tasks = index.get("tasks", [])
  409. existing_idx = next((i for i, t in enumerate(tasks) if t["task_id"] == task_id), None)
  410. if existing_idx is not None:
  411. tasks[existing_idx] = index_entry
  412. else:
  413. tasks.append(index_entry)
  414. index["tasks"] = tasks
  415. self._save_index(index)
  416. async def rebuild_index(self):
  417. """Rebuild index by scanning all task directories"""
  418. logger.info("Rebuilding task index...")
  419. index = {"version": "1.0", "tasks": []}
  420. # Scan all directories
  421. for task_dir in self.output_dir.iterdir():
  422. if not task_dir.is_dir() or task_dir.name.startswith("."):
  423. continue
  424. task_id = task_dir.name
  425. metadata = await self.load_task_metadata(task_id)
  426. if metadata:
  427. # Try to get title from multiple sources
  428. title = metadata.get("input", {}).get("title")
  429. if not title or title == "":
  430. # Try to get title from storyboard if input title is empty
  431. storyboard = await self.load_storyboard(task_id)
  432. if storyboard and storyboard.title:
  433. title = storyboard.title
  434. else:
  435. # Fall back to using input text preview
  436. input_text = metadata.get("input", {}).get("text", "")
  437. if input_text:
  438. # Use first 30 characters of input text as title
  439. title = input_text[:30] + ("..." if len(input_text) > 30 else "")
  440. else:
  441. title = "Untitled"
  442. # Add to index
  443. index["tasks"].append({
  444. "task_id": task_id,
  445. "created_at": metadata.get("created_at"),
  446. "completed_at": metadata.get("completed_at"),
  447. "status": metadata.get("status", "unknown"),
  448. "title": title,
  449. "duration": metadata.get("result", {}).get("duration", 0),
  450. "n_frames": metadata.get("result", {}).get("n_frames", 0),
  451. "file_size": metadata.get("result", {}).get("file_size", 0),
  452. "video_path": metadata.get("result", {}).get("video_path"),
  453. })
  454. self._save_index(index)
  455. logger.info(f"Index rebuilt: {len(index['tasks'])} tasks")
  456. # ========================================================================
  457. # Paginated Listing
  458. # ========================================================================
  459. async def list_tasks_paginated(
  460. self,
  461. page: int = 1,
  462. page_size: int = 20,
  463. status: Optional[str] = None,
  464. sort_by: str = "created_at",
  465. sort_order: str = "desc"
  466. ) -> Dict[str, Any]:
  467. """
  468. List tasks with pagination
  469. Args:
  470. page: Page number (1-indexed)
  471. page_size: Items per page
  472. status: Filter by status (optional)
  473. sort_by: Sort field (created_at, completed_at, title, duration)
  474. sort_order: Sort order (asc, desc)
  475. Returns:
  476. {
  477. "tasks": [...], # List of task summaries
  478. "total": 100, # Total matching tasks
  479. "page": 1, # Current page
  480. "page_size": 20, # Items per page
  481. "total_pages": 5 # Total pages
  482. }
  483. """
  484. index = self._load_index()
  485. tasks = index.get("tasks", [])
  486. # Filter by status
  487. if status:
  488. tasks = [t for t in tasks if t.get("status") == status]
  489. # Sort
  490. reverse = (sort_order == "desc")
  491. if sort_by in ["created_at", "completed_at"]:
  492. tasks.sort(
  493. key=lambda t: datetime.fromisoformat(t.get(sort_by, "1970-01-01T00:00:00")),
  494. reverse=reverse
  495. )
  496. elif sort_by in ["title", "duration", "n_frames"]:
  497. tasks.sort(key=lambda t: t.get(sort_by, ""), reverse=reverse)
  498. # Paginate
  499. total = len(tasks)
  500. total_pages = (total + page_size - 1) // page_size
  501. start_idx = (page - 1) * page_size
  502. end_idx = start_idx + page_size
  503. page_tasks = tasks[start_idx:end_idx]
  504. return {
  505. "tasks": page_tasks,
  506. "total": total,
  507. "page": page,
  508. "page_size": page_size,
  509. "total_pages": total_pages,
  510. }
  511. # ========================================================================
  512. # Statistics
  513. # ========================================================================
  514. async def get_statistics(self) -> Dict[str, Any]:
  515. """
  516. Get statistics about all tasks
  517. Returns:
  518. {
  519. "total_tasks": 100,
  520. "completed": 95,
  521. "failed": 5,
  522. "total_duration": 3600.5, # seconds
  523. "total_size": 1024000000, # bytes
  524. }
  525. """
  526. index = self._load_index()
  527. tasks = index.get("tasks", [])
  528. stats = {
  529. "total_tasks": len(tasks),
  530. "completed": len([t for t in tasks if t.get("status") == "completed"]),
  531. "failed": len([t for t in tasks if t.get("status") == "failed"]),
  532. "total_duration": sum(t.get("duration", 0) for t in tasks),
  533. "total_size": sum(t.get("file_size", 0) for t in tasks),
  534. }
  535. return stats
  536. # ========================================================================
  537. # Delete Task
  538. # ========================================================================
  539. async def delete_task(self, task_id: str) -> bool:
  540. """
  541. Delete a task and all its files
  542. Args:
  543. task_id: Task ID to delete
  544. Returns:
  545. True if successful, False otherwise
  546. """
  547. try:
  548. import shutil
  549. task_dir = self.get_task_dir(task_id)
  550. if task_dir.exists():
  551. shutil.rmtree(task_dir)
  552. logger.info(f"Deleted task directory: {task_dir}")
  553. # Update index
  554. index = self._load_index()
  555. tasks = index.get("tasks", [])
  556. tasks = [t for t in tasks if t["task_id"] != task_id]
  557. index["tasks"] = tasks
  558. self._save_index(index)
  559. return True
  560. except Exception as e:
  561. logger.error(f"Failed to delete task {task_id}: {e}")
  562. return False