'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;
}
}