Files
openmaic/OpenMAIC/lib/import/import-classroom-core.ts
2026-08-16 14:58:47 +08:00

379 lines
13 KiB
TypeScript

// 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<string, string>;
posterRefToNewId: Record<string, string>;
posterByMediaRef: Record<string, string>;
}
export interface BuildImportedDocumentOptions {
stageId: string;
now: number;
newAgentIds: string[];
audioRefToNewId: Record<string, string>;
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<string>();
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<T extends Pick<Slide, 'background' | 'elements'>>(
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<Record<string, string>> {
const mappings: Record<string, string> = {};
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<ImportedMediaMappings> {
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,
}),
),
};
}