Files
openmaic/OpenMAIC/lib/video-export-app/package-zip.ts
2026-08-16 14:58:47 +08:00

94 lines
3.7 KiB
TypeScript

'use client';
/**
* Packaging — assemble the emitted text project + collected asset bytes +
* vendored GSAP into the self-contained export ZIP.
*
* The emitter ({@link emitHyperframes}) produced every text file (index.html,
* manifest, subtitles, README) and the collection layer ({@link collectVideoAssets})
* produced the binary bytes keyed by the compiler's asset-plan paths. This layer
* lays them out under `assets/`, adds the vendored GSAP (determinism: shipped in
* the zip, never a CDN), and streams the archive to a Blob. JSZip is imported
* lazily so it stays out of the main app bundle.
*
* App-side / impure: does IO (fetch for GSAP) and dynamic-imports JSZip.
*/
import { assetUrl, type EmittedProject } from '@/lib/video-export';
/** Default location the app serves the vendored GSAP from (committed at public/vendor). */
const GSAP_PUBLIC_URL = '/vendor/gsap.min.js';
export interface PackageOptions {
/**
* The vendored GSAP source text. Defaults to fetching {@link GSAP_PUBLIC_URL}
* (served from `public/vendor/gsap.min.js`). Passing it explicitly avoids the
* fetch (e.g. in tests).
*/
gsapSource?: string;
/** Load one app-local vendored binary. Injectable for deterministic unit tests. */
loadVendorAsset?: (sourceUrl: string) => Promise<Blob>;
onProgress?: (message: string) => void;
}
/** Fetch the app's committed, vendored GSAP so the export bundles it (no CDN at render). */
async function loadGsapSource(): Promise<string> {
const res = await fetch(GSAP_PUBLIC_URL);
if (!res.ok)
throw new Error(`Failed to load vendored GSAP from ${GSAP_PUBLIC_URL} (${res.status})`);
return res.text();
}
async function loadVendorAsset(sourceUrl: string): Promise<Blob> {
const res = await fetch(sourceUrl);
if (!res.ok) throw new Error(`Failed to load vendored export asset ${sourceUrl} (${res.status})`);
return res.blob();
}
/**
* Build the export ZIP blob from an emitted project and its collected assets.
* Text files land at the project root; asset blobs land under `assets/<planPath>`
* (matching the URLs the emitted HTML references); GSAP is vendored at the
* project's `gsapVendorPath`.
*/
export async function packageVideoZip(
project: EmittedProject,
assetBlobs: Map<string, Blob>,
options: PackageOptions = {},
): Promise<Blob> {
const JSZip = (await import('jszip')).default;
const zip = new JSZip();
// Emitted project-relative text files (including nested license files).
for (const file of project.files) {
zip.file(file.path, file.content);
}
// Collected binary assets under assets/<planPath>.
for (const [planPath, blob] of assetBlobs) {
// JSZip's Node build cannot reliably consume a web Blob directly; an
// ArrayBuffer works in both browser and Node and preserves the exact bytes.
zip.file(assetUrl(planPath), await blob.arrayBuffer());
}
// Quiz fonts are project-declared: cover-only/no-Quiz exports never fetch or
// parse their bytes, while Quiz projects keep every font local to the ZIP.
const vendorAssetLoader = options.loadVendorAsset ?? loadVendorAsset;
const vendorAssets = await Promise.all(
project.vendorAssets.map(async (asset) => ({
path: asset.path,
bytes: await (await vendorAssetLoader(asset.sourceUrl)).arrayBuffer(),
})),
);
for (const asset of vendorAssets) {
zip.file(asset.path, asset.bytes);
}
// Vendored GSAP at the path the HTML loads it from.
options.onProgress?.('Bundling GSAP runtime');
const gsapSource = options.gsapSource ?? (await loadGsapSource());
zip.file(project.gsapVendorPath, gsapSource);
options.onProgress?.('Compressing archive');
return zip.generateAsync({ type: 'blob', compression: 'DEFLATE' });
}