OpenMAIC-React / src /lib /generation /scene-builder.ts
muthuk1's picture
Convert OpenMAIC from Next.js to React (Vite)
f56a29b verified
raw
history blame
6.75 kB
/**
* Standalone scene building and element normalization.
* Does NOT depend on store — returns complete Scene objects.
*/
import { nanoid } from 'nanoid';
import type {
SceneOutline,
GeneratedSlideContent,
GeneratedQuizContent,
GeneratedInteractiveContent,
GeneratedPBLContent,
PdfImage,
ImageMapping,
} from '@/lib/types/generation';
import type { LanguageModel } from 'ai';
import type { Slide, SlideTheme } from '@/lib/types/slides';
import type { Scene } from '@/lib/types/stage';
import type { Action } from '@/lib/types/action';
import { applyOutlineFallbacks } from './outline-generator';
import { generateSceneContent, generateSceneActions } from './scene-generator';
import type { AgentInfo, SceneGenerationContext, AICallFn } from './pipeline-types';
import { buildLanguageText } from './prompt-formatters';
import { createLogger } from '@/lib/logger';
const log = createLogger('Generation');
/**
* Replace sequential gen_img_N / gen_vid_N IDs in outlines with globally unique IDs.
*
* The LLM generates sequential placeholder IDs (gen_img_1, gen_img_2, ...) which are
* only unique within a single course. Since the media store uses elementId as key
* without stageId scoping, identical IDs across different courses cause thumbnail
* contamination on the homepage. Using nanoid-based IDs ensures global uniqueness.
*/
export function uniquifyMediaElementIds(outlines: SceneOutline[]): SceneOutline[] {
const idMap = new Map<string, string>();
// First pass: collect all sequential media IDs and assign unique replacements
for (const outline of outlines) {
if (!outline.mediaGenerations) continue;
for (const mg of outline.mediaGenerations) {
if (!idMap.has(mg.elementId)) {
const prefix = mg.type === 'video' ? 'gen_vid_' : 'gen_img_';
idMap.set(mg.elementId, `${prefix}${nanoid(8)}`);
}
}
}
if (idMap.size === 0) return outlines;
// Second pass: replace IDs in mediaGenerations
return outlines.map((outline) => {
if (!outline.mediaGenerations) return outline;
return {
...outline,
mediaGenerations: outline.mediaGenerations.map((mg) => ({
...mg,
elementId: idMap.get(mg.elementId) || mg.elementId,
})),
};
});
}
/**
* Build a complete Scene object from an outline (for SSE streaming)
* This function does NOT depend on store - it returns a complete Scene object
*/
export async function buildSceneFromOutline(
outline: SceneOutline,
aiCall: AICallFn,
stageId: string,
assignedImages?: PdfImage[],
imageMapping?: ImageMapping,
languageModel?: LanguageModel,
visionEnabled?: boolean,
ctx?: SceneGenerationContext,
agents?: AgentInfo[],
onPhaseChange?: (phase: 'content' | 'actions') => void,
userProfile?: string,
languageDirective?: string,
): Promise<Scene | null> {
// Apply type fallbacks
outline = applyOutlineFallbacks(outline, !!languageModel);
const langText = buildLanguageText(languageDirective, outline.languageNote);
// Step 1: Generate content (with images if available)
onPhaseChange?.('content');
log.debug(`Step 1: Generating content for: ${outline.title}`);
if (assignedImages && assignedImages.length > 0) {
log.debug(
`Using ${assignedImages.length} assigned images: ${assignedImages.map((img) => img.id).join(', ')}`,
);
}
log.debug(
`imageMapping available: ${imageMapping ? Object.keys(imageMapping).length + ' keys' : 'undefined'}`,
);
const content = await generateSceneContent(outline, aiCall, {
assignedImages,
imageMapping,
languageModel,
visionEnabled,
agents,
languageDirective: langText,
});
if (!content) {
log.error(`Failed to generate content for: ${outline.title}`);
return null;
}
// Step 2: Generate Actions
onPhaseChange?.('actions');
log.debug(`Step 2: Generating actions for: ${outline.title}`);
const actions = await generateSceneActions(outline, content, aiCall, {
ctx,
agents,
userProfile,
languageDirective: langText,
});
log.debug(`Generated ${actions.length} actions for: ${outline.title}`);
// Build complete Scene object
return buildCompleteScene(outline, content, actions, stageId);
}
/**
* Build complete Scene object (without API/store)
*/
export function buildCompleteScene(
outline: SceneOutline,
content:
| GeneratedSlideContent
| GeneratedQuizContent
| GeneratedInteractiveContent
| GeneratedPBLContent,
actions: Action[],
stageId: string,
): Scene | null {
const sceneId = nanoid();
if (outline.type === 'slide' && 'elements' in content) {
// Build Slide object
const defaultTheme: SlideTheme = {
backgroundColor: '#ffffff',
themeColors: ['#5b9bd5', '#ed7d31', '#a5a5a5', '#ffc000', '#4472c4'],
fontColor: '#333333',
fontName: 'Microsoft YaHei',
outline: { color: '#d14424', width: 2, style: 'solid' },
shadow: { h: 0, v: 0, blur: 10, color: '#000000' },
};
const slide: Slide = {
id: nanoid(),
viewportSize: 1000,
viewportRatio: 0.5625,
theme: defaultTheme,
elements: content.elements,
background: content.background,
};
return {
id: sceneId,
stageId,
type: 'slide',
title: outline.title,
order: outline.order,
content: {
type: 'slide',
canvas: slide,
},
actions,
createdAt: Date.now(),
updatedAt: Date.now(),
};
}
if (outline.type === 'quiz' && 'questions' in content) {
return {
id: sceneId,
stageId,
type: 'quiz',
title: outline.title,
order: outline.order,
content: {
type: 'quiz',
questions: content.questions,
},
actions,
createdAt: Date.now(),
updatedAt: Date.now(),
};
}
if (outline.type === 'interactive' && 'html' in content) {
return {
id: sceneId,
stageId,
type: 'interactive',
title: outline.title,
order: outline.order,
content: {
type: 'interactive',
url: '',
html: content.html,
// Ultra Mode widget fields
widgetType: content.widgetType,
widgetConfig: content.widgetConfig,
teacherActions: content.teacherActions,
},
actions,
createdAt: Date.now(),
updatedAt: Date.now(),
};
}
if (outline.type === 'pbl' && 'projectConfig' in content) {
return {
id: sceneId,
stageId,
type: 'pbl',
title: outline.title,
order: outline.order,
content: {
type: 'pbl',
projectConfig: content.projectConfig,
},
actions,
createdAt: Date.now(),
updatedAt: Date.now(),
};
}
return null;
}