'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; onProgress?: (message: string) => void; } /** Fetch the app's committed, vendored GSAP so the export bundles it (no CDN at render). */ async function loadGsapSource(): Promise { 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 { 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/` * (matching the URLs the emitted HTML references); GSAP is vendored at the * project's `gsapVendorPath`. */ export async function packageVideoZip( project: EmittedProject, assetBlobs: Map, options: PackageOptions = {}, ): Promise { 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/. 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' }); }