media-generation.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. /**
  2. * Media Generation Store
  3. *
  4. * Tracks per-element media generation status (pending → generating → done/failed).
  5. * Drives skeleton loading in slide renderer components.
  6. * Persistence is handled by IndexedDB (mediaFiles table), not Zustand middleware.
  7. */
  8. import { create } from 'zustand';
  9. import type { MediaGenerationRequest } from '@/lib/media/types';
  10. import { db } from '@/lib/utils/database';
  11. import { createLogger } from '@/lib/logger';
  12. const log = createLogger('MediaGenerationStore');
  13. // ==================== Types ====================
  14. export type MediaTaskStatus = 'pending' | 'generating' | 'done' | 'failed';
  15. export interface MediaTask {
  16. elementId: string;
  17. type: 'image' | 'video';
  18. status: MediaTaskStatus;
  19. prompt: string;
  20. params: {
  21. aspectRatio?: string;
  22. style?: string;
  23. duration?: number;
  24. };
  25. objectUrl?: string; // URL.createObjectURL() for rendering
  26. poster?: string; // Video poster objectUrl
  27. error?: string;
  28. errorCode?: string; // Structured error code (e.g. 'CONTENT_SENSITIVE')
  29. retryCount: number;
  30. stageId: string;
  31. }
  32. interface MediaGenerationState {
  33. tasks: Record<string, MediaTask>;
  34. // Batch enqueue
  35. enqueueTasks: (stageId: string, requests: MediaGenerationRequest[]) => void;
  36. // Status transitions
  37. markGenerating: (elementId: string) => void;
  38. markDone: (elementId: string, objectUrl: string, poster?: string) => void;
  39. markFailed: (elementId: string, error: string, errorCode?: string) => void;
  40. // Retry support
  41. markPendingForRetry: (elementId: string) => void;
  42. // Queries
  43. getTask: (elementId: string) => MediaTask | undefined;
  44. isReady: (elementId: string) => boolean;
  45. // Restore from IndexedDB on page load
  46. restoreFromDB: (stageId: string) => Promise<void>;
  47. // Cleanup
  48. clearStage: (stageId: string) => void;
  49. revokeObjectUrls: () => void;
  50. }
  51. // ==================== Helper ====================
  52. /** Check if a src string is a generated media placeholder ID */
  53. export function isMediaPlaceholder(src: string): boolean {
  54. return /^gen_(img|vid)_[\w-]+$/i.test(src);
  55. }
  56. // ==================== Store ====================
  57. export const useMediaGenerationStore = create<MediaGenerationState>()((set, get) => ({
  58. tasks: {},
  59. enqueueTasks: (stageId, requests) => {
  60. const newTasks: Record<string, MediaTask> = {};
  61. for (const req of requests) {
  62. // Skip if already tracked
  63. if (get().tasks[req.elementId]) continue;
  64. newTasks[req.elementId] = {
  65. elementId: req.elementId,
  66. type: req.type,
  67. status: 'pending',
  68. prompt: req.prompt,
  69. params: {
  70. aspectRatio: req.aspectRatio,
  71. style: req.style,
  72. },
  73. retryCount: 0,
  74. stageId,
  75. };
  76. }
  77. if (Object.keys(newTasks).length > 0) {
  78. set((s) => ({ tasks: { ...s.tasks, ...newTasks } }));
  79. }
  80. },
  81. markGenerating: (elementId) =>
  82. set((s) => {
  83. const task = s.tasks[elementId];
  84. if (!task) return s;
  85. return {
  86. tasks: { ...s.tasks, [elementId]: { ...task, status: 'generating' } },
  87. };
  88. }),
  89. markDone: (elementId, objectUrl, poster) =>
  90. set((s) => {
  91. const task = s.tasks[elementId];
  92. if (!task) return s;
  93. return {
  94. tasks: {
  95. ...s.tasks,
  96. [elementId]: {
  97. ...task,
  98. status: 'done',
  99. objectUrl,
  100. poster,
  101. error: undefined,
  102. },
  103. },
  104. };
  105. }),
  106. markFailed: (elementId, error, errorCode) =>
  107. set((s) => {
  108. const task = s.tasks[elementId];
  109. if (!task) return s;
  110. return {
  111. tasks: {
  112. ...s.tasks,
  113. [elementId]: { ...task, status: 'failed', error, errorCode },
  114. },
  115. };
  116. }),
  117. markPendingForRetry: (elementId) =>
  118. set((s) => {
  119. const task = s.tasks[elementId];
  120. if (!task) return s;
  121. return {
  122. tasks: {
  123. ...s.tasks,
  124. [elementId]: {
  125. ...task,
  126. status: 'pending',
  127. error: undefined,
  128. errorCode: undefined,
  129. retryCount: task.retryCount + 1,
  130. },
  131. },
  132. };
  133. }),
  134. getTask: (elementId) => get().tasks[elementId],
  135. isReady: (elementId) => get().tasks[elementId]?.status === 'done',
  136. restoreFromDB: async (stageId) => {
  137. try {
  138. const records = await db.mediaFiles.where('stageId').equals(stageId).toArray();
  139. const restored: Record<string, MediaTask> = {};
  140. for (const rec of records) {
  141. // Extract elementId from compound key (stageId:elementId)
  142. const elementId = rec.id.includes(':') ? rec.id.split(':').slice(1).join(':') : rec.id;
  143. const params = JSON.parse(rec.params || '{}');
  144. if (rec.error) {
  145. // Restore as failed task (persisted non-retryable error)
  146. restored[elementId] = {
  147. elementId,
  148. type: rec.type,
  149. status: 'failed',
  150. prompt: rec.prompt,
  151. params,
  152. error: rec.error,
  153. errorCode: rec.errorCode,
  154. retryCount: 0,
  155. stageId,
  156. };
  157. } else {
  158. // Re-wrap blob with stored mimeType — IndexedDB may drop Blob.type
  159. const blob = rec.blob.type ? rec.blob : new Blob([rec.blob], { type: rec.mimeType });
  160. const objectUrl = URL.createObjectURL(blob);
  161. const poster = rec.poster ? URL.createObjectURL(rec.poster) : undefined;
  162. restored[elementId] = {
  163. elementId,
  164. type: rec.type,
  165. status: 'done',
  166. prompt: rec.prompt,
  167. params,
  168. objectUrl,
  169. poster,
  170. retryCount: 0,
  171. stageId,
  172. };
  173. }
  174. }
  175. if (Object.keys(restored).length > 0) {
  176. set((s) => ({ tasks: { ...s.tasks, ...restored } }));
  177. }
  178. } catch (err) {
  179. log.error('Failed to restore from DB:', err);
  180. }
  181. },
  182. clearStage: (stageId) =>
  183. set((s) => {
  184. const remaining: Record<string, MediaTask> = {};
  185. for (const [id, task] of Object.entries(s.tasks)) {
  186. if (task.stageId !== stageId) {
  187. remaining[id] = task;
  188. } else if (task.objectUrl) {
  189. URL.revokeObjectURL(task.objectUrl);
  190. if (task.poster) URL.revokeObjectURL(task.poster);
  191. }
  192. }
  193. return { tasks: remaining };
  194. }),
  195. revokeObjectUrls: () => {
  196. const tasks = get().tasks;
  197. for (const task of Object.values(tasks)) {
  198. if (task.objectUrl) URL.revokeObjectURL(task.objectUrl);
  199. if (task.poster) URL.revokeObjectURL(task.poster);
  200. }
  201. },
  202. }));