diff --git a/app/page.tsx b/app/page.tsx
index 091a7328d..dc01d95dd 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -18,6 +18,7 @@ import {
Monitor,
BotOff,
ChevronUp,
+ Upload,
} from 'lucide-react';
import { useI18n } from '@/lib/hooks/use-i18n';
import { LanguageSwitcher } from '@/components/language-switcher';
@@ -48,6 +49,7 @@ import { toast } from 'sonner';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useDraftCache } from '@/lib/hooks/use-draft-cache';
import { SpeechButton } from '@/components/audio/speech-button';
+import { useImportClassroom } from '@/lib/import/use-import-classroom';
const log = createLogger('Home');
@@ -149,6 +151,12 @@ function HomePage() {
}
};
+ const { importing, fileInputRef, triggerFileSelect, handleFileChange } = useImportClassroom(
+ () => {
+ loadClassrooms();
+ },
+ );
+
useEffect(() => {
// Clear stale media store to prevent cross-course thumbnail contamination.
// The store may hold tasks from a previously visited classroom whose elementIds
@@ -319,6 +327,13 @@ function HomePage() {
return (
+
{/* ═══ Top-right pill (unchanged) ═══ */}
)}
+
+ {/* ── Import button (empty state) ── */}
+ {classrooms.length === 0 && (
+
+ )}
{/* ═══ Recent classrooms — collapsible ═══ */}
@@ -554,32 +581,44 @@ function HomePage() {
className="relative z-10 mt-10 w-full max-w-6xl flex flex-col items-center"
>
{/* Trigger — divider-line with centered text */}
-
+
{/* Expandable content */}
diff --git a/components/header.tsx b/components/header.tsx
index 84c0c3e99..00ae7f605 100644
--- a/components/header.tsx
+++ b/components/header.tsx
@@ -10,6 +10,7 @@ import {
Download,
FileDown,
Package,
+ Archive,
} from 'lucide-react';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useTheme } from '@/lib/hooks/use-theme';
@@ -21,6 +22,7 @@ import { cn } from '@/lib/utils';
import { useStageStore } from '@/lib/store/stage';
import { useMediaGenerationStore } from '@/lib/store/media-generation';
import { useExportPPTX } from '@/lib/export/use-export-pptx';
+import { useExportClassroom } from '@/lib/export/use-export-classroom';
interface HeaderProps {
readonly currentSceneTitle: string;
@@ -35,6 +37,7 @@ export function Header({ currentSceneTitle }: HeaderProps) {
// Export
const { exporting: isExporting, exportPPTX, exportResourcePack } = useExportPPTX();
+ const { exporting: isExportingZip, exportClassroomZip } = useExportClassroom();
const [exportMenuOpen, setExportMenuOpen] = useState(false);
const exportRef = useRef(null);
const scenes = useStageStore((s) => s.scenes);
@@ -177,24 +180,24 @@ export function Header({ currentSceneTitle }: HeaderProps) {
{
- if (canExport && !isExporting) setExportMenuOpen(!exportMenuOpen);
+ if (canExport && !isExporting && !isExportingZip) setExportMenuOpen(!exportMenuOpen);
}}
- disabled={!canExport || isExporting}
+ disabled={!canExport || isExporting || isExportingZip}
title={
canExport
- ? isExporting
+ ? isExporting || isExportingZip
? t('export.exporting')
: t('export.pptx')
: t('share.notReady')
}
className={cn(
'shrink-0 p-2 rounded-full transition-all',
- canExport && !isExporting
+ canExport && !isExporting && !isExportingZip
? 'text-gray-400 dark:text-gray-500 hover:bg-white dark:hover:bg-gray-700 hover:text-gray-800 dark:hover:text-gray-200 hover:shadow-sm'
: 'text-gray-300 dark:text-gray-600 cursor-not-allowed opacity-50',
)}
>
- {isExporting ? (
+ {isExporting || isExportingZip ? (
) : (
@@ -227,6 +230,22 @@ export function Header({ currentSceneTitle }: HeaderProps) {
+ {
+ setExportMenuOpen(false);
+ exportClassroomZip();
+ }}
+ disabled={isExportingZip}
+ className="w-full px-4 py-2.5 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-2.5"
+ >
+
+
+
{t('export.classroomZip')}
+
+ {t('export.classroomZipDesc')}
+
+
+
)}
diff --git a/lib/export/classroom-zip-types.ts b/lib/export/classroom-zip-types.ts
new file mode 100644
index 000000000..60316f807
--- /dev/null
+++ b/lib/export/classroom-zip-types.ts
@@ -0,0 +1,66 @@
+// lib/export/classroom-zip-types.ts
+import type { SceneType, SceneContent } from '@/lib/types/stage';
+import type { Action } from '@/lib/types/action';
+import type { Slide } from '@/lib/types/slides';
+
+export const CLASSROOM_ZIP_FORMAT_VERSION = 1;
+export const CLASSROOM_ZIP_EXTENSION = '.maic.zip';
+
+export interface ClassroomManifest {
+ formatVersion: number;
+ exportedAt: string;
+ appVersion: string;
+ stage: ManifestStage;
+ agents: ManifestAgent[];
+ scenes: ManifestScene[];
+ mediaIndex: Record;
+}
+
+export interface ManifestStage {
+ name: string;
+ description?: string;
+ language?: string;
+ style?: string;
+ createdAt: number;
+ updatedAt: number;
+}
+
+export interface ManifestAgent {
+ name: string;
+ role: string;
+ persona: string;
+ avatar: string;
+ color: string;
+ priority: number;
+ /** Reserved for forward-compat. Not currently persisted in GeneratedAgentRecord DB schema. */
+ voiceConfig?: { providerId: string; voiceId: string };
+}
+
+export interface ManifestScene {
+ type: SceneType;
+ title: string;
+ order: number;
+ content: SceneContent;
+ actions?: ManifestAction[];
+ whiteboards?: Slide[];
+ multiAgent?: {
+ enabled: boolean;
+ agentIndices: number[];
+ directorPrompt?: string;
+ };
+}
+
+export type ManifestAction = Omit & {
+ audioRef?: string;
+};
+
+export interface MediaIndexEntry {
+ type: 'audio' | 'image' | 'generated';
+ mimeType?: string;
+ format?: string;
+ duration?: number;
+ voice?: string;
+ size?: number;
+ prompt?: string;
+ missing?: boolean;
+}
diff --git a/lib/export/classroom-zip-utils.ts b/lib/export/classroom-zip-utils.ts
new file mode 100644
index 000000000..f1069bac9
--- /dev/null
+++ b/lib/export/classroom-zip-utils.ts
@@ -0,0 +1,89 @@
+import type { Action, SpeechAction } from '@/lib/types/action';
+import type { ManifestAction } from './classroom-zip-types';
+import { db } from '@/lib/utils/database';
+import type { AudioFileRecord, MediaFileRecord } from '@/lib/utils/database';
+import type { Scene } from '@/lib/types/stage';
+
+// ─── Export: Collect Media ─────────────────────────────────────
+
+export interface CollectedAudio {
+ zipPath: string;
+ record: AudioFileRecord;
+}
+
+export interface CollectedMedia {
+ zipPath: string;
+ record: MediaFileRecord;
+ elementId: string;
+}
+
+export async function collectAudioFiles(scenes: Scene[]): Promise {
+ const audioIds = new Set();
+ for (const scene of scenes) {
+ for (const action of scene.actions ?? []) {
+ if (action.type === 'speech' && (action as SpeechAction).audioId) {
+ audioIds.add((action as SpeechAction).audioId!);
+ }
+ }
+ }
+ const collected: CollectedAudio[] = [];
+ for (const audioId of audioIds) {
+ const record = await db.audioFiles.get(audioId);
+ if (record) {
+ const ext = record.format || 'mp3';
+ collected.push({ zipPath: `audio/${audioId}.${ext}`, record });
+ }
+ }
+ return collected;
+}
+
+export async function collectMediaFiles(stageId: string): Promise {
+ const records = await db.mediaFiles.where('stageId').equals(stageId).toArray();
+ const collected: CollectedMedia[] = [];
+ for (const record of records) {
+ const elementId = record.id.includes(':') ? record.id.split(':').slice(1).join(':') : record.id;
+ const ext = record.mimeType?.split('/')[1] || 'jpg';
+ collected.push({ zipPath: `media/${elementId}.${ext}`, record, elementId });
+ }
+ return collected;
+}
+
+// ─── Export: Action Serialization ──────────────────────────────
+
+export function actionsToManifest(
+ actions: Action[],
+ audioIdToPath: Map,
+): ManifestAction[] {
+ return actions.map((action) => {
+ if (action.type === 'speech') {
+ const speech = action as SpeechAction;
+ const { audioId, ...rest } = speech;
+ const audioRef = audioId ? audioIdToPath.get(audioId) : undefined;
+ return {
+ ...rest,
+ ...(audioRef ? { audioRef } : {}),
+ ...(speech.audioUrl ? { audioUrl: speech.audioUrl } : {}),
+ } as ManifestAction;
+ }
+ return action as ManifestAction;
+ });
+}
+
+// ─── Import: Reference Rewriting ───────────────────────────────
+
+export function rewriteAudioRefsToIds(
+ actions: ManifestAction[],
+ audioRefMap: Record,
+): Action[] {
+ return actions.map((action) => {
+ if (action.type === 'speech' && 'audioRef' in action) {
+ const { audioRef, ...rest } = action;
+ const audioId = audioRef ? audioRefMap[audioRef] : undefined;
+ return {
+ ...rest,
+ ...(audioId ? { audioId } : {}),
+ } as Action;
+ }
+ return action as Action;
+ });
+}
diff --git a/lib/export/use-export-classroom.ts b/lib/export/use-export-classroom.ts
new file mode 100644
index 000000000..b65392ad0
--- /dev/null
+++ b/lib/export/use-export-classroom.ts
@@ -0,0 +1,186 @@
+'use client';
+
+import { useState, useCallback } from 'react';
+import { saveAs } from 'file-saver';
+import { toast } from 'sonner';
+import { useStageStore } from '@/lib/store/stage';
+import { useI18n } from '@/lib/hooks/use-i18n';
+import { getGeneratedAgentsByStageId } from '@/lib/utils/database';
+import {
+ CLASSROOM_ZIP_FORMAT_VERSION,
+ CLASSROOM_ZIP_EXTENSION,
+ type ClassroomManifest,
+ type ManifestStage,
+ type ManifestAgent,
+ type ManifestScene,
+ type MediaIndexEntry,
+} from './classroom-zip-types';
+import { collectAudioFiles, collectMediaFiles, actionsToManifest } from './classroom-zip-utils';
+import type { SpeechAction } from '@/lib/types/action';
+import { createLogger } from '@/lib/logger';
+
+const log = createLogger('ExportClassroom');
+
+export function useExportClassroom() {
+ const [exporting, setExporting] = useState(false);
+ const { t } = useI18n();
+
+ const exportClassroomZip = useCallback(async () => {
+ const { stage, scenes } = useStageStore.getState();
+ if (!stage?.id || scenes.length === 0) return;
+
+ setExporting(true);
+ const toastId = toast.loading(t('export.exporting'));
+
+ try {
+ const JSZip = (await import('jszip')).default;
+ const zip = new JSZip();
+
+ // 1. Collect agents from DB
+ const agentRecords = await getGeneratedAgentsByStageId(stage.id);
+
+ // 2. Collect audio files
+ const audioFiles = await collectAudioFiles(scenes);
+
+ // 3. Collect media files (generated images/videos)
+ const mediaFiles = await collectMediaFiles(stage.id);
+
+ // 4. Build audioId → zipPath mapping for manifest
+ const audioIdToPath = new Map();
+ for (const af of audioFiles) {
+ audioIdToPath.set(af.record.id, af.zipPath);
+ }
+
+ // 5. Build manifest
+ const manifestStage: ManifestStage = {
+ name: stage.name,
+ description: stage.description,
+ language: stage.languageDirective,
+ style: stage.style,
+ createdAt: stage.createdAt,
+ updatedAt: stage.updatedAt,
+ };
+
+ const manifestAgents: ManifestAgent[] = agentRecords.map((a) => ({
+ name: a.name,
+ role: a.role,
+ persona: a.persona,
+ avatar: a.avatar,
+ color: a.color,
+ priority: a.priority,
+ }));
+
+ // Also include generatedAgentConfigs from stage if agents not in DB
+ if (manifestAgents.length === 0 && stage.generatedAgentConfigs?.length) {
+ for (const a of stage.generatedAgentConfigs) {
+ manifestAgents.push({
+ name: a.name,
+ role: a.role,
+ persona: a.persona,
+ avatar: a.avatar,
+ color: a.color,
+ priority: a.priority,
+ });
+ }
+ }
+
+ // Build agent ID → index mapping for multiAgent references
+ const agentIdToIndex = new Map();
+ agentRecords.forEach((a, i) => agentIdToIndex.set(a.id, i));
+ if (stage.generatedAgentConfigs?.length && agentRecords.length === 0) {
+ stage.generatedAgentConfigs.forEach((a, i) => agentIdToIndex.set(a.id, i));
+ }
+
+ const manifestScenes: ManifestScene[] = scenes.map((scene) => ({
+ type: scene.type,
+ title: scene.title,
+ order: scene.order,
+ content: scene.content,
+ actions: scene.actions ? actionsToManifest(scene.actions, audioIdToPath) : undefined,
+ whiteboards: scene.whiteboards,
+ ...(scene.multiAgent?.enabled
+ ? {
+ multiAgent: {
+ enabled: true,
+ agentIndices: (scene.multiAgent.agentIds ?? [])
+ .map((id) => agentIdToIndex.get(id))
+ .filter((i): i is number => i !== undefined),
+ directorPrompt: scene.multiAgent.directorPrompt,
+ },
+ }
+ : {}),
+ }));
+
+ // 6. Build mediaIndex
+ const mediaIndex: Record = {};
+
+ for (const af of audioFiles) {
+ mediaIndex[af.zipPath] = {
+ type: 'audio',
+ format: af.record.format,
+ duration: af.record.duration,
+ voice: af.record.voice,
+ };
+ }
+ for (const mf of mediaFiles) {
+ mediaIndex[mf.zipPath] = {
+ type: 'generated',
+ mimeType: mf.record.mimeType,
+ size: mf.record.size,
+ prompt: mf.record.prompt,
+ };
+ }
+
+ // Check for missing audio references
+ for (const scene of scenes) {
+ for (const action of scene.actions ?? []) {
+ if (action.type === 'speech') {
+ const audioId = (action as SpeechAction).audioId;
+ if (audioId && !audioIdToPath.has(audioId)) {
+ const missingPath = `audio/${audioId}.mp3`;
+ mediaIndex[missingPath] = { type: 'audio', missing: true };
+ }
+ }
+ }
+ }
+
+ // 7. Assemble manifest
+ const manifest: ClassroomManifest = {
+ formatVersion: CLASSROOM_ZIP_FORMAT_VERSION,
+ exportedAt: new Date().toISOString(),
+ appVersion: process.env.npm_package_version || '0.0.0',
+ stage: manifestStage,
+ agents: manifestAgents,
+ scenes: manifestScenes,
+ mediaIndex,
+ };
+
+ zip.file('manifest.json', JSON.stringify(manifest, null, 2));
+
+ // 8. Add media blobs to ZIP
+ for (const af of audioFiles) {
+ zip.file(af.zipPath, af.record.blob);
+ }
+ for (const mf of mediaFiles) {
+ zip.file(mf.zipPath, mf.record.blob);
+ if (mf.record.poster) {
+ zip.file(mf.zipPath.replace(/\.\w+$/, '.poster.jpg'), mf.record.poster);
+ }
+ }
+
+ // 9. Generate and download
+ const zipBlob = await zip.generateAsync({ type: 'blob' });
+ const safeName = stage.name.replace(/[\\/:*?"<>|]/g, '_') || 'classroom';
+ saveAs(zipBlob, `${safeName}${CLASSROOM_ZIP_EXTENSION}`);
+
+ toast.success(t('export.exportSuccess'), { id: toastId });
+ } catch (error) {
+ log.error('Classroom ZIP export failed:', error);
+ toast.error(t('export.exportFailed'), { id: toastId });
+ } finally {
+ setExporting(false);
+ }
+ }, [t]);
+
+ return { exporting, exportClassroomZip };
+}
diff --git a/lib/i18n/locales/en-US.json b/lib/i18n/locales/en-US.json
index 979b4dab2..d6aa9d2f3 100644
--- a/lib/i18n/locales/en-US.json
+++ b/lib/i18n/locales/en-US.json
@@ -34,7 +34,23 @@
"resourcePackDesc": "PPTX + interactive pages",
"exporting": "Exporting...",
"exportSuccess": "Export successful",
- "exportFailed": "Export failed"
+ "exportFailed": "Export failed",
+ "classroomZip": "Export Classroom ZIP",
+ "classroomZipDesc": "Course structure + media files"
+ },
+ "import": {
+ "classroom": "Import Classroom",
+ "parsing": "Parsing ZIP...",
+ "validating": "Validating data...",
+ "writingMedia": "Writing media files...",
+ "writingCourse": "Writing course data...",
+ "success": "Classroom imported successfully",
+ "error": {
+ "invalidZip": "Invalid file. Please select a valid .maic.zip file.",
+ "invalidManifest": "Invalid classroom file: manifest.json is missing or corrupted.",
+ "missingData": "Invalid classroom file: missing required course data.",
+ "storageFull": "Import failed: browser storage is full. Try clearing old classrooms."
+ }
},
"chat": {
"lecture": "Lecture",
diff --git a/lib/i18n/locales/ja-JP.json b/lib/i18n/locales/ja-JP.json
index 21aea354a..d6c37b9a6 100644
--- a/lib/i18n/locales/ja-JP.json
+++ b/lib/i18n/locales/ja-JP.json
@@ -34,7 +34,23 @@
"resourcePackDesc": "PPTX+インタラクティブページ",
"exporting": "エクスポート中...",
"exportSuccess": "エクスポートが完了しました",
- "exportFailed": "エクスポートに失敗しました"
+ "exportFailed": "エクスポートに失敗しました",
+ "classroomZip": "教室ZIPをエクスポート",
+ "classroomZipDesc": "コース構造 + メディアファイル"
+ },
+ "import": {
+ "classroom": "教室をインポート",
+ "parsing": "ZIP を解析中...",
+ "validating": "データを検証中...",
+ "writingMedia": "メディアファイルを書き込み中...",
+ "writingCourse": "コースデータを書き込み中...",
+ "success": "教室のインポートが完了しました",
+ "error": {
+ "invalidZip": "無効なファイルです。有効な .maic.zip ファイルを選択してください。",
+ "invalidManifest": "無効な教室ファイル:manifest.json が見つからないか破損しています。",
+ "missingData": "無効な教室ファイル:必要なコースデータが不足しています。",
+ "storageFull": "インポート失敗:ブラウザのストレージが一杯です。古い教室を削除してください。"
+ }
},
"chat": {
"lecture": "講義",
diff --git a/lib/i18n/locales/ru-RU.json b/lib/i18n/locales/ru-RU.json
index 296a09d9d..2c1becdaf 100644
--- a/lib/i18n/locales/ru-RU.json
+++ b/lib/i18n/locales/ru-RU.json
@@ -34,7 +34,23 @@
"resourcePackDesc": "PPTX + интерактивные страницы",
"exporting": "Экспорт...",
"exportSuccess": "Экспорт успешен",
- "exportFailed": "Ошибка экспорта"
+ "exportFailed": "Ошибка экспорта",
+ "classroomZip": "Экспорт ZIP класса",
+ "classroomZipDesc": "Структура курса + медиафайлы"
+ },
+ "import": {
+ "classroom": "Импорт класса",
+ "parsing": "Анализ ZIP...",
+ "validating": "Проверка данных...",
+ "writingMedia": "Запись медиафайлов...",
+ "writingCourse": "Запись данных курса...",
+ "success": "Класс успешно импортирован",
+ "error": {
+ "invalidZip": "Недопустимый файл. Выберите корректный файл .maic.zip.",
+ "invalidManifest": "Недопустимый файл класса: manifest.json отсутствует или повреждён.",
+ "missingData": "Недопустимый файл класса: отсутствуют необходимые данные курса.",
+ "storageFull": "Импорт не удался: хранилище браузера заполнено. Удалите старые классы."
+ }
},
"chat": {
"lecture": "Лекция",
diff --git a/lib/i18n/locales/zh-CN.json b/lib/i18n/locales/zh-CN.json
index 1a3b07b0f..f74a25030 100644
--- a/lib/i18n/locales/zh-CN.json
+++ b/lib/i18n/locales/zh-CN.json
@@ -34,7 +34,23 @@
"resourcePackDesc": "PPTX + 交互式页面",
"exporting": "正在导出...",
"exportSuccess": "导出成功",
- "exportFailed": "导出失败"
+ "exportFailed": "导出失败",
+ "classroomZip": "导出课堂 ZIP",
+ "classroomZipDesc": "课程结构 + 媒体文件"
+ },
+ "import": {
+ "classroom": "导入课堂",
+ "parsing": "正在解析 ZIP...",
+ "validating": "正在验证数据...",
+ "writingMedia": "正在写入媒体文件...",
+ "writingCourse": "正在写入课程数据...",
+ "success": "课堂导入成功",
+ "error": {
+ "invalidZip": "无效文件,请选择有效的 .maic.zip 文件。",
+ "invalidManifest": "无效课堂文件:manifest.json 缺失或已损坏。",
+ "missingData": "无效课堂文件:缺少必需的课程数据。",
+ "storageFull": "导入失败:浏览器存储空间已满,请清理旧课堂后重试。"
+ }
},
"chat": {
"lecture": "授课",
diff --git a/lib/import/use-import-classroom.ts b/lib/import/use-import-classroom.ts
new file mode 100644
index 000000000..d087381a9
--- /dev/null
+++ b/lib/import/use-import-classroom.ts
@@ -0,0 +1,247 @@
+'use client';
+
+import { useState, useCallback, useRef } from 'react';
+import { nanoid } from 'nanoid';
+import { toast } from 'sonner';
+import { useI18n } from '@/lib/hooks/use-i18n';
+import { db, mediaFileKey } from '@/lib/utils/database';
+import type { AudioFileRecord, MediaFileRecord, GeneratedAgentRecord } from '@/lib/utils/database';
+import type { ClassroomManifest, ManifestScene } from '@/lib/export/classroom-zip-types';
+import { rewriteAudioRefsToIds } from '@/lib/export/classroom-zip-utils';
+import { createLogger } from '@/lib/logger';
+
+const log = createLogger('ImportClassroom');
+
+export type ImportPhase =
+ | 'idle'
+ | 'parsing'
+ | 'validating'
+ | 'writingMedia'
+ | 'writingCourse'
+ | 'done';
+
+export function useImportClassroom(onSuccess?: () => void) {
+ const [importing, setImporting] = useState(false);
+ const [phase, setPhase] = useState('idle');
+ const fileInputRef = useRef(null);
+ const { t } = useI18n();
+
+ const triggerFileSelect = useCallback(() => {
+ fileInputRef.current?.click();
+ }, []);
+
+ const handleFileChange = useCallback(
+ async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ // Reset input so same file can be re-selected
+ e.target.value = '';
+
+ setImporting(true);
+ setPhase('parsing');
+ const toastId = toast.loading(t('import.parsing'));
+
+ try {
+ // 0. Size check — warn for files over 200MB
+ const MAX_SAFE_SIZE = 200 * 1024 * 1024;
+ if (file.size > MAX_SAFE_SIZE) {
+ log.warn(`Large ZIP file: ${(file.size / 1024 / 1024).toFixed(0)}MB`);
+ }
+
+ // 1. Parse ZIP
+ const JSZip = (await import('jszip')).default;
+ const zip = await JSZip.loadAsync(file);
+
+ const manifestFile = zip.file('manifest.json');
+ if (!manifestFile) {
+ toast.error(t('import.error.invalidManifest'), { id: toastId });
+ return;
+ }
+
+ // 2. Validate
+ setPhase('validating');
+ toast.loading(t('import.validating'), { id: toastId });
+
+ const manifestText = await manifestFile.async('text');
+ let manifest: ClassroomManifest;
+ try {
+ manifest = JSON.parse(manifestText);
+ } catch {
+ toast.error(t('import.error.invalidManifest'), { id: toastId });
+ return;
+ }
+
+ if (!manifest.stage || !manifest.scenes || !Array.isArray(manifest.scenes)) {
+ toast.error(t('import.error.missingData'), { id: toastId });
+ return;
+ }
+
+ // 3. Generate new IDs
+ const newStageId = nanoid();
+ const now = Date.now();
+
+ // Agent ID mapping: index → new ID
+ const newAgentIds: string[] = (manifest.agents ?? []).map(() => nanoid());
+
+ // Audio ref → new ID mapping
+ const audioRefToNewId: Record = {};
+ for (const [zipPath, entry] of Object.entries(manifest.mediaIndex ?? {})) {
+ if (entry.type === 'audio' && !entry.missing) {
+ audioRefToNewId[zipPath] = nanoid();
+ }
+ }
+
+ // Media ref → new ID mapping
+ const mediaRefToNewId: Record = {};
+ for (const [zipPath, entry] of Object.entries(manifest.mediaIndex ?? {})) {
+ if ((entry.type === 'generated' || entry.type === 'image') && !entry.missing) {
+ const filename = zipPath.split('/').pop() ?? '';
+ const elementId = filename.replace(/\.\w+$/, '');
+ mediaRefToNewId[zipPath] = mediaFileKey(newStageId, elementId);
+ }
+ }
+
+ // 4. Write media to IndexedDB
+ setPhase('writingMedia');
+ toast.loading(t('import.writingMedia'), { id: toastId });
+
+ // Write audio files one at a time
+ for (const [zipPath, newId] of Object.entries(audioRefToNewId)) {
+ const zipEntry = zip.file(zipPath);
+ if (!zipEntry) continue;
+ const blob = await zipEntry.async('blob');
+ const meta = manifest.mediaIndex[zipPath];
+ const record: AudioFileRecord = {
+ id: newId,
+ blob,
+ format: meta.format || 'mp3',
+ duration: meta.duration,
+ voice: meta.voice,
+ createdAt: now,
+ };
+ await db.audioFiles.put(record);
+ }
+
+ // Write generated media files one at a time
+ for (const [zipPath, newId] of Object.entries(mediaRefToNewId)) {
+ const zipEntry = zip.file(zipPath);
+ if (!zipEntry) continue;
+ const blob = await zipEntry.async('blob');
+ const meta = manifest.mediaIndex[zipPath];
+
+ const record: MediaFileRecord = {
+ id: newId,
+ stageId: newStageId,
+ type: meta.mimeType?.startsWith('video/') ? 'video' : 'image',
+ blob,
+ mimeType: meta.mimeType || 'image/jpeg',
+ size: meta.size || blob.size,
+ prompt: meta.prompt || '',
+ params: '',
+ createdAt: now,
+ };
+
+ // Check for poster before writing to avoid redundant put
+ const posterPath = zipPath.replace(/\.\w+$/, '.poster.jpg');
+ const posterEntry = zip.file(posterPath);
+ if (posterEntry) {
+ record.poster = await posterEntry.async('blob');
+ }
+
+ await db.mediaFiles.put(record);
+ }
+
+ // 5. Write course data
+ setPhase('writingCourse');
+ toast.loading(t('import.writingCourse'), { id: toastId });
+
+ // Write stage
+ await db.stages.put({
+ id: newStageId,
+ 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,
+ });
+
+ // Write agents
+ if (manifest.agents?.length) {
+ const agentRecords: GeneratedAgentRecord[] = manifest.agents.map((a, i) => ({
+ id: newAgentIds[i],
+ stageId: newStageId,
+ name: a.name,
+ role: a.role,
+ persona: a.persona,
+ avatar: a.avatar,
+ color: a.color,
+ priority: a.priority,
+ createdAt: now,
+ }));
+ await db.generatedAgents.bulkPut(agentRecords);
+ }
+
+ // Write scenes with rewritten references
+ const sceneRecords = manifest.scenes.map((mScene: ManifestScene, index: number) => {
+ const newSceneId = nanoid();
+
+ const actions = mScene.actions
+ ? rewriteAudioRefsToIds(mScene.actions, audioRefToNewId)
+ : undefined;
+
+ let multiAgent = undefined;
+ if (mScene.multiAgent?.enabled) {
+ multiAgent = {
+ enabled: true,
+ agentIds: (mScene.multiAgent.agentIndices ?? [])
+ .map((idx) => newAgentIds[idx])
+ .filter(Boolean),
+ directorPrompt: mScene.multiAgent.directorPrompt,
+ };
+ }
+
+ return {
+ id: newSceneId,
+ stageId: newStageId,
+ type: mScene.type,
+ title: mScene.title,
+ order: mScene.order ?? index,
+ content: mScene.content,
+ actions,
+ whiteboard: mScene.whiteboards,
+ multiAgent,
+ createdAt: now,
+ updatedAt: now,
+ };
+ });
+ await db.scenes.bulkPut(sceneRecords);
+
+ // 6. Done
+ setPhase('done');
+ toast.success(t('import.success'), { id: toastId });
+ onSuccess?.();
+ } catch (error) {
+ log.error('Classroom ZIP import failed:', error);
+ const isQuotaError = error instanceof DOMException && error.name === 'QuotaExceededError';
+ toast.error(isQuotaError ? t('import.error.storageFull') : t('import.error.invalidZip'), {
+ id: toastId,
+ });
+ } finally {
+ setImporting(false);
+ setPhase('idle');
+ }
+ },
+ [t, onSuccess],
+ );
+
+ return {
+ importing,
+ phase,
+ fileInputRef,
+ triggerFileSelect,
+ handleFileChange,
+ };
+}
diff --git a/tests/export/classroom-zip.test.ts b/tests/export/classroom-zip.test.ts
new file mode 100644
index 000000000..e9df43ecd
--- /dev/null
+++ b/tests/export/classroom-zip.test.ts
@@ -0,0 +1,146 @@
+import { describe, test, expect } from 'vitest';
+import { rewriteAudioRefsToIds, actionsToManifest } from '@/lib/export/classroom-zip-utils';
+import {
+ CLASSROOM_ZIP_FORMAT_VERSION,
+ type ClassroomManifest,
+} from '@/lib/export/classroom-zip-types';
+import type { SpeechAction, SpotlightAction } from '@/lib/types/action';
+
+// ─── rewriteAudioRefsToIds ────────────────────────────────────
+
+describe('rewriteAudioRefsToIds', () => {
+ test('replaces audioRef with new audioId in speech actions', () => {
+ const actions = [
+ { id: 'a1', type: 'speech' as const, text: 'Hello', audioRef: 'audio/abc.mp3' },
+ { id: 'a2', type: 'spotlight' as const, elementId: 'el1' },
+ ];
+ const audioRefMap = { 'audio/abc.mp3': 'new-audio-id-1' };
+ const result = rewriteAudioRefsToIds(actions, audioRefMap);
+ expect(result[0]).toMatchObject({
+ type: 'speech',
+ text: 'Hello',
+ audioId: 'new-audio-id-1',
+ });
+ expect(result[1]).toMatchObject({ type: 'spotlight', elementId: 'el1' });
+ });
+
+ test('skips speech actions without audioRef', () => {
+ const actions = [
+ { id: 'a1', type: 'speech' as const, text: 'Hello', audioUrl: 'https://example.com/a.mp3' },
+ ];
+ const result = rewriteAudioRefsToIds(actions, {});
+ expect(result[0]).toMatchObject({
+ type: 'speech',
+ text: 'Hello',
+ audioUrl: 'https://example.com/a.mp3',
+ });
+ });
+});
+
+// ─── actionsToManifest ────────────────────────────────────────
+
+describe('actionsToManifest', () => {
+ test('converts audioId to audioRef for speech actions', () => {
+ const actions = [
+ {
+ id: 'act1',
+ type: 'speech' as const,
+ text: 'Hello',
+ audioId: 'audio-123',
+ voice: 'alloy',
+ speed: 1,
+ } as SpeechAction,
+ { id: 'act2', type: 'spotlight' as const, elementId: 'el1' } as SpotlightAction,
+ ];
+ const audioIdToPath = new Map([['audio-123', 'audio/audio-123.mp3']]);
+
+ const result = actionsToManifest(actions, audioIdToPath);
+
+ expect(result[0]).toMatchObject({
+ type: 'speech',
+ text: 'Hello',
+ audioRef: 'audio/audio-123.mp3',
+ voice: 'alloy',
+ });
+ expect(result[0]).not.toHaveProperty('audioId');
+ expect(result[1]).toMatchObject({ type: 'spotlight', elementId: 'el1' });
+ });
+
+ test('preserves audioUrl when audioId is absent', () => {
+ const actions = [
+ {
+ id: 'act1',
+ type: 'speech' as const,
+ text: 'Hi',
+ audioUrl: 'https://cdn.example.com/hi.mp3',
+ } as SpeechAction,
+ ];
+ const result = actionsToManifest(actions, new Map());
+ expect(result[0]).toMatchObject({
+ type: 'speech',
+ text: 'Hi',
+ audioUrl: 'https://cdn.example.com/hi.mp3',
+ });
+ expect(result[0]).not.toHaveProperty('audioRef');
+ });
+});
+
+// ─── Manifest round-trip ──────────────────────────────────────
+
+describe('manifest round-trip', () => {
+ test('manifest structure is valid JSON-serializable', () => {
+ const manifest: ClassroomManifest = {
+ formatVersion: CLASSROOM_ZIP_FORMAT_VERSION,
+ exportedAt: new Date().toISOString(),
+ appVersion: '0.1.0',
+ stage: {
+ name: 'Test Course',
+ description: 'A test',
+ language: 'en-US',
+ style: 'professional',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ },
+ agents: [
+ {
+ name: 'Prof',
+ role: 'lecturer',
+ persona: 'Friendly professor',
+ avatar: '👨🏫',
+ color: '#4A90D9',
+ priority: 1,
+ },
+ ],
+ scenes: [
+ {
+ type: 'slide',
+ title: 'Intro',
+ order: 0,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ content: { type: 'slide', canvas: { id: 's1', elements: [] } } as any,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ actions: [{ id: 'a1', type: 'speech', text: 'Welcome', audioRef: 'audio/a1.mp3' } as any],
+ },
+ ],
+ mediaIndex: {
+ 'audio/a1.mp3': { type: 'audio', format: 'mp3', duration: 5.2 },
+ },
+ };
+
+ const serialized = JSON.stringify(manifest);
+ const deserialized = JSON.parse(serialized) as ClassroomManifest;
+
+ expect(deserialized.formatVersion).toBe(CLASSROOM_ZIP_FORMAT_VERSION);
+ expect(deserialized.stage.name).toBe('Test Course');
+ expect(deserialized.agents).toHaveLength(1);
+ expect(deserialized.scenes).toHaveLength(1);
+ expect(deserialized.scenes[0].actions?.[0]).toMatchObject({
+ type: 'speech',
+ audioRef: 'audio/a1.mp3',
+ });
+ expect(deserialized.mediaIndex['audio/a1.mp3']).toMatchObject({
+ type: 'audio',
+ duration: 5.2,
+ });
+ });
+});