// Shared manifest → document conversion for both the ops ZIP import and the // learner player. A frozen bundle's `manifest.json` is a `ClassroomManifest` // (identical contract to the interactive-ZIP export), so both entry points // build the playable `AppDocument` through the same mapping: // // audio: speech actions rewrite audioRef (zip path) → allocated pool id // media: slide/video refs rewrite old ref → allocated pool id (+ posters) // agents: positional manifest roster → stage-embedded GeneratedAgentConfigs // scenes: canonicalized scenes with rewritten actions/whiteboards // // The learner player additionally relies on one structural guarantee: the // built document carries NO outlines, so the classroom page's generation // resume paths (scene generation / media generation) never trigger — the // courseware plays fully statically. import type { ClassroomManifest, ManifestScene } from '@/lib/export/classroom-zip-types'; import { agentConfigFromManifest } from '@/lib/export/classroom-zip-types'; import { rewriteAudioRefsToIds } from '@/lib/export/classroom-zip-utils'; import { canonicalizeLegacyScene, type AppDocument } from '@/lib/document-store'; import { isConcreteMediaAddress } from '@/lib/media/resolve-media-ref'; import { isGeneratedMediaPlaceholder } from '@/lib/media/media-ref'; import { db, mediaFileKey } from '@/lib/utils/database'; import type { AudioFileRecord } from '@/lib/utils/database'; import { putAsset } from '@/lib/media/asset-pool'; import type { GeneratedAgentConfig } from '@/lib/types/stage'; import type { Slide } from '@openmaic/dsl'; import type JSZip from 'jszip'; export interface ImportedMediaMappings { refToNewId: Record; posterRefToNewId: Record; posterByMediaRef: Record; } export interface BuildImportedDocumentOptions { stageId: string; now: number; newAgentIds: string[]; audioRefToNewId: Record; mediaMappings: ImportedMediaMappings; fallbackDiscussionAgentIndex?: number; } function rewriteMediaRef(value: string, mapped: string | undefined): string | undefined { if (mapped) return mapped; if (isConcreteMediaAddress(value) || isGeneratedMediaPlaceholder(value)) return value; return undefined; } /** Map a ZIP media path (`media/img-1.png`) back to its manifest media ref. */ export function mediaRefFromZipPath(zipPath: string, mimeType?: string): string { const relative = zipPath.startsWith('media/') ? zipPath.slice('media/'.length) : zipPath; const suffix = mimeType?.split('/')[1]; if (suffix && relative.endsWith(`.${suffix}`)) return relative.slice(0, -suffix.length - 1); const slash = relative.lastIndexOf('/'); const dot = relative.lastIndexOf('.'); return dot > slash ? relative.slice(0, dot) : relative; } function siblingPosterZipPath(zipPath: string, mimeType?: string): string { const suffix = mimeType?.split('/')[1]; if (suffix && zipPath.endsWith(`.${suffix}`)) { return `${zipPath.slice(0, -suffix.length)}.poster.jpg`; } return zipPath.replace(/\.[^/]+$/, '.poster.jpg'); } function sceneSlides(scene: ManifestScene): Slide[] { const slides: Slide[] = []; if (scene.content.type === 'slide') slides.push(scene.content.canvas); slides.push(...(scene.whiteboards ?? [])); return slides; } function posterRefsForMedia(manifest: ClassroomManifest, mediaRef: string): string[] { const refs = new Set(); for (const scene of manifest.scenes) { for (const slide of sceneSlides(scene)) { for (const element of slide.elements) { if ( element.type === 'video' && (element.src === mediaRef || element.mediaRef === mediaRef) && element.poster ) { refs.add(element.poster); } } } } return [...refs]; } export function rewriteImportedSlideMediaRefs>( slide: T, mappings: ImportedMediaMappings, ): T { const background = slide.background?.type === 'image' && slide.background.image ? { ...slide.background, image: { ...slide.background.image, src: rewriteMediaRef( slide.background.image.src, mappings.refToNewId[slide.background.image.src], ) ?? '', }, } : slide.background; return { ...slide, background, elements: slide.elements.map((element) => { if (element.type === 'image') { const src = rewriteMediaRef(element.src, mappings.refToNewId[element.src]) ?? ''; return src === element.src ? element : { ...element, src }; } if (element.type !== 'video') return element; const oldMediaRef = element.mediaRef || element.src || ''; const src = element.src ? (rewriteMediaRef(element.src, mappings.refToNewId[element.src]) ?? '') : undefined; const mediaRef = element.mediaRef ? rewriteMediaRef(element.mediaRef, mappings.refToNewId[element.mediaRef]) : undefined; const poster = element.poster ? rewriteMediaRef( element.poster, mappings.posterRefToNewId[element.poster] ?? mappings.refToNewId[element.poster], ) : mappings.posterByMediaRef[oldMediaRef]; const rewritten = { ...element, ...(src !== undefined ? { src } : {}) }; if (mediaRef) rewritten.mediaRef = mediaRef; else delete rewritten.mediaRef; if (poster) rewritten.poster = poster; else delete rewritten.poster; return rewritten; }), } as T; } export function rewriteImportedVideoManifest( manifest: AppDocument['stage']['videoManifest'], mappings: ImportedMediaMappings, ): AppDocument['stage']['videoManifest'] { if (!manifest) return manifest; return Object.fromEntries( Object.entries(manifest).flatMap(([ref, entry]) => { const rewritten = rewriteMediaRef(ref, mappings.refToNewId[ref]); return rewritten ? [[rewritten, entry] as const] : []; }), ); } // ─── Media materialization (browser: asset pool + Dexie mirror) ──────────── /** Allocate imported audio only after its ZIP entry has been confirmed present. */ export async function materializeImportedAudio( zip: JSZip, manifest: ClassroomManifest, stageId: string, createdAt: number, allocatedIds: string[] = [], ): Promise> { const mappings: Record = {}; for (const [zipPath, meta] of Object.entries(manifest.mediaIndex ?? {})) { if (meta.type !== 'audio' || meta.missing) continue; const zipEntry = zip.file(zipPath); if (!zipEntry) continue; const blob = await zipEntry.async('blob'); const assetId = await putAsset(blob, { contentType: blob.type || `audio/${meta.format || 'mp3'}`, mediaType: 'audio', duration: meta.duration, voice: meta.voice, }); allocatedIds.push(assetId); mappings[zipPath] = assetId; const record: AudioFileRecord = { id: assetId, stageId, blob, format: meta.format || 'mp3', duration: meta.duration, voice: meta.voice, createdAt, }; await db.audioFiles.put(record); } return mappings; } /** Allocate imported media into the browser-global pool and mirror it to Dexie. */ export async function materializeImportedMedia( zip: JSZip, manifest: ClassroomManifest, stageId: string, createdAt: number, allocatedIds: string[] = [], ): Promise { const mappings: ImportedMediaMappings = { refToNewId: {}, posterRefToNewId: {}, posterByMediaRef: {}, }; const imported: Array<{ oldRef: string; assetId: string; type: 'image' | 'video'; posterBlob?: Blob; prompt?: string; }> = []; for (const [zipPath, meta] of Object.entries(manifest.mediaIndex ?? {})) { if ((meta.type !== 'generated' && meta.type !== 'image') || meta.missing) continue; const zipEntry = zip.file(zipPath); if (!zipEntry) continue; const blob = await zipEntry.async('blob'); const oldRef = mediaRefFromZipPath(zipPath, meta.mimeType); const mimeType = meta.mimeType || 'image/jpeg'; const type = mimeType.startsWith('video/') ? 'video' : 'image'; const posterEntry = type === 'video' ? zip.file(siblingPosterZipPath(zipPath, meta.mimeType)) : null; const posterBlob = posterEntry ? await posterEntry.async('blob') : undefined; const assetId = await putAsset(blob, { contentType: mimeType, mediaType: type, prompt: meta.prompt, }); allocatedIds.push(assetId); mappings.refToNewId[oldRef] = assetId; await db.mediaFiles.put({ id: mediaFileKey(stageId, assetId), stageId, type, blob, mimeType, size: meta.size || blob.size, poster: posterBlob, prompt: meta.prompt || '', params: '', createdAt, }); imported.push({ oldRef, assetId, type, posterBlob, prompt: meta.prompt }); } // A modern ZIP can contain both the video's legacy sibling poster and the // poster's own mediaIndex entry. Reuse that entry's freshly allocated id; // only older ZIPs need an extra allocation for the sibling poster bytes. for (const entry of imported) { if (entry.type !== 'video' || !entry.posterBlob) continue; const oldPosterRefs = posterRefsForMedia(manifest, entry.oldRef); let posterAssetId = oldPosterRefs .map((oldPosterRef) => mappings.refToNewId[oldPosterRef]) .find(Boolean); if (!posterAssetId) { posterAssetId = await putAsset(entry.posterBlob, { contentType: entry.posterBlob.type || 'image/jpeg', mediaType: 'video-poster', parentRef: entry.assetId, }); allocatedIds.push(posterAssetId); await db.mediaFiles.put({ id: mediaFileKey(stageId, posterAssetId), stageId, type: 'image', blob: entry.posterBlob, mimeType: entry.posterBlob.type || 'image/jpeg', size: entry.posterBlob.size, prompt: entry.prompt || '', params: '', createdAt, }); } mappings.posterByMediaRef[entry.oldRef] = posterAssetId; for (const oldPosterRef of oldPosterRefs) { mappings.posterRefToNewId[oldPosterRef] = posterAssetId; } } return mappings; } // ─── Document build ──────────────────────────────────────────────────────── /** * Build the playable document from a manifest (roster + scenes + rewritten * audio/media references). The caller owns media materialization * (`materializeImportedAudio` / `materializeImportedMedia`) and the document * write (`mutateDocument`). * * The returned document deliberately carries no generation outlines. */ export function buildImportedDocument( manifest: ClassroomManifest, options: BuildImportedDocumentOptions, ): AppDocument { const { stageId, now, newAgentIds, audioRefToNewId, mediaMappings, fallbackDiscussionAgentIndex, } = options; const importedAgentConfigs: GeneratedAgentConfig[] = (manifest.agents ?? []).map((agent, index) => agentConfigFromManifest(agent, newAgentIds[index]), ); return { stage: { id: stageId, name: manifest.stage.name || 'Imported Classroom', description: manifest.stage.description, languageDirective: manifest.stage.language, style: manifest.stage.style, createdAt: manifest.stage.createdAt || now, updatedAt: now, agentIds: newAgentIds.length > 0 ? newAgentIds : undefined, ...(manifest.stage.whiteboard ? { whiteboard: manifest.stage.whiteboard.map((slide) => rewriteImportedSlideMediaRefs(slide, mediaMappings), ), } : {}), ...(manifest.stage.interactiveMode ? { interactiveMode: true } : {}), ...(manifest.stage.taskEngineMode ? { taskEngineMode: true } : {}), ...(manifest.stage.videoManifest ? { videoManifest: rewriteImportedVideoManifest( manifest.stage.videoManifest, mediaMappings, ), } : {}), ...(importedAgentConfigs.length > 0 ? { generatedAgentConfigs: importedAgentConfigs } : {}), }, scenes: manifest.scenes.map((mScene: ManifestScene, index: number) => canonicalizeLegacyScene({ id: `${stageId}_s${index}`, stageId, title: mScene.title, order: mScene.order ?? index, content: mScene.content.type === 'slide' ? { ...mScene.content, canvas: rewriteImportedSlideMediaRefs(mScene.content.canvas, mediaMappings), } : mScene.content, actions: mScene.actions ? rewriteAudioRefsToIds(mScene.actions, audioRefToNewId, { agentIds: newAgentIds, fallbackDiscussionAgentIndex, }) : undefined, whiteboards: mScene.whiteboards?.map((slide) => rewriteImportedSlideMediaRefs(slide, mediaMappings), ), multiAgent: mScene.multiAgent?.enabled ? { enabled: true, agentIds: (mScene.multiAgent.agentIndices ?? []) .map((idx) => newAgentIds[idx]) .filter(Boolean), directorPrompt: mScene.multiAgent.directorPrompt, } : undefined, createdAt: now, updatedAt: now, }), ), }; }