Files
openmaic/OpenMAIC/tests/import/import-media-assets.test.ts
2026-08-16 14:58:47 +08:00

440 lines
14 KiB
TypeScript

import { BrowserAssetStore } from '@openmaic/storage';
import { IDBFactory } from 'fake-indexeddb';
import JSZip from 'jszip';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Slide } from '@openmaic/dsl';
import type { ClassroomManifest } from '@/lib/export/classroom-zip-types';
import { expectDocumentAssetOwnership } from '../media/assert-stage-asset-ownership';
const mocks = vi.hoisted(() => ({
getPool: vi.fn(),
mediaPut: vi.fn(),
audioPut: vi.fn(),
}));
vi.mock('@/lib/media/asset-pool', () => ({
getAssetPool: () => mocks.getPool(),
putAsset: (...args: unknown[]) => mocks.getPool().put(...args),
removeAsset: (...args: unknown[]) => mocks.getPool().remove(...args),
}));
vi.mock('@/lib/utils/database', () => ({
mediaFileKey: (stageId: string, ref: string) => `${stageId}:${ref}`,
db: {
mediaFiles: { put: mocks.mediaPut },
audioFiles: { put: mocks.audioPut },
},
}));
import {
materializeImportedAudio,
materializeImportedMedia,
mediaRefFromZipPath,
rewriteImportedSlideMediaRefs,
rewriteImportedVideoManifest,
} from '@/lib/import/import-classroom-core';
describe('classroom import media allocation', () => {
let pool: BrowserAssetStore;
let objectUrls: Map<string, Blob>;
beforeEach(() => {
pool = new BrowserAssetStore({
indexedDB: new IDBFactory(),
dbName: `import-media-${crypto.randomUUID()}`,
});
mocks.getPool.mockReset().mockReturnValue(pool);
mocks.mediaPut.mockReset().mockResolvedValue(undefined);
mocks.audioPut.mockReset().mockResolvedValue(undefined);
objectUrls = new Map();
vi.stubGlobal('URL', {
createObjectURL: vi.fn((blob: Blob) => {
const url = `blob:import-${objectUrls.size + 1}`;
objectUrls.set(url, blob);
return url;
}),
revokeObjectURL: vi.fn(),
});
});
afterEach(async () => {
await pool.close();
vi.unstubAllGlobals();
});
async function poolText(ref: string): Promise<string | null> {
const url = await pool.resolve(ref);
if (!url) return null;
try {
return (await objectUrls.get(url)?.text()) ?? null;
} finally {
await pool.release(ref);
}
}
it('re-mints colliding video and poster refs without changing the source assets', async () => {
const sourceVideoId = await pool.put(new Blob(['source-video'], { type: 'video/mp4' }));
const sourcePosterId = await pool.put(new Blob(['source-poster'], { type: 'image/jpeg' }));
const sourceVideoBefore = await poolText(sourceVideoId);
const sourcePosterBefore = await poolText(sourcePosterId);
const videoPath = `media/${sourceVideoId}.mp4`;
const posterPath = `media/${sourcePosterId}.jpg`;
const zip = new JSZip();
zip.file(videoPath, 'imported-video');
zip.file(videoPath.replace(/\.\w+$/, '.poster.jpg'), 'imported-poster');
zip.file(posterPath, 'imported-poster');
const slide = {
id: 'slide-1',
viewportSize: 1000,
viewportRatio: 0.5625,
theme: {
fontName: 'Arial',
fontColor: '#111111',
backgroundColor: '#ffffff',
themeColors: ['#111111'],
},
elements: [
{
id: 'video-1',
type: 'video',
src: sourceVideoId,
mediaRef: sourceVideoId,
poster: sourcePosterId,
left: 0,
top: 0,
width: 100,
height: 56,
rotate: 0,
autoplay: false,
},
],
} as Slide;
const manifest = {
formatVersion: 1,
exportedAt: new Date(0).toISOString(),
appVersion: 'test',
stage: {
name: 'Imported',
createdAt: 1,
updatedAt: 1,
videoManifest: { [sourceVideoId]: { type: 'video', prompt: 'Clip' } },
},
agents: [],
scenes: [
{
type: 'slide',
title: 'Scene',
order: 1,
content: { type: 'slide', canvas: slide },
},
],
mediaIndex: {
[videoPath]: { type: 'generated', mimeType: 'video/mp4', size: 14, prompt: 'Clip' },
[posterPath]: {
type: 'generated',
mimeType: 'image/jpeg',
size: 15,
prompt: 'Poster',
},
},
} satisfies ClassroomManifest;
const allocatedIds: string[] = [];
const mappings = await materializeImportedMedia(
zip,
manifest,
'imported-stage',
2,
allocatedIds,
);
const importedSlide = rewriteImportedSlideMediaRefs(slide, mappings);
const importedManifest = rewriteImportedVideoManifest(manifest.stage.videoManifest, mappings);
const newVideoId = mappings.refToNewId[sourceVideoId];
const newPosterId = mappings.refToNewId[sourcePosterId];
expect(newVideoId).not.toBe(sourceVideoId);
expect(newPosterId).not.toBe(sourcePosterId);
expect(allocatedIds).toEqual([newVideoId, newPosterId]);
expect(importedSlide.elements[0]).toMatchObject({
src: newVideoId,
mediaRef: newVideoId,
poster: newPosterId,
});
expect(importedManifest).toEqual({
[newVideoId]: { type: 'video', prompt: 'Clip' },
});
expect(await poolText(newVideoId)).toBe('imported-video');
expect(await poolText(newPosterId)).toBe('imported-poster');
// Byte-compare the colliding source-course entries before and after import.
expect(await poolText(sourceVideoId)).toBe(sourceVideoBefore);
expect(await poolText(sourcePosterId)).toBe(sourcePosterBefore);
expect(mocks.mediaPut).toHaveBeenCalledTimes(2);
expect(new Set(mocks.mediaPut.mock.calls.map(([row]) => row.id))).toEqual(
new Set([`imported-stage:${newVideoId}`, `imported-stage:${newPosterId}`]),
);
await expectDocumentAssetOwnership({
document: {
stage: {
id: 'imported-stage',
name: 'Imported',
createdAt: 1,
updatedAt: 2,
videoManifest: importedManifest,
},
scenes: [
{
id: 'scene-1',
stageId: 'imported-stage',
title: 'Scene',
order: 1,
type: 'slide',
content: { type: 'slide', canvas: importedSlide },
},
],
} as Parameters<typeof expectDocumentAssetOwnership>[0]['document'],
mediaRowIds: new Set(mocks.mediaPut.mock.calls.map(([row]) => row.id)),
audioRowIds: new Set(),
poolHas: async (ref) => {
const url = await pool.resolve(ref);
if (!url) return false;
await pool.release(ref);
return true;
},
});
});
it('clears opaque unmapped refs while preserving mapped, generated, and concrete refs', () => {
const rewritten = rewriteImportedSlideMediaRefs(
{
id: 'slide-refs',
viewportSize: 1000,
viewportRatio: 0.5625,
theme: {
fontName: 'Arial',
fontColor: '#111111',
backgroundColor: '#ffffff',
themeColors: ['#111111'],
},
elements: [
{ id: 'mapped', type: 'image', src: 'source-image' },
{ id: 'foreign', type: 'image', src: 'foreign-course-id' },
{ id: 'placeholder', type: 'image', src: 'gen_img_waiting' },
{ id: 'data-image', type: 'image', src: 'data:image/png;base64,AAAA' },
{ id: 'remote-image', type: 'image', src: 'https://example.test/image.png' },
{ id: 'relative-image', type: 'image', src: 'media/user-image.png' },
{
id: 'video',
type: 'video',
src: 'foreign-video',
mediaRef: 'foreign-video-ref',
poster: 'foreign-poster',
},
{
id: 'concrete-video',
type: 'video',
src: 'https://example.test/video.mp4',
mediaRef: 'videos/user-video.mp4',
poster: 'data:image/jpeg;base64,BBBB',
},
],
} as unknown as Slide,
{
refToNewId: { 'source-image': 'ast_new_image' },
posterRefToNewId: {},
posterByMediaRef: {},
},
);
expect(rewritten.elements[0]).toMatchObject({ src: 'ast_new_image' });
expect(rewritten.elements[1]).toMatchObject({ src: '' });
expect(rewritten.elements[2]).toMatchObject({ src: 'gen_img_waiting' });
expect(rewritten.elements[3]).toMatchObject({ src: 'data:image/png;base64,AAAA' });
expect(rewritten.elements[4]).toMatchObject({ src: 'https://example.test/image.png' });
expect(rewritten.elements[5]).toMatchObject({ src: 'media/user-image.png' });
expect(rewritten.elements[6]).toMatchObject({ src: '' });
expect(rewritten.elements[6]).not.toHaveProperty('mediaRef');
expect(rewritten.elements[6]).not.toHaveProperty('poster');
expect(rewritten.elements[7]).toMatchObject({
src: 'https://example.test/video.mp4',
mediaRef: 'videos/user-video.mp4',
poster: 'data:image/jpeg;base64,BBBB',
});
expect(
rewriteImportedVideoManifest(
{
foreign: { type: 'video', prompt: 'drop' },
'https://example.test/video.mp4': { type: 'video', prompt: 'remote' },
'videos/user-video.mp4': { type: 'video', prompt: 'relative' },
gen_vid_waiting: { type: 'video', prompt: 'generated' },
},
{
refToNewId: {},
posterRefToNewId: {},
posterByMediaRef: {},
},
),
).toEqual({
'https://example.test/video.mp4': { type: 'video', prompt: 'remote' },
'videos/user-video.mp4': { type: 'video', prompt: 'relative' },
gen_vid_waiting: { type: 'video', prompt: 'generated' },
});
});
it.each(['media/123', 'api/media?id=1', '/path/to/resource', 'path?query=val'])(
'preserves the extensionless relative media address %s',
(address) => {
const rewritten = rewriteImportedSlideMediaRefs(
{
id: 'slide-relative-refs',
viewportSize: 1000,
viewportRatio: 0.5625,
background: { type: 'image', image: { src: address } },
elements: [
{ id: 'image', type: 'image', src: address },
{
id: 'video',
type: 'video',
src: address,
mediaRef: address,
poster: address,
},
],
} as unknown as Slide,
{ refToNewId: {}, posterRefToNewId: {}, posterByMediaRef: {} },
);
expect(rewritten.background).toMatchObject({ type: 'image', image: { src: address } });
expect(rewritten.elements[0]).toMatchObject({ src: address });
expect(rewritten.elements[1]).toMatchObject({
src: address,
mediaRef: address,
poster: address,
});
},
);
it('falls back to the general ref mapping for an independently indexed poster', () => {
const rewritten = rewriteImportedSlideMediaRefs(
{
id: 'slide-poster',
viewportSize: 1000,
viewportRatio: 0.5625,
theme: {
fontName: 'Arial',
fontColor: '#111111',
backgroundColor: '#ffffff',
themeColors: ['#111111'],
},
elements: [
{
id: 'video',
type: 'video',
src: 'source-video',
mediaRef: 'source-video',
poster: 'source-poster',
},
],
} as unknown as Slide,
{
refToNewId: {
'source-video': 'ast_new_video',
'source-poster': 'ast_new_poster',
},
posterRefToNewId: {},
posterByMediaRef: {},
},
);
expect(rewritten.elements[0]).toMatchObject({
src: 'ast_new_video',
mediaRef: 'ast_new_video',
poster: 'ast_new_poster',
});
});
it.each([
[
'mapped',
'source-background',
{ 'source-background': 'ast_new_background' },
'ast_new_background',
],
['generated', 'gen_img_background', {}, 'gen_img_background'],
['concrete', 'https://example.test/background.png', {}, 'https://example.test/background.png'],
['unmapped opaque', 'foreign-background', {}, ''],
])(
'rewrites a %s slide background through the import boundary',
(_case, src, mapping, expected) => {
const rewritten = rewriteImportedSlideMediaRefs(
{
id: 'slide-background',
viewportSize: 1000,
viewportRatio: 0.5625,
background: { type: 'image', image: { src } },
elements: [],
} as unknown as Slide,
{
refToNewId: mapping,
posterRefToNewId: {},
posterByMediaRef: {},
},
);
expect(rewritten.background).toMatchObject({ type: 'image', image: { src: expected } });
},
);
it('allocates audio only after confirming the ZIP entry and stamps stage ownership', async () => {
const zip = new JSZip();
const missingPath = 'audio/missing.mp3';
const presentPath = 'audio/present.mp3';
zip.file(presentPath, 'voice-bytes');
const manifest = {
formatVersion: 1,
exportedAt: new Date(0).toISOString(),
appVersion: 'test',
stage: { name: 'Imported', createdAt: 1, updatedAt: 1 },
agents: [],
scenes: [],
mediaIndex: {
[missingPath]: { type: 'audio', format: 'mp3' },
[presentPath]: { type: 'audio', format: 'mp3' },
},
} satisfies ClassroomManifest;
const allocations: string[] = [];
const put = vi.spyOn(pool, 'put');
const mappings = await materializeImportedAudio(
zip,
manifest,
'imported-stage',
2,
allocations,
);
expect(mappings[missingPath]).toBeUndefined();
expect(mappings[presentPath]).toMatch(/^ast_/);
expect(put).toHaveBeenCalledTimes(1);
expect(allocations).toEqual([mappings[presentPath]]);
expect(mocks.audioPut).toHaveBeenCalledWith(
expect.objectContaining({ id: mappings[presentPath], stageId: 'imported-stage' }),
);
});
it('re-derives nested refs with parameterized MIME metadata intact', () => {
expect(mediaRefFromZipPath('media/nested/course/ref.svg', 'image/svg+xml; charset=utf-8')).toBe(
'nested/course/ref',
);
expect(
mediaRefFromZipPath(
'media/nested/course/ref.svg+xml;charset=utf-8',
'image/svg+xml;charset=utf-8',
),
).toBe('nested/course/ref');
});
});