Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

243 lines
8.2 KiB
TypeScript

'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 { db, 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';
import {
inlineHtmlAssets,
createAssetFetcher,
type InlineOptions,
type InlineReport,
} from './inline-assets';
import { createProxiedFetch } from './proxied-fetch';
import type { SceneContent } from '@/lib/types/stage';
export async function inlineSceneContent(
content: SceneContent,
options?: InlineOptions,
): Promise<{ content: SceneContent; report: InlineReport }> {
if (content?.type !== 'interactive' || !('html' in content) || !content.html) {
return { content, report: { inlined: [], failed: [] } };
}
const { html, report } = await inlineHtmlAssets(content.html, options);
return { content: { ...content, html }, report };
}
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. Read latest stage name from IndexedDB (may have been renamed on home page)
const freshStage = await db.stages.get(stage.id);
const latestName = freshStage?.name || stage.name;
// 2. Collect agents from DB
const agentRecords = await getGeneratedAgentsByStageId(stage.id);
// 3. Collect audio files
const audioFiles = await collectAudioFiles(scenes);
// 4. Collect media files (generated images/videos)
const mediaFiles = await collectMediaFiles(stage.id);
// 5. Build audioId → zipPath mapping for manifest
const audioIdToPath = new Map<string, string>();
for (const af of audioFiles) {
audioIdToPath.set(af.record.id, af.zipPath);
}
// 6. Build manifest
const manifestStage: ManifestStage = {
name: latestName,
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<string, number>();
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 aggregateReport: InlineReport = { inlined: [], failed: [] };
const sharedFetcher = createAssetFetcher({ fetchImpl: createProxiedFetch() });
const manifestScenes: ManifestScene[] = await Promise.all(
scenes.map(async (scene) => {
const { content, report } = await inlineSceneContent(scene.content, {
fetcher: sharedFetcher,
});
for (const u of report.inlined)
if (!aggregateReport.inlined.includes(u)) aggregateReport.inlined.push(u);
for (const f of report.failed)
if (!aggregateReport.failed.some((g) => g.url === f.url))
aggregateReport.failed.push(f);
return {
type: scene.type,
title: scene.title,
order: scene.order,
content,
actions: scene.actions
? actionsToManifest(scene.actions, audioIdToPath, agentIdToIndex)
: 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,
},
}
: {}),
};
}),
);
// 7. Build mediaIndex
const mediaIndex: Record<string, MediaIndexEntry> = {};
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 };
}
}
}
}
// 8. 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));
// 9. 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);
}
}
// 10. Generate and download
const zipBlob = await zip.generateAsync({ type: 'blob' });
const safeName = latestName.replace(/[\\/:*?"<>|]/g, '_') || 'classroom';
saveAs(zipBlob, `${safeName}${CLASSROOM_ZIP_EXTENSION}`);
if (aggregateReport.failed.length > 0) {
log.warn('Some interactive-scene assets could not be inlined:', aggregateReport.failed);
const hosts = [
...new Set(
aggregateReport.failed.map((f) => {
try {
return new URL(f.url).host;
} catch {
return f.url;
}
}),
),
];
toast.warning(t('export.inlinePartial', { count: aggregateReport.failed.length }), {
description: hosts.join(', '),
});
}
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 };
}