379 lines
13 KiB
TypeScript
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,
|
|
}),
|
|
),
|
|
};
|
|
}
|