// Portable classroom serialization for the frozen bundle. // // Mirrors the interactive-ZIP export flow (`lib/export/use-export-classroom.ts` // + `classroom-zip-utils.ts`) but is pure and server-safe: the browser export // reads bytes from IndexedDB at call time, while the packager receives bytes // through injected resolvers. The manifest shape is identical, so the L3 // player can reuse the existing import machinery // (`rewriteAudioRefsToIds` / manifest → document) unchanged. import type { Action, DiscussionAction, SpeechAction } from '@/lib/types/action'; import type { GeneratedAgentConfig, Scene, Stage } from '@/lib/types/stage'; import type { ClassroomManifest, ManifestAction, ManifestAgent, ManifestScene, ManifestStage, MediaIndexEntry, } from '@/lib/export/classroom-zip-types'; import { manifestAgentFromConfig, CLASSROOM_ZIP_FORMAT_VERSION, } from '@/lib/export/classroom-zip-types'; export interface ManifestBuildOptions { appVersion: string; /** audioId → ZIP path (`audio/.`); speech actions are rewritten to `audioRef`. */ audioIdToPath: Map; agentConfigs: GeneratedAgentConfig[]; exportedAt?: string; } export function serializeActions( actions: Action[], audioIdToPath: Map, agentIdToIndex: Map, ): ManifestAction[] { return actions.map((action) => { if (action.type === 'speech') { const speech = action as SpeechAction; const { audioId, audioUrl, ...rest } = speech; const audioRef = audioId ? audioIdToPath.get(audioId) : undefined; return { ...rest, ...(audioRef ? { audioRef } : {}), // A frozen bundle must play its embedded bytes. Keep a remote URL only // for legacy URL-only speech that could not be mapped to bundle audio. ...(!audioRef && audioUrl ? { audioUrl } : {}), } as ManifestAction; } if (action.type === 'discussion') { const discussion = action as DiscussionAction; const { agentId, ...rest } = discussion; const agentIndex = agentId ? agentIdToIndex.get(agentId) : undefined; return { ...rest, ...(agentIndex !== undefined ? { agentIndex } : agentId ? { agentId } : {}), } as ManifestAction; } return action as ManifestAction; }); } export interface SerializedScene { scene: ManifestScene; /** MediaIndexEntry additions for this scene's audio/media, keyed by ZIP path. */ } /** Serialize one scene into its portable manifest shape (interactive HTML assumed pre-inlined). */ export function serializeScene( scene: Scene, audioIdToPath: Map, agentIdToIndex: Map, ): ManifestScene { return { type: scene.type, title: scene.title, order: scene.order, content: scene.content, actions: scene.actions ? serializeActions(scene.actions, audioIdToPath, agentIdToIndex) : undefined, whiteboards: scene.whiteboards, ...(scene.multiAgent?.enabled ? { multiAgent: { enabled: true, agentIndices: (scene.multiAgent.agentIds ?? []) .map((id) => agentIdToIndex.get(id)) .filter((index): index is number => index !== undefined), directorPrompt: scene.multiAgent.directorPrompt, }, } : {}), }; } export function serializeStage(stage: Stage): ManifestStage { return { name: stage.name, description: stage.description, language: stage.languageDirective, style: stage.style, whiteboard: stage.whiteboard, videoManifest: stage.videoManifest, interactiveMode: stage.interactiveMode, taskEngineMode: stage.taskEngineMode, createdAt: stage.createdAt, updatedAt: stage.updatedAt, }; } /** * Build the portable classroom manifest. * * `scenes` must already carry inlined interactive HTML and prepared PBL * scenes (the packager's responsibility); `mediaIndex` is supplied by the * caller, which owns byte collection. */ export function buildManifest( stage: Stage, scenes: Scene[], options: ManifestBuildOptions, mediaIndex: Record, ): ClassroomManifest { const agentIdToIndex = new Map(); options.agentConfigs.forEach((agent, index) => agentIdToIndex.set(agent.id, index)); return { formatVersion: CLASSROOM_ZIP_FORMAT_VERSION, exportedAt: options.exportedAt ?? new Date().toISOString(), appVersion: options.appVersion, stage: serializeStage(stage), agents: options.agentConfigs.map(manifestAgentFromConfig) satisfies ManifestAgent[], scenes: scenes.map((scene) => serializeScene(scene, options.audioIdToPath, agentIdToIndex)), mediaIndex, }; }