'use client'; /** * Deep app-side module that turns authored interactive HTML into bounded, * self-contained pages ready for the pure video compiler and byte collector. */ import type { Scene } from '@/lib/types/stage'; import { inlineHtmlAssets, type FetchAsset } from '@/lib/export/inline-assets'; import { injectIntoDocumentHead } from '@/lib/utils/html-document'; import { patchHtmlForIframe } from '@/lib/utils/iframe'; import type { InteractiveHtmlMeta, InteractiveHtmlSource } from '@/lib/video-export/deps'; import { INTERACTIVE_READY_TIMEOUT_MS, INTERACTIVE_SETTLE_MS, INTERACTIVE_STATIC_MESSAGE_FLAG, } from '@/lib/video-export/interactive-static'; const DEFAULT_MAX_HTML_BYTES = 32 * 1024 * 1024; export interface PreparedInteractiveHtmlSet extends InteractiveHtmlSource { /** Exact packaged HTML for an owning asset-plan entry. */ content(assetId: string): string | undefined; } export interface PrepareInteractiveHtmlOptions { fetcher?: FetchAsset; maxHtmlBytes?: number; } function staticCaptureInjection(): string { const flag = JSON.stringify(INTERACTIVE_STATIC_MESSAGE_FLAG); const settleMs = INTERACTIVE_SETTLE_MS; const internalTimeoutMs = Math.max(1_000, INTERACTIVE_READY_TIMEOUT_MS - 1_000); return ` `; } /** KaTeX emits `about:invalid` font fallbacks after its embedded data fonts. */ function stripInvalidFontFallbacks(html: string): string { return html.replace( /url\(\s*about:invalid\s*\)(?:\s*format\(\s*["'][^"']+["']\s*\))?\s*,?/gi, '', ); } async function sha256(value: string): Promise { const bytes = new TextEncoder().encode(value); const digest = await crypto.subtle.digest('SHA-256', bytes); return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join(''); } class PreparedInteractiveHtmlSetImpl implements PreparedInteractiveHtmlSet { constructor( private readonly bySceneId: ReadonlyMap, private readonly byAssetId: ReadonlyMap, ) {} html(scene: { id: string }): InteractiveHtmlMeta | null { return this.bySceneId.get(scene.id) ?? null; } content(assetId: string): string | undefined { return this.byAssetId.get(assetId); } } export function emptyPreparedInteractiveHtmlSet(): PreparedInteractiveHtmlSet { return new PreparedInteractiveHtmlSetImpl(new Map(), new Map()); } export async function prepareInteractiveHtmlScenes( scenes: readonly Scene[], options: PrepareInteractiveHtmlOptions = {}, ): Promise { const bySceneId = new Map(); const byAssetId = new Map(); const maxBytes = options.maxHtmlBytes ?? DEFAULT_MAX_HTML_BYTES; for (const scene of scenes) { if (scene.content.type !== 'interactive') continue; const assetId = `interactive:${scene.id}`; const authored = scene.content.html; if (!authored?.trim()) { bySceneId.set(scene.id, { id: assetId, present: false, failure: 'missing-html', }); continue; } try { const { html: inlined, report, unresolved, } = await inlineHtmlAssets(authored, { fetcher: options.fetcher, keepImportmapFallbacks: false, }); const sanitized = stripInvalidFontFallbacks(inlined); const residual = [ ...new Set([ ...report.failed .map((failure) => failure.url) .filter((url) => !/^about:invalid$/i.test(url)), ...unresolved, ]), ]; if (residual.length > 0) { bySceneId.set(scene.id, { id: assetId, present: false, failure: 'unresolved-resource', message: `unresolved-interactive-resource:${residual.slice(0, 3).join(', ')}${residual.length > 3 ? ` (+${residual.length - 3} more)` : ''}`, }); continue; } const packaged = injectIntoDocumentHead( patchHtmlForIframe(sanitized), staticCaptureInjection(), ); const size = new TextEncoder().encode(packaged).byteLength; if (size > maxBytes) { bySceneId.set(scene.id, { id: assetId, present: false, failure: 'too-large', message: `interactive-html-too-large:${size}/${maxBytes}`, }); continue; } const contentHash = await sha256(packaged); bySceneId.set(scene.id, { id: assetId, present: true, contentHash }); byAssetId.set(assetId, packaged); } catch (error) { bySceneId.set(scene.id, { id: assetId, present: false, failure: 'packaging-failed', message: `interactive-html-packaging-failed:${error instanceof Error ? error.message : String(error)}`, }); } } return new PreparedInteractiveHtmlSetImpl(bySceneId, byAssetId); } declare global { interface Window { __openmaicFreezeInteractive?: () => void; } }