scene-builder.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. /**
  2. * Standalone scene building and element normalization.
  3. * Does NOT depend on store — returns complete Scene objects.
  4. */
  5. import { nanoid } from 'nanoid';
  6. import type {
  7. SceneOutline,
  8. GeneratedSlideContent,
  9. GeneratedQuizContent,
  10. GeneratedInteractiveContent,
  11. GeneratedPBLContent,
  12. PdfImage,
  13. ImageMapping,
  14. } from '@/lib/types/generation';
  15. import type { LanguageModel } from 'ai';
  16. import type { Slide, SlideTheme } from '@/lib/types/slides';
  17. import type { Scene } from '@/lib/types/stage';
  18. import type { Action } from '@/lib/types/action';
  19. import { applyOutlineFallbacks } from './outline-generator';
  20. import { generateSceneContent, generateSceneActions } from './scene-generator';
  21. import type { AgentInfo, SceneGenerationContext, AICallFn } from './pipeline-types';
  22. import { createLogger } from '@/lib/logger';
  23. const log = createLogger('Generation');
  24. /**
  25. * Replace sequential gen_img_N / gen_vid_N IDs in outlines with globally unique IDs.
  26. *
  27. * The LLM generates sequential placeholder IDs (gen_img_1, gen_img_2, ...) which are
  28. * only unique within a single course. Since the media store uses elementId as key
  29. * without stageId scoping, identical IDs across different courses cause thumbnail
  30. * contamination on the homepage. Using nanoid-based IDs ensures global uniqueness.
  31. */
  32. export function uniquifyMediaElementIds(outlines: SceneOutline[]): SceneOutline[] {
  33. const idMap = new Map<string, string>();
  34. // First pass: collect all sequential media IDs and assign unique replacements
  35. for (const outline of outlines) {
  36. if (!outline.mediaGenerations) continue;
  37. for (const mg of outline.mediaGenerations) {
  38. if (!idMap.has(mg.elementId)) {
  39. const prefix = mg.type === 'video' ? 'gen_vid_' : 'gen_img_';
  40. idMap.set(mg.elementId, `${prefix}${nanoid(8)}`);
  41. }
  42. }
  43. }
  44. if (idMap.size === 0) return outlines;
  45. // Second pass: replace IDs in mediaGenerations
  46. return outlines.map((outline) => {
  47. if (!outline.mediaGenerations) return outline;
  48. return {
  49. ...outline,
  50. mediaGenerations: outline.mediaGenerations.map((mg) => ({
  51. ...mg,
  52. elementId: idMap.get(mg.elementId) || mg.elementId,
  53. })),
  54. };
  55. });
  56. }
  57. /**
  58. * Build a complete Scene object from an outline (for SSE streaming)
  59. * This function does NOT depend on store - it returns a complete Scene object
  60. */
  61. export async function buildSceneFromOutline(
  62. outline: SceneOutline,
  63. aiCall: AICallFn,
  64. stageId: string,
  65. assignedImages?: PdfImage[],
  66. imageMapping?: ImageMapping,
  67. languageModel?: LanguageModel,
  68. visionEnabled?: boolean,
  69. ctx?: SceneGenerationContext,
  70. agents?: AgentInfo[],
  71. onPhaseChange?: (phase: 'content' | 'actions') => void,
  72. userProfile?: string,
  73. ): Promise<Scene | null> {
  74. // Apply type fallbacks
  75. outline = applyOutlineFallbacks(outline, !!languageModel);
  76. // Step 1: Generate content (with images if available)
  77. onPhaseChange?.('content');
  78. log.debug(`Step 1: Generating content for: ${outline.title}`);
  79. if (assignedImages && assignedImages.length > 0) {
  80. log.debug(
  81. `Using ${assignedImages.length} assigned images: ${assignedImages.map((img) => img.id).join(', ')}`,
  82. );
  83. }
  84. log.debug(
  85. `imageMapping available: ${imageMapping ? Object.keys(imageMapping).length + ' keys' : 'undefined'}`,
  86. );
  87. const content = await generateSceneContent(
  88. outline,
  89. aiCall,
  90. assignedImages,
  91. imageMapping,
  92. languageModel,
  93. visionEnabled,
  94. undefined,
  95. agents,
  96. );
  97. if (!content) {
  98. log.error(`Failed to generate content for: ${outline.title}`);
  99. return null;
  100. }
  101. // Step 2: Generate Actions
  102. onPhaseChange?.('actions');
  103. log.debug(`Step 2: Generating actions for: ${outline.title}`);
  104. const actions = await generateSceneActions(outline, content, aiCall, ctx, agents, userProfile);
  105. log.debug(`Generated ${actions.length} actions for: ${outline.title}`);
  106. // Build complete Scene object
  107. return buildCompleteScene(outline, content, actions, stageId);
  108. }
  109. /**
  110. * Build complete Scene object (without API/store)
  111. */
  112. export function buildCompleteScene(
  113. outline: SceneOutline,
  114. content:
  115. | GeneratedSlideContent
  116. | GeneratedQuizContent
  117. | GeneratedInteractiveContent
  118. | GeneratedPBLContent,
  119. actions: Action[],
  120. stageId: string,
  121. ): Scene | null {
  122. const sceneId = nanoid();
  123. if (outline.type === 'slide' && 'elements' in content) {
  124. // Build Slide object
  125. const defaultTheme: SlideTheme = {
  126. backgroundColor: '#ffffff',
  127. themeColors: ['#5b9bd5', '#ed7d31', '#a5a5a5', '#ffc000', '#4472c4'],
  128. fontColor: '#333333',
  129. fontName: 'Microsoft YaHei',
  130. outline: { color: '#d14424', width: 2, style: 'solid' },
  131. shadow: { h: 0, v: 0, blur: 10, color: '#000000' },
  132. };
  133. const slide: Slide = {
  134. id: nanoid(),
  135. viewportSize: 1000,
  136. viewportRatio: 0.5625,
  137. theme: defaultTheme,
  138. elements: content.elements,
  139. background: content.background,
  140. };
  141. return {
  142. id: sceneId,
  143. stageId,
  144. type: 'slide',
  145. title: outline.title,
  146. order: outline.order,
  147. content: {
  148. type: 'slide',
  149. canvas: slide,
  150. },
  151. actions,
  152. createdAt: Date.now(),
  153. updatedAt: Date.now(),
  154. };
  155. }
  156. if (outline.type === 'quiz' && 'questions' in content) {
  157. return {
  158. id: sceneId,
  159. stageId,
  160. type: 'quiz',
  161. title: outline.title,
  162. order: outline.order,
  163. content: {
  164. type: 'quiz',
  165. questions: content.questions,
  166. },
  167. actions,
  168. createdAt: Date.now(),
  169. updatedAt: Date.now(),
  170. };
  171. }
  172. if (outline.type === 'interactive' && 'html' in content) {
  173. return {
  174. id: sceneId,
  175. stageId,
  176. type: 'interactive',
  177. title: outline.title,
  178. order: outline.order,
  179. content: {
  180. type: 'interactive',
  181. url: '',
  182. html: content.html,
  183. },
  184. actions,
  185. createdAt: Date.now(),
  186. updatedAt: Date.now(),
  187. };
  188. }
  189. if (outline.type === 'pbl' && 'projectConfig' in content) {
  190. return {
  191. id: sceneId,
  192. stageId,
  193. type: 'pbl',
  194. title: outline.title,
  195. order: outline.order,
  196. content: {
  197. type: 'pbl',
  198. projectConfig: content.projectConfig,
  199. },
  200. actions,
  201. createdAt: Date.now(),
  202. updatedAt: Date.now(),
  203. };
  204. }
  205. return null;
  206. }