feat(video-export): capture self-contained static interactive HTML (#1086)

* feat(video-export): capture static interactive HTML

* fix(video-export): harden interactive HTML capture diagnostics

* fix(video-export): initialize runtime diagnostics in manifest

* fix(video-export): close interactive capture review findings

* fix(video-export): preserve packaged interactive resources

* fix(video-export): address packaging review feedback

* fix(video-export): harden interactive HTML packaging

* fix(video-export): unify interactive asset packaging

* fix(video-export): close remaining interactive packaging gaps
This commit is contained in:
杨慎
2026-08-11 22:16:58 +08:00
committed by GitHub
parent 969574bee4
commit 4ac70cceeb
45 changed files with 3254 additions and 321 deletions
+6 -1
View File
@@ -224,6 +224,11 @@ jobs:
env:
COVER_LAYOUT_BROWSER: '1'
- name: Interactive static HTML Chromium smoke
run: pnpm exec vitest run tests/video-export/interactive-static-html.browser.test.ts
env:
INTERACTIVE_STATIC_BROWSER: '1'
# runner.* is only valid in step contexts (not job-level env).
- name: Materialize Hyperframes lint samples
run: pnpm exec vitest run tests/video-export/e2e-materialize.test.ts
@@ -238,7 +243,7 @@ jobs:
HF_E2E_DIR: ${{ runner.temp }}/openmaic-hyperframes-samples
run: |
set -euo pipefail
for sample in quiz pbl-v2 pbl-legacy pbl-dense mixed arabic; do
for sample in quiz pbl-v2 pbl-legacy pbl-dense mixed arabic interactive-static; do
dir="$HF_E2E_DIR/$sample"
if output="$(pnpm exec hyperframes lint "$dir" 2>&1)"; then
status=0
+85
View File
@@ -0,0 +1,85 @@
import postcss, { type AtRule, type Root } from 'postcss';
import valueParser, { type Node as CssValueNode } from 'postcss-value-parser';
export interface CssUrlReference {
raw: string;
start: number;
end: number;
}
export function cssUrlReferences(value: string): CssUrlReference[] {
const refs: CssUrlReference[] = [];
valueParser(value).walk((node: CssValueNode) => {
if (node.type !== 'function' || node.value.toLowerCase() !== 'url') return;
const raw = valueParser.stringify(node.nodes).trim();
const unquoted =
(raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))
? raw.slice(1, -1)
: raw;
refs.push({ raw: unquoted, start: node.sourceIndex, end: node.sourceEndIndex });
return false;
});
return refs;
}
export function rewriteCssValue(
value: string,
replacementFor: (raw: string) => string | undefined,
): string {
return cssUrlReferences(value)
.sort((left, right) => right.start - left.start)
.reduce((rewritten, ref) => {
const replacement = replacementFor(ref.raw);
return replacement === undefined
? rewritten
: rewritten.slice(0, ref.start) + `url(${replacement})` + rewritten.slice(ref.end);
}, value);
}
export function cssImportReference(rule: AtRule): { url: string; conditions: string } | null {
const parsed = valueParser(rule.params);
const node = parsed.nodes.find(
(candidate) => candidate.type !== 'space' && candidate.type !== 'comment',
);
if (!node) return null;
let url: string | null = null;
if (node.type === 'string') url = node.value;
if (node.type === 'function' && node.value.toLowerCase() === 'url') {
const raw = valueParser.stringify(node.nodes).trim();
url =
(raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))
? raw.slice(1, -1)
: raw;
}
if (!url) return null;
return { url, conditions: rule.params.slice(node.sourceEndIndex).trim() };
}
export function parseCss(css: string, cssUrl: string): Root {
try {
return postcss.parse(css, { from: undefined });
} catch (error) {
throw new Error(
`interactive-css-parse-failed:${cssUrl}:${error instanceof Error ? error.message : String(error)}`,
);
}
}
export function collectCssAssetReferences(
css: string,
context: 'stylesheet' | 'declaration-list' = 'stylesheet',
): Array<{ kind: 'css-url' | 'css-import'; url: string }> {
const root = parseCss(context === 'stylesheet' ? css : `.x{${css}}`, 'inline-css');
const refs: Array<{ kind: 'css-url' | 'css-import'; url: string }> = [];
root.walkAtRules((rule) => {
if (rule.name.toLowerCase() !== 'import') return;
const reference = cssImportReference(rule);
if (reference) refs.push({ kind: 'css-import', url: reference.url });
});
root.walkDecls((declaration) => {
for (const ref of cssUrlReferences(declaration.value)) {
refs.push({ kind: 'css-url', url: ref.raw });
}
});
return refs;
}
+329
View File
@@ -0,0 +1,329 @@
import { extractSpecifiers } from './inline-assets-importmap';
import { collectCssAssetReferences } from './css-asset-parser';
import { parse, type DefaultTreeAdapterTypes } from 'parse5';
import parseSrcset from 'parse-srcset';
export type AssetRefKind =
| 'link'
| 'script'
| 'img'
| 'srcset'
| 'poster'
| 'iframe-src'
| 'iframe-srcdoc'
| 'object-data'
| 'embed-src'
| 'source'
| 'video'
| 'audio'
| 'svg-image'
| 'svg-use'
| 'css-url'
| 'css-import'
| 'module-import'
| 'base'
| 'importmap';
export interface AssetRef {
kind: AssetRefKind;
url: string;
}
export interface SourceRange {
start: number;
end: number;
}
export interface SourcePatch {
range: SourceRange;
replacement: string;
}
export interface HtmlAttributeAsset extends AssetRef {
tagName: string;
attributeName: string;
attributes: Readonly<Record<string, string>>;
attributeRange?: SourceRange;
elementRange?: SourceRange;
}
export interface HtmlTextElement {
tagName: 'script' | 'style';
attributes: Readonly<Record<string, string>>;
content: string;
contentRange?: SourceRange;
elementRange?: SourceRange;
}
export interface HtmlAssetInventory {
attributeAssets: HtmlAttributeAsset[];
moduleScripts: HtmlTextElement[];
importmaps: HtmlTextElement[];
styles: HtmlTextElement[];
styleAttributes: Array<{ css: string; range?: SourceRange }>;
svgPresentationAttributes: Array<{
attributeName: string;
cssValue: string;
range?: SourceRange;
}>;
}
export function applySourcePatches(html: string, patches: readonly SourcePatch[]): string {
const ordered = [...patches].sort((left, right) => right.range.start - left.range.start);
let previousStart = html.length;
let result = html;
for (const patch of ordered) {
if (
patch.range.start < 0 ||
patch.range.end < patch.range.start ||
patch.range.end > html.length ||
patch.range.end > previousStart
) {
throw new Error('overlapping-or-invalid-html-source-patch');
}
result = result.slice(0, patch.range.start) + patch.replacement + result.slice(patch.range.end);
previousStart = patch.range.start;
}
return result;
}
export function replaceAttributePatch(
asset: HtmlAttributeAsset,
value: string,
): SourcePatch | null {
return replaceAttributeRangePatch(asset.attributeName, asset.attributeRange, value);
}
export function replaceAttributeRangePatch(
attributeName: string,
range: SourceRange | undefined,
value: string,
): SourcePatch | null {
if (!range) return null;
const escaped = value.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
return {
range,
replacement: `${attributeName}="${escaped}"`,
};
}
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
const SVG_URL_PRESENTATION_ATTRIBUTES = new Set([
'clip-path',
'cursor',
'fill',
'filter',
'marker',
'marker-end',
'marker-mid',
'marker-start',
'mask',
'stroke',
]);
function isElement(
node: DefaultTreeAdapterTypes.ChildNode,
): node is DefaultTreeAdapterTypes.Element {
return !node.nodeName.startsWith('#');
}
function isTemplate(
element: DefaultTreeAdapterTypes.Element,
): element is DefaultTreeAdapterTypes.Template {
return element.tagName === 'template' && 'content' in element;
}
function sourceRange(location: { startOffset: number; endOffset: number } | null | undefined) {
return location ? { start: location.startOffset, end: location.endOffset } : undefined;
}
function attributeName(attribute: DefaultTreeAdapterTypes.Element['attrs'][number]): string {
return attribute.prefix ? `${attribute.prefix}:${attribute.name}` : attribute.name;
}
function attributesOf(element: DefaultTreeAdapterTypes.Element): Record<string, string> {
return Object.fromEntries(
element.attrs.map((attribute) => [attributeName(attribute), attribute.value]),
);
}
function textElement(
html: string,
element: DefaultTreeAdapterTypes.Element,
): HtmlTextElement | null {
if (element.tagName !== 'script' && element.tagName !== 'style') return null;
const location = element.sourceCodeLocation;
const start = location?.startTag?.endOffset;
const end = location?.endTag?.startOffset;
return {
tagName: element.tagName,
attributes: attributesOf(element),
content: start !== undefined && end !== undefined ? html.slice(start, end) : '',
contentRange: start !== undefined && end !== undefined ? { start, end } : undefined,
elementRange: sourceRange(location),
};
}
function attributeRange(
element: DefaultTreeAdapterTypes.Element,
attribute: DefaultTreeAdapterTypes.Element['attrs'][number],
): SourceRange | undefined {
const locations = element.sourceCodeLocation?.attrs;
return sourceRange(locations?.[attributeName(attribute)] ?? locations?.[attribute.name]);
}
export function analyzeHtmlAssetInventory(html: string): HtmlAssetInventory {
const inventory: HtmlAssetInventory = {
attributeAssets: [],
moduleScripts: [],
importmaps: [],
styles: [],
styleAttributes: [],
svgPresentationAttributes: [],
};
const document = parse(html, { sourceCodeLocationInfo: true });
const visit = (element: DefaultTreeAdapterTypes.Element) => {
const attrs = attributesOf(element);
const type = attrs.type?.trim().toLowerCase();
const addAttribute = (kind: AssetRefKind, name: string) => {
const attribute = element.attrs.find((candidate) => attributeName(candidate) === name);
if (!attribute?.value.trim()) return;
inventory.attributeAssets.push({
kind,
url: attribute.value,
tagName: element.tagName,
attributeName: name,
attributes: attrs,
attributeRange: attributeRange(element, attribute),
elementRange: sourceRange(element.sourceCodeLocation),
});
};
if (element.tagName === 'base') addAttribute('base', 'href');
if (element.tagName === 'link') addAttribute('link', 'href');
if (element.tagName === 'script' && type !== 'importmap' && type !== 'application/json') {
addAttribute('script', 'src');
}
if (element.tagName === 'img') {
addAttribute('img', 'src');
addAttribute('srcset', 'srcset');
}
if (element.tagName === 'source') {
addAttribute('source', 'src');
addAttribute('srcset', 'srcset');
}
if (element.tagName === 'video') {
addAttribute('video', 'src');
addAttribute('poster', 'poster');
}
if (element.tagName === 'audio') addAttribute('audio', 'src');
if (element.tagName === 'iframe') {
addAttribute('iframe-src', 'src');
addAttribute('iframe-srcdoc', 'srcdoc');
}
if (element.tagName === 'object') addAttribute('object-data', 'data');
if (element.tagName === 'embed') addAttribute('embed-src', 'src');
if (element.namespaceURI === SVG_NAMESPACE && element.tagName === 'image') {
addAttribute('svg-image', attrs.href !== undefined ? 'href' : 'xlink:href');
}
if (element.namespaceURI === SVG_NAMESPACE && element.tagName === 'use') {
addAttribute('svg-use', attrs.href !== undefined ? 'href' : 'xlink:href');
}
const styleAttribute = element.attrs.find((attribute) => attribute.name === 'style');
if (styleAttribute) {
inventory.styleAttributes.push({
css: styleAttribute.value,
range: attributeRange(element, styleAttribute),
});
}
if (element.namespaceURI === SVG_NAMESPACE) {
for (const attribute of element.attrs) {
if (!SVG_URL_PRESENTATION_ATTRIBUTES.has(attribute.name)) continue;
inventory.svgPresentationAttributes.push({
attributeName: attribute.name,
cssValue: attribute.value,
range: attributeRange(element, attribute),
});
}
}
const text = textElement(html, element);
if (text?.tagName === 'style') inventory.styles.push(text);
if (text?.tagName === 'script' && type === 'module') inventory.moduleScripts.push(text);
if (text?.tagName === 'script' && type === 'importmap') inventory.importmaps.push(text);
for (const child of element.childNodes) if (isElement(child)) visit(child);
if (isTemplate(element)) {
for (const child of element.content.childNodes) if (isElement(child)) visit(child);
}
};
for (const child of document.childNodes) if (isElement(child)) visit(child);
return inventory;
}
/** Compatibility view over the structured inventory. */
export function collectAssetRefs(
html: string,
options: { includeRelative?: boolean } = {},
): AssetRef[] {
const inventory = analyzeHtmlAssetInventory(html);
const refs: AssetRef[] = [];
const pushUrl = (kind: AssetRefKind, url: string) => {
if (kind === 'iframe-srcdoc') {
refs.push({ kind, url: 'iframe[srcdoc]' });
return;
}
const value = url.trim();
if (!value) return;
if (/^(?:data:|blob:)/i.test(value)) {
if (kind === 'iframe-src' || kind === 'object-data' || kind === 'embed-src') {
refs.push({ kind, url: value });
} else if (/^blob:/i.test(value) && options.includeRelative) {
refs.push({ kind, url: value });
}
return;
}
if (/^(?:about:|#)/i.test(value)) return;
if (options.includeRelative || /^https?:\/\//i.test(value)) refs.push({ kind, url: value });
};
for (const ref of inventory.attributeAssets) {
if (ref.kind === 'srcset') {
for (const candidate of parseSrcset(ref.url)) pushUrl('srcset', candidate.url);
} else {
pushUrl(ref.kind, ref.url);
}
}
for (const style of inventory.styles) {
for (const ref of collectCssAssetReferences(style.content)) pushUrl(ref.kind, ref.url);
}
for (const style of inventory.styleAttributes) {
for (const ref of collectCssAssetReferences(style.css, 'declaration-list')) {
pushUrl(ref.kind, ref.url);
}
}
for (const attribute of inventory.svgPresentationAttributes) {
for (const ref of collectCssAssetReferences(
`${attribute.attributeName}:${attribute.cssValue}`,
'declaration-list',
)) {
pushUrl(ref.kind, ref.url);
}
}
for (const script of inventory.moduleScripts) {
for (const specifier of extractSpecifiers(script.content)) pushUrl('module-import', specifier);
}
for (const importmap of inventory.importmaps) {
try {
const imports = (JSON.parse(importmap.content) as { imports?: Record<string, string> })
.imports;
for (const value of Object.values(imports ?? {})) pushUrl('importmap', value);
} catch {
// Malformed import maps are surfaced by the strict video preparation path.
}
}
return refs;
}
+115 -19
View File
@@ -1,16 +1,48 @@
import { toDataUri, type InlineReport } from './inline-assets-shared';
import { parse } from 'es-module-lexer/js';
// Matches: import ... from 'X' | import 'X' | export ... from 'X' | import('X')
const IMPORT_SPEC_RE =
/(?:\bimport\b[\s\S]*?\bfrom\b|\bexport\b[\s\S]*?\bfrom\b|\bimport)\s*["']([^"']+)["']|\bimport\(\s*["']([^"']+)["']\s*\)/g;
interface ModuleSpecifierRange {
specifier: string;
start: number;
end: number;
}
function moduleSpecifierRanges(code: string): ModuleSpecifierRange[] {
const [imports] = parse(code);
return imports.flatMap((entry) => {
if (!entry.n || entry.d === -2) return [];
let start = entry.s;
let end = entry.e;
if (entry.d >= 0 && (code[start] === '"' || code[start] === "'")) {
start++;
end--;
}
return [{ specifier: entry.n, start, end }];
});
}
/** Rewrite every statically discoverable module specifier without duplicating parser logic. */
export async function rewriteModuleSpecifiers(
code: string,
replacementFor: (specifier: string) => string | undefined | Promise<string | undefined>,
): Promise<string> {
const ranges = moduleSpecifierRanges(code);
const replacements = await Promise.all(
ranges.map(async ({ specifier, start, end }) => {
return (await replacementFor(specifier)) ?? code.slice(start, end);
}),
);
let rewritten = '';
let last = 0;
ranges.forEach((range, index) => {
rewritten += code.slice(last, range.start) + replacements[index];
last = range.end;
});
return rewritten + code.slice(last);
}
export function extractSpecifiers(code: string): string[] {
const specs = new Set<string>();
for (const m of code.matchAll(IMPORT_SPEC_RE)) {
const s = m[1] ?? m[2];
if (s) specs.add(s);
}
return [...specs];
return [...new Set(moduleSpecifierRanges(code).map(({ specifier }) => specifier))];
}
/** Resolve a specifier against importmap: exact match first, then longest '/'-terminated prefix. */
@@ -27,18 +59,72 @@ export function resolveSpecifier(spec: string, imports: Record<string, string>):
export async function buildInlinedImportmap(
originalImports: Record<string, string>,
moduleScriptBodies: string[],
moduleScripts: readonly (string | { code: string; baseUrl?: string })[],
fetchAsset: (url: string) => Promise<{ bytes: Uint8Array; contentType: string } | null>,
): Promise<{ imports: Record<string, string>; report: InlineReport }> {
const report: InlineReport = { inlined: [], failed: [] };
const resolvedDataUri = new Map<string, string>(); // specifier -> data: URI
const visited = new Set<string>();
async function visitSpecifier(spec: string): Promise<void> {
if (visited.has(spec)) return;
visited.add(spec);
const absUrl = resolveSpecifier(spec, originalImports);
if (!absUrl) return; // not mapped (relative/bare-unmapped) — leave to browser
const normalizedScripts = moduleScripts.map((script) =>
typeof script === 'string' ? { code: script, baseUrl: undefined } : script,
);
const resolveExternal = (specifier: string, baseUrl?: string): string | null => {
if (/^https?:\/\//i.test(specifier)) return specifier;
if (!baseUrl || /^(?:data:|blob:|about:|#)/i.test(specifier)) return null;
try {
const resolved = new URL(specifier, baseUrl).href;
return /^https?:\/\//i.test(resolved) ? resolved : null;
} catch {
return null;
}
};
async function inlineModuleDependencies(
code: string,
baseUrl: string | undefined,
stack: Set<string>,
): Promise<string> {
return rewriteModuleSpecifiers(code, async (spec) => {
if (!spec || resolveSpecifier(spec, originalImports)) {
return undefined;
}
const absUrl = resolveExternal(spec, baseUrl);
if (!absUrl) {
return undefined;
}
if (stack.has(absUrl)) {
if (!report.failed.some((failure) => failure.url === absUrl)) {
report.failed.push({ url: absUrl, reason: 'cyclic module dependency' });
}
return undefined;
}
const got = await fetchAsset(absUrl);
if (!got) {
if (!report.failed.some((failure) => failure.url === absUrl)) {
report.failed.push({ url: absUrl, reason: 'fetch failed' });
}
return undefined;
}
if (!report.inlined.includes(absUrl)) report.inlined.push(absUrl);
const nestedStack = new Set(stack).add(absUrl);
const nestedCode = await inlineModuleDependencies(
new TextDecoder().decode(got.bytes),
absUrl,
nestedStack,
);
const dataUri = toDataUri(new TextEncoder().encode(nestedCode), got.contentType);
return dataUri;
});
}
async function visitSpecifier(spec: string, baseUrl?: string): Promise<void> {
const visitKey = `${baseUrl ?? ''}\u0000${spec}`;
if (visited.has(visitKey)) return;
visited.add(visitKey);
const absUrl = resolveSpecifier(spec, originalImports) ?? resolveExternal(spec, baseUrl);
if (!absUrl) return; // bare/unmapped specifier — leave to the browser for diagnostics
if (/^data:/i.test(absUrl)) {
resolvedDataUri.set(spec, absUrl);
return;
@@ -51,16 +137,26 @@ export async function buildInlinedImportmap(
}
resolvedDataUri.set(spec, toDataUri(got.bytes, got.contentType));
if (!report.inlined.includes(absUrl)) report.inlined.push(absUrl);
const code = new TextDecoder().decode(got.bytes);
const code = await inlineModuleDependencies(
new TextDecoder().decode(got.bytes),
absUrl,
new Set([absUrl]),
);
resolvedDataUri.set(spec, toDataUri(new TextEncoder().encode(code), got.contentType));
for (const childSpec of extractSpecifiers(code)) {
await visitSpecifier(childSpec);
await visitSpecifier(childSpec, absUrl);
}
}
for (const body of moduleScriptBodies) {
for (const spec of extractSpecifiers(body)) await visitSpecifier(spec);
for (const { code, baseUrl } of normalizedScripts) {
for (const spec of extractSpecifiers(code)) await visitSpecifier(spec, baseUrl);
}
// Also inline direct imports from module scripts that do not use an importmap.
// `inlineHtmlAssets` invokes the same dependency walk while rewriting each
// module source; this pass ensures importmap entries still discover every
// nested dependency before the map is emitted.
const imports: Record<string, string> = {};
for (const [spec, dataUri] of resolvedDataUri) imports[spec] = dataUri;
return { imports, report };
+2
View File
@@ -7,6 +7,8 @@ export interface InlineOptions {
fetchImpl?: typeof fetch;
maxAssetBytes?: number;
fetcher?: FetchAsset;
/** Keep original remote import-map entries as an online fallback. Default true. */
keepImportmapFallbacks?: boolean;
}
export type FetchAsset = (
+551 -205
View File
@@ -4,58 +4,107 @@ import {
type InlineOptions,
type FetchAsset,
} from './inline-assets-shared';
import { buildInlinedImportmap } from './inline-assets-importmap';
import {
buildInlinedImportmap,
resolveSpecifier,
rewriteModuleSpecifiers,
} from './inline-assets-importmap';
import {
analyzeHtmlAssetInventory,
applySourcePatches,
collectAssetRefs,
replaceAttributePatch,
replaceAttributeRangePatch,
type AssetRefKind,
type SourcePatch,
} from './html-asset-inventory';
import parseSrcset, { type SrcsetCandidate } from 'parse-srcset';
import type { AtRule, Root } from 'postcss';
import {
cssImportReference,
cssUrlReferences,
parseCss,
rewriteCssValue,
} from './css-asset-parser';
export { toDataUri } from './inline-assets-shared';
export type { InlineReport, InlineOptions, FetchAsset } from './inline-assets-shared';
export type AssetRefKind = 'link' | 'script' | 'img' | 'source' | 'css-url' | 'importmap';
export interface AssetRef {
kind: AssetRefKind;
url: string;
}
export { collectAssetRefs } from './html-asset-inventory';
export type { AssetRef, AssetRefKind } from './html-asset-inventory';
const HTTP_URL = /^https?:\/\//i;
/** Scan LLM-generated interactive HTML for external http(s) asset references. */
export function collectAssetRefs(html: string): AssetRef[] {
const refs: AssetRef[] = [];
const push = (kind: AssetRefKind, url: string) => {
if (HTTP_URL.test(url)) refs.push({ kind, url });
};
interface CssImportConditions {
layer?: string | null;
supports?: string;
media?: string;
}
for (const m of html.matchAll(/<link\b[^>]*?\bhref\s*=\s*["']([^"']+)["'][^>]*>/gi)) {
push('link', m[1]);
}
for (const m of html.matchAll(/<script\b([^>]*?)\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi)) {
const whole = m[0].toLowerCase();
if (whole.includes('importmap') || whole.includes('application/json')) continue;
push('script', m[2]);
}
for (const m of html.matchAll(/<img\b[^>]*?\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi)) {
push('img', m[1]);
}
for (const m of html.matchAll(/<source\b[^>]*?\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi)) {
push('source', m[1]);
}
for (const m of html.matchAll(/url\(\s*["']?([^"')]+)["']?\s*\)/gi)) {
push('css-url', m[1].trim());
}
for (const m of html.matchAll(
/<script\b[^>]*type\s*=\s*["']importmap["'][^>]*>([\s\S]*?)<\/script>/gi,
)) {
try {
const map = JSON.parse(m[1]);
const imports = map.imports ?? {};
for (const v of Object.values(imports)) {
if (typeof v === 'string') push('importmap', v);
}
} catch {
// malformed importmap — skip
function readParenthesized(value: string, start: number): { inner: string; rest: string } | null {
if (value[start] !== '(') return null;
let depth = 0;
for (let index = start; index < value.length; index++) {
if (value[index] === '(') depth++;
if (value[index] === ')') depth--;
if (depth === 0) {
return { inner: value.slice(start + 1, index), rest: value.slice(index + 1).trim() };
}
}
return refs;
return null;
}
function parseCssImportConditions(value: string): CssImportConditions {
let rest = value.trim();
const conditions: CssImportConditions = {};
if (/^layer\b/i.test(rest)) {
rest = rest.slice(5).trimStart();
if (rest.startsWith('(')) {
const parsed = readParenthesized(rest, 0);
conditions.layer = parsed?.inner.trim() ?? null;
rest = parsed?.rest ?? '';
} else {
conditions.layer = null;
}
}
if (/^supports\s*\(/i.test(rest)) {
const open = rest.indexOf('(');
const parsed = readParenthesized(rest, open);
conditions.supports = parsed?.inner.trim() ?? '';
rest = parsed?.rest ?? '';
}
conditions.media = rest || undefined;
return conditions;
}
function wrapImportedCss(css: string, conditions: CssImportConditions): string {
let wrapped = css;
if (conditions.media) wrapped = `@media ${conditions.media}{${wrapped}}`;
if (conditions.supports) {
const supports = /^(?:\(|not\b|selector\(|font-(?:format|tech)\()/i.test(conditions.supports)
? conditions.supports
: `(${conditions.supports})`;
wrapped = `@supports ${supports}{${wrapped}}`;
}
if (conditions.layer !== undefined) {
wrapped = conditions.layer ? `@layer ${conditions.layer}{${wrapped}}` : `@layer{${wrapped}}`;
}
return wrapped;
}
function serializeSrcset(candidates: SrcsetCandidate[]): string {
return candidates
.map((candidate) => {
const descriptor =
candidate.w !== undefined
? `${candidate.w}w`
: candidate.d !== undefined
? `${candidate.d}x`
: candidate.h !== undefined
? `${candidate.h}h`
: '';
return descriptor ? `${candidate.url} ${descriptor}` : candidate.url;
})
.join(', ');
}
const DEFAULT_MAX_ASSET_BYTES = 8 * 1024 * 1024;
@@ -147,6 +196,56 @@ function guessMime(url: string): string {
const NON_WOFF2_FONT_EXT = /\.(woff|ttf|otf|eot)(\?|#|$)/i;
const WOFF2_EXT = /\.woff2(\?|#|$)/i;
async function inlineParsedCssUrls(
root: Root,
cssUrl: string,
fetchAsset: FetchAsset,
dropRefs: ReadonlySet<string>,
): Promise<{ failed: { url: string; reason: string }[]; inlined: string[] }> {
const failed: { url: string; reason: string }[] = [];
const inlined: string[] = [];
const refsByUrl = new Map<string, Map<string, string>>();
root.walkDecls((declaration) => {
for (const ref of cssUrlReferences(declaration.value)) {
const raw = ref.raw.trim();
if (/^(?:data:|blob:|about:|#)/i.test(raw) || dropRefs.has(raw)) continue;
try {
const resolved = new URL(raw, cssUrl);
const fragment = resolved.hash;
resolved.hash = '';
const fetchUrl = resolved.href;
const raws = refsByUrl.get(fetchUrl) ?? new Map<string, string>();
raws.set(raw, fragment);
refsByUrl.set(fetchUrl, raws);
} catch {
// Leave unresolvable values untouched for strict residual validation.
}
}
});
const replacements = new Map<string, string>();
await mapWithConcurrency([...refsByUrl.entries()], 8, async ([fetchUrl, raws]) => {
const got = await fetchAsset(fetchUrl);
if (got) {
const dataUri = toDataUri(got.bytes, got.contentType);
for (const [raw, fragment] of raws) replacements.set(raw, dataUri + fragment);
inlined.push(fetchUrl);
} else {
failed.push({ url: fetchUrl, reason: 'fetch failed' });
}
});
root.walkDecls((declaration) => {
declaration.value = rewriteCssValue(declaration.value, (raw) => {
const key = raw.trim();
if (replacements.has(key)) return replacements.get(key);
if (dropRefs.has(key)) return 'about:invalid';
return undefined;
});
});
return { failed, inlined };
}
/** Inline every url(...) inside a CSS text, resolving relative URLs against cssUrl.
*
* Woff2-preference optimisation: within any @font-face block that contains a
@@ -159,140 +258,269 @@ export async function inlineCssUrls(
css: string,
cssUrl: string,
fetchAsset: FetchAsset,
): Promise<{ css: string; failed: { url: string; reason: string }[] }> {
activeCssUrls: ReadonlySet<string> = new Set(),
): Promise<{ css: string; failed: { url: string; reason: string }[]; inlined: string[] }> {
const failed: { url: string; reason: string }[] = [];
const inlined: string[] = [];
const root = parseCss(css, cssUrl);
const imports: AtRule[] = [];
root.walkAtRules((rule) => {
if (rule.name.toLowerCase() === 'import') imports.push(rule);
});
for (const rule of imports) {
const reference = cssImportReference(rule);
if (!reference) continue;
const raw = reference.url.trim();
if (/^(?:data:|blob:|about:|#)/i.test(raw)) continue;
let abs: string;
try {
abs = new URL(raw, cssUrl).href;
} catch {
continue;
}
if (activeCssUrls.has(abs)) {
// Only imports on the current recursion path are cycles. Sibling imports
// of the same stylesheet may carry distinct media/layer/supports guards.
rule.remove();
continue;
}
const got = await fetchAsset(abs);
if (!got) {
failed.push({ url: abs, reason: 'fetch failed' });
continue;
}
const nested = await inlineCssUrls(
new TextDecoder().decode(got.bytes),
abs,
fetchAsset,
new Set(activeCssUrls).add(abs),
);
inlined.push(abs, ...nested.inlined);
failed.push(...nested.failed);
const wrapped = wrapImportedCss(nested.css, parseCssImportConditions(reference.conditions));
rule.replaceWith(...parseCss(wrapped, abs).nodes);
}
// 1. Find @font-face blocks; build dropRefs (non-woff2 fonts in blocks that have a woff2).
const dropRefs = new Set<string>();
for (const block of css.match(/@font-face\s*\{[^}]*\}/gi) ?? []) {
const blockUrls = [...block.matchAll(/url\(\s*(["']?)([^"')]+)\1\s*\)/gi)].map((m) =>
m[2].trim(),
);
root.walkAtRules((rule) => {
if (rule.name.toLowerCase() !== 'font-face') return;
const blockUrls: string[] = [];
rule.walkDecls((declaration) => {
blockUrls.push(...cssUrlReferences(declaration.value).map((ref) => ref.raw.trim()));
});
const hasWoff2 = blockUrls.some((u) => WOFF2_EXT.test(u) || /^data:font\/woff2/i.test(u));
if (!hasWoff2) continue;
if (!hasWoff2) return;
for (const u of blockUrls) {
if (!/^data:/i.test(u) && NON_WOFF2_FONT_EXT.test(u)) dropRefs.add(u);
}
}
// 2. Collect unique refs to FETCH (exclude data:, dropRefs, unresolvable).
const urlRe = /url\(\s*(["']?)([^"')]+)\1\s*\)/gi;
const uniqueRefs = new Map<string, string>(); // raw -> absolute url
for (const m of css.matchAll(urlRe)) {
const raw = m[2].trim();
if (/^data:/i.test(raw)) continue;
if (dropRefs.has(raw)) continue;
if (uniqueRefs.has(raw)) continue;
try {
uniqueRefs.set(raw, new URL(raw, cssUrl).href);
} catch {
// skip unresolvable
}
}
// 3. Fetch in parallel (bounded).
const replacements = new Map<string, string>();
const failed: { url: string; reason: string }[] = [];
const entries = [...uniqueRefs.entries()];
await mapWithConcurrency(entries, 8, async ([raw, abs]) => {
const got = await fetchAsset(abs);
if (got) {
replacements.set(raw, toDataUri(got.bytes, got.contentType));
} else {
failed.push({ url: abs, reason: 'fetch failed' });
}
});
// 4. Rewrite.
const rewritten = css.replace(urlRe, (full, _q, raw) => {
const key = String(raw).trim();
if (replacements.has(key)) return `url(${replacements.get(key)})`;
if (dropRefs.has(key)) return 'url(about:invalid)';
return full;
});
const rewritten = await inlineParsedCssUrls(root, cssUrl, fetchAsset, dropRefs);
failed.push(...rewritten.failed);
inlined.push(...rewritten.inlined);
return { css: rewritten, failed };
return { css: root.toString(), failed, inlined };
}
async function inlineStyleAttributeUrls(
css: string,
fetchAsset: FetchAsset,
): Promise<{ css: string; failed: { url: string; reason: string }[]; inlined: string[] }> {
const root = parseCss(`.openmaic-style{${css}}`, 'style-attribute');
const result = await inlineParsedCssUrls(root, 'about:blank', fetchAsset, new Set());
const serialized = root.toString();
return {
css: serialized.slice(serialized.indexOf('{') + 1, serialized.lastIndexOf('}')),
...result,
};
}
async function inlineSvgPresentationAttributeUrls(
attributeName: string,
cssValue: string,
fetchAsset: FetchAsset,
): Promise<{ cssValue: string; failed: { url: string; reason: string }[]; inlined: string[] }> {
const root = parseCss(
`.openmaic-svg{${attributeName}:${cssValue}}`,
'svg-presentation-attribute',
);
let declarationValue = cssValue;
const result = await inlineParsedCssUrls(root, 'about:blank', fetchAsset, new Set());
root.walkDecls((declaration) => {
if (declaration.prop === attributeName) declarationValue = declaration.value;
});
return { cssValue: declarationValue, ...result };
}
// ---------------------------------------------------------------------------
// inlineHtmlAssets — Task 4
// ---------------------------------------------------------------------------
async function replaceAsync(
input: string,
re: RegExp,
replacer: (...args: string[]) => Promise<string>,
): Promise<string> {
const matches = [...input.matchAll(re)];
// Process sequentially so the fetcher cache is populated before the next
// occurrence of the same URL is processed (dedup guarantee).
const replaced: string[] = [];
for (const m of matches) {
replaced.push(await replacer(...(m as unknown as string[])));
}
let result = '';
let last = 0;
matches.forEach((m, i) => {
result += input.slice(last, m.index!) + replaced[i];
last = m.index! + m[0].length;
});
return result + input.slice(last);
}
async function inlineImportmaps(
html: string,
fetchAsset: FetchAsset,
report: InlineReport,
keepFallbacks: boolean,
): Promise<string> {
// Collect inline module-script bodies (type="module", non-importmap, with a body).
const moduleBodies: string[] = [];
for (const m of html.matchAll(
/<script\b([^>]*)\btype\s*=\s*["']module["']([^>]*)>([\s\S]*?)<\/script>/gi,
)) {
if (m[3]?.trim()) moduleBodies.push(m[3]);
}
return await replaceAsync(
html,
/<script\b[^>]*type\s*=\s*["']importmap["'][^>]*>([\s\S]*?)<\/script>/gi,
async (full, json) => {
let parsed: { imports?: Record<string, string> };
try {
parsed = JSON.parse(json);
} catch {
return full;
// Collect both inline module bodies and external module sources. The latter
// must be inspected before the script is converted to a data URI, otherwise
// bare imports in an external module are invisible to importmap analysis.
const moduleScripts: Array<{ code: string; baseUrl?: string }> = [];
const inventory = analyzeHtmlAssetInventory(html);
for (const script of inventory.moduleScripts) {
const src = script.attributes.src;
if (src && /^https?:\/\//i.test(src)) {
const got = await fetchAsset(src);
if (!got) {
if (!report.failed.some((failure) => failure.url === src))
report.failed.push({ url: src, reason: 'fetch failed' });
continue;
}
const orig = parsed.imports ?? {};
const { imports: inlined, report: r } = await buildInlinedImportmap(
orig,
moduleBodies,
fetchAsset,
moduleScripts.push({ code: new TextDecoder().decode(got.bytes), baseUrl: src });
} else if (script.content.trim()) {
moduleScripts.push({ code: script.content, baseUrl: undefined });
}
}
const patches: SourcePatch[] = [];
for (const importmap of inventory.importmaps) {
if (!importmap.contentRange) continue;
let parsed: { imports?: Record<string, string>; [key: string]: unknown };
try {
parsed = JSON.parse(importmap.content);
} catch {
continue;
}
const orig = parsed.imports ?? {};
const { imports: inlined, report: r } = await buildInlinedImportmap(
orig,
moduleScripts,
fetchAsset,
);
for (const u of r.inlined) if (!report.inlined.includes(u)) report.inlined.push(u);
for (const f of r.failed)
if (!report.failed.some((g) => g.url === f.url)) report.failed.push(f);
// Merge: start from originals, overlay inlined data: entries.
// Merge: original prefix entries are retained as online fallback; inlined explicit
// data: entries take precedence for the modules we inlined. Keeping both is safe per
// the importmap spec (explicit specifier shadows prefix key) and strictly more correct:
// a sub-path not seen during static analysis can still resolve via the prefix online.
const merged: Record<string, string> = keepFallbacks ? { ...orig, ...inlined } : inlined;
patches.push({
range: importmap.contentRange,
replacement: JSON.stringify({ ...parsed, imports: merged }),
});
}
return applySourcePatches(html, patches);
}
function readImportmapImports(html: string): Record<string, string> {
const imports: Record<string, string> = {};
for (const importmap of analyzeHtmlAssetInventory(html).importmaps) {
try {
Object.assign(
imports,
(JSON.parse(importmap.content) as { imports?: Record<string, string> }).imports ?? {},
);
for (const u of r.inlined) if (!report.inlined.includes(u)) report.inlined.push(u);
for (const f of r.failed)
if (!report.failed.some((g) => g.url === f.url)) report.failed.push(f);
// Merge: start from originals, overlay inlined data: entries.
// Merge: original prefix entries are retained as online fallback; inlined explicit
// data: entries take precedence for the modules we inlined. Keeping both is safe per
// the importmap spec (explicit specifier shadows prefix key) and strictly more correct:
// a sub-path not seen during static analysis can still resolve via the prefix online.
const merged: Record<string, string> = { ...orig, ...inlined };
return `<script type="importmap">${JSON.stringify({ imports: merged })}</script>`;
},
} catch {
// Strict preparation reports malformed import maps separately.
}
}
return imports;
}
function unresolvedAssetUrls(html: string): string[] {
const inventory = analyzeHtmlAssetInventory(html);
const imports = readImportmapImports(html);
const unresolved = new Set(
collectAssetRefs(html, { includeRelative: true })
.filter((ref) => ref.kind !== 'module-import' || !resolveSpecifier(ref.url, imports))
.map((ref) => ref.url),
);
for (const importmap of inventory.importmaps) {
try {
JSON.parse(importmap.content);
} catch {
unresolved.add('malformed importmap');
}
}
return [...unresolved];
}
/**
* Scoped maps are intentionally rejected until module referrer URLs can be
* preserved through data-URI packaging; silently flattening them changes which
* dependency wins for modules under each scope.
*/
function unsupportedImportmapFeatures(html: string): string[] {
for (const importmap of analyzeHtmlAssetInventory(html).importmaps) {
try {
const parsed = JSON.parse(importmap.content) as { scopes?: unknown };
if (
parsed.scopes !== undefined &&
(typeof parsed.scopes !== 'object' ||
parsed.scopes === null ||
Array.isArray(parsed.scopes) ||
Object.keys(parsed.scopes).length > 0)
) {
return ['unsupported-importmap-scopes'];
}
} catch {
// Malformed import maps are reported by residual validation.
}
}
return [];
}
/** Rewrite direct URL/relative module dependencies to data URIs for offline use. */
async function inlineModuleSource(
code: string,
baseUrl: string | undefined,
imports: Record<string, string>,
fetchAsset: FetchAsset,
report: InlineReport,
): Promise<string> {
const built = await buildInlinedImportmap(imports, [{ code, baseUrl }], fetchAsset);
for (const url of built.report.inlined)
if (!report.inlined.includes(url)) report.inlined.push(url);
for (const failure of built.report.failed) {
if (!report.failed.some((existing) => existing.url === failure.url))
report.failed.push(failure);
}
return rewriteModuleSpecifiers(code, (specifier) => {
return !resolveSpecifier(specifier, imports) ? built.imports[specifier] : undefined;
});
}
export async function inlineHtmlAssets(
html: string,
options?: InlineOptions,
): Promise<{ html: string; report: InlineReport }> {
): Promise<{ html: string; report: InlineReport; unresolved: string[] }> {
const fetchAsset = options?.fetcher ?? createAssetFetcher(options);
const report: InlineReport = { inlined: [], failed: [] };
const unsupported = unsupportedImportmapFeatures(html);
if (unsupported.length > 0) return { html, report, unresolved: unsupported };
// Pre-warm non-importmap asset fetches in parallel so the sequential
// replaceAsync passes below hit a warm cache (fonts are parallelized
// inside inlineCssUrls; importmap modules are handled in buildInlinedImportmap).
// structured rewrite phases hit a warm cache (fonts are parallelized inside
// inlineCssUrls; importmap modules are handled in buildInlinedImportmap).
await Promise.all(
collectAssetRefs(html)
.filter((r) => r.kind !== 'importmap')
.map((r) => fetchAsset(r.url).catch(() => null)),
.filter(
(ref) =>
ref.kind !== 'importmap' &&
ref.kind !== 'iframe-src' &&
ref.kind !== 'iframe-srcdoc' &&
ref.kind !== 'object-data' &&
ref.kind !== 'embed-src',
)
.map((ref) => {
const url =
ref.kind === 'svg-image' || ref.kind === 'svg-use' || ref.kind === 'css-url'
? ref.url.split('#')[0]
: ref.url;
return fetchAsset(url).catch(() => null);
}),
);
let out = html;
@@ -303,79 +531,197 @@ export async function inlineHtmlAssets(
if (!report.failed.some((f) => f.url === url)) report.failed.push({ url, reason });
};
// 1) <link rel=stylesheet href> → <style> with nested url() inlined
out = await replaceAsync(
out,
/<link\b([^>]*?)\bhref\s*=\s*["'](https?:\/\/[^"']+)["']([^>]*)>/gi,
async (full, pre, url, post) => {
const isStylesheet = /rel\s*=\s*["']?stylesheet/i.test(pre + post);
if (!isStylesheet) return full;
const got = await fetchAsset(url);
const inlineAttributes = async (kinds: ReadonlySet<AssetRefKind>) => {
const patches: SourcePatch[] = [];
for (const asset of analyzeHtmlAssetInventory(out).attributeAssets) {
if (!kinds.has(asset.kind) || !HTTP_URL.test(asset.url)) continue;
const isSvgReference = asset.kind === 'svg-image' || asset.kind === 'svg-use';
const hashIndex = isSvgReference ? asset.url.indexOf('#') : -1;
const fetchUrl = hashIndex === -1 ? asset.url : asset.url.slice(0, hashIndex);
const fragment = hashIndex === -1 ? '' : asset.url.slice(hashIndex);
const got = await fetchAsset(fetchUrl);
if (!got) {
markFailed(url, 'fetch failed');
return full;
markFailed(fetchUrl, 'fetch failed');
continue;
}
const patch = replaceAttributePatch(asset, toDataUri(got.bytes, got.contentType) + fragment);
if (patch) patches.push(patch);
markInlined(fetchUrl);
}
out = applySourcePatches(out, patches);
};
// Resolve importmap entries before converting external module scripts. This
// lets the importmap walker inspect their source bodies and nested imports.
out = await inlineImportmaps(out, fetchAsset, report, options?.keepImportmapFallbacks !== false);
// 1) Real <link rel=stylesheet href> → <style> with nested url() inlined.
{
const patches: SourcePatch[] = [];
for (const asset of analyzeHtmlAssetInventory(out).attributeAssets) {
if (
asset.kind !== 'link' ||
!HTTP_URL.test(asset.url) ||
!asset.attributes.rel?.toLowerCase().split(/\s+/).includes('stylesheet') ||
!asset.elementRange
) {
continue;
}
const got = await fetchAsset(asset.url);
if (!got) {
markFailed(asset.url, 'fetch failed');
continue;
}
let cssText = new TextDecoder().decode(got.bytes);
const { css: rewritten, failed: cssFailed } = await inlineCssUrls(cssText, url, fetchAsset);
const {
css: rewritten,
failed: cssFailed,
inlined: cssInlined,
} = await inlineCssUrls(cssText, asset.url, fetchAsset);
cssText = rewritten;
for (const f of cssFailed) markFailed(f.url, f.reason);
const mediaMatch = /\bmedia\s*=\s*["']([^"']+)["']/i.exec(pre + post);
const mediaAttr = mediaMatch ? ` media="${mediaMatch[1].replace(/"/g, '&quot;')}"` : '';
markInlined(url);
return `<style data-inlined-from=""${mediaAttr}>${cssText}</style>`;
},
);
for (const inlined of cssInlined) markInlined(inlined);
const mediaAttr = asset.attributes.media
? ` media="${asset.attributes.media.replace(/"/g, '&quot;')}"`
: '';
markInlined(asset.url);
patches.push({
range: asset.elementRange,
replacement: `<style data-inlined-from=""${mediaAttr}>${cssText}</style>`,
});
}
out = applySourcePatches(out, patches);
}
// 2) <script src> (non-importmap) → data: URI src
out = await replaceAsync(
out,
/<script\b([^>]*?)\bsrc\s*=\s*["'](https?:\/\/[^"']+)["']([^>]*)>/gi,
async (full, pre, url, post) => {
const attrs = (pre + post).toLowerCase();
if (attrs.includes('importmap') || attrs.includes('application/json')) return full;
const got = await fetchAsset(url);
// 2) Real <script src> (non-importmap) → data: URI src.
{
const patches: SourcePatch[] = [];
for (const asset of analyzeHtmlAssetInventory(out).attributeAssets) {
if (asset.kind !== 'script' || !HTTP_URL.test(asset.url)) continue;
const got = await fetchAsset(asset.url);
if (!got) {
markFailed(url, 'fetch failed');
return full;
markFailed(asset.url, 'fetch failed');
continue;
}
markInlined(url);
return `<script${pre}src="${toDataUri(got.bytes, got.contentType)}"${post}>`;
},
);
const type = asset.attributes.type?.trim().toLowerCase() === 'module';
const source = new TextDecoder().decode(got.bytes);
const rewritten = type
? await inlineModuleSource(source, asset.url, readImportmapImports(out), fetchAsset, report)
: source;
const patch = replaceAttributePatch(
asset,
toDataUri(new TextEncoder().encode(rewritten), got.contentType),
);
if (patch) patches.push(patch);
markInlined(asset.url);
}
out = applySourcePatches(out, patches);
}
// 3) <img src> and <source src>
out = await replaceAsync(
out,
/<(img|source)\b([^>]*?)\bsrc\s*=\s*["'](https?:\/\/[^"']+)["']([^>]*)>/gi,
async (full, tag, pre, url, post) => {
const got = await fetchAsset(url);
if (!got) {
markFailed(url, 'fetch failed');
return full;
}
markInlined(url);
return `<${tag}${pre}src="${toDataUri(got.bytes, got.contentType)}"${post}>`;
},
);
// Inline module bodies can contain direct absolute imports or relative
// imports. Rewrite those too; bare specifiers remain governed by importmap.
{
const patches: SourcePatch[] = [];
for (const script of analyzeHtmlAssetInventory(out).moduleScripts) {
if (!script.contentRange || !script.content.trim()) continue;
const rewritten = await inlineModuleSource(
script.content,
undefined,
readImportmapImports(out),
fetchAsset,
report,
);
patches.push({ range: script.contentRange, replacement: rewritten });
}
out = applySourcePatches(out, patches);
}
// 4) url() inside authored <style> blocks (skip ones we created in step 1)
out = await replaceAsync(
out,
/<style\b([^>]*)>([\s\S]*?)<\/style>/gi,
async (full, attrs, body) => {
if (/data-inlined-from=/.test(attrs)) return full;
const { css: rewritten, failed: cssFailed } = await inlineCssUrls(
body,
'about:blank',
// 3) Real <img>/<source>/<video>/<audio> src attributes.
await inlineAttributes(new Set<AssetRefKind>(['img', 'source', 'video', 'audio']));
// 4) Responsive image candidates use the same offline guarantee as src.
{
const patches: SourcePatch[] = [];
for (const asset of analyzeHtmlAssetInventory(out).attributeAssets) {
if (asset.kind !== 'srcset') continue;
const rewritten = await Promise.all(
parseSrcset(asset.url).map(async (candidate) => {
if (!HTTP_URL.test(candidate.url)) return candidate;
const got = await fetchAsset(candidate.url);
if (!got) {
markFailed(candidate.url, 'fetch failed');
return candidate;
}
markInlined(candidate.url);
return { ...candidate, url: toDataUri(got.bytes, got.contentType) };
}),
);
const patch = replaceAttributePatch(asset, serializeSrcset(rewritten));
if (patch) patches.push(patch);
}
out = applySourcePatches(out, patches);
}
// 5) <video poster> is an external image resource too.
await inlineAttributes(new Set<AssetRefKind>(['poster', 'svg-image', 'svg-use']));
// 6) url() inside authored <style> blocks (skip ones we created in step 1).
{
const patches: SourcePatch[] = [];
for (const style of analyzeHtmlAssetInventory(out).styles) {
if (style.attributes['data-inlined-from'] !== undefined || !style.contentRange) continue;
const {
css: rewritten,
failed: cssFailed,
inlined: cssInlined,
} = await inlineCssUrls(style.content, 'about:blank', fetchAsset);
for (const f of cssFailed) markFailed(f.url, f.reason);
for (const inlined of cssInlined) markInlined(inlined);
patches.push({ range: style.contentRange, replacement: rewritten });
}
out = applySourcePatches(out, patches);
}
// 7) url() inside authored style attributes.
{
const patches: SourcePatch[] = [];
for (const style of analyzeHtmlAssetInventory(out).styleAttributes) {
if (!style.range) continue;
const {
css: rewritten,
failed: cssFailed,
inlined: cssInlined,
} = await inlineStyleAttributeUrls(style.css, fetchAsset);
for (const f of cssFailed) markFailed(f.url, f.reason);
for (const inlined of cssInlined) markInlined(inlined);
const patch = replaceAttributeRangePatch('style', style.range, rewritten);
if (patch) patches.push(patch);
}
out = applySourcePatches(out, patches);
}
// 8) url() inside SVG presentation attributes.
{
const patches: SourcePatch[] = [];
for (const attribute of analyzeHtmlAssetInventory(out).svgPresentationAttributes) {
if (!attribute.range) continue;
const {
cssValue,
failed: cssFailed,
inlined: cssInlined,
} = await inlineSvgPresentationAttributeUrls(
attribute.attributeName,
attribute.cssValue,
fetchAsset,
);
for (const f of cssFailed) markFailed(f.url, f.reason);
return `<style${attrs}>${rewritten}</style>`;
},
);
for (const failure of cssFailed) markFailed(failure.url, failure.reason);
for (const inlined of cssInlined) markInlined(inlined);
if (cssValue === attribute.cssValue) continue;
const patch = replaceAttributeRangePatch(attribute.attributeName, attribute.range, cssValue);
if (patch) patches.push(patch);
}
out = applySourcePatches(out, patches);
}
// 5) importmap (Task 5)
out = await inlineImportmaps(out, fetchAsset, report);
return { html: out, report };
return { html: out, report, unresolved: unresolvedAssetUrls(out) };
}
+10
View File
@@ -0,0 +1,10 @@
declare module 'parse-srcset' {
export interface SrcsetCandidate {
url: string;
w?: number;
d?: number;
h?: number;
}
export default function parseSrcset(value: string): SrcsetCandidate[];
}
+42
View File
@@ -0,0 +1,42 @@
import { parse, type DefaultTreeAdapterTypes } from 'parse5';
function isElement(
node: DefaultTreeAdapterTypes.ChildNode,
): node is DefaultTreeAdapterTypes.Element {
return !node.nodeName.startsWith('#');
}
function insertAt(html: string, offset: number, injection: string): string {
return html.slice(0, offset) + injection + html.slice(offset);
}
/** Inject markup at the start of the document's parsed head without rewriting authored HTML. */
export function injectIntoDocumentHead(html: string, injection: string): string {
const document = parse(html, { sourceCodeLocationInfo: true });
const htmlElement = document.childNodes.find(
(node): node is DefaultTreeAdapterTypes.Element => isElement(node) && node.tagName === 'html',
);
const headElement = htmlElement?.childNodes.find(
(node): node is DefaultTreeAdapterTypes.Element => isElement(node) && node.tagName === 'head',
);
const explicitHeadEnd = headElement?.sourceCodeLocation?.startTag?.endOffset;
if (explicitHeadEnd !== undefined) return insertAt(html, explicitHeadEnd, injection);
const firstHeadChildOffset = headElement?.childNodes.reduce<number | undefined>((first, node) => {
const offset = node.sourceCodeLocation?.startOffset;
if (offset === undefined) return first;
return first === undefined ? offset : Math.min(first, offset);
}, undefined);
if (firstHeadChildOffset !== undefined) return insertAt(html, firstHeadChildOffset, injection);
const explicitHtmlEnd = htmlElement?.sourceCodeLocation?.startTag?.endOffset;
if (explicitHtmlEnd !== undefined) {
return insertAt(html, explicitHtmlEnd, `<head>${injection}</head>`);
}
const doctype = document.childNodes.find((node) => node.nodeName === '#documentType');
const doctypeEnd = doctype?.sourceCodeLocation?.endOffset;
if (doctypeEnd !== undefined) return insertAt(html, doctypeEnd, `<head>${injection}</head>`);
return `<head>${injection}</head>${html}`;
}
+3 -18
View File
@@ -1,3 +1,5 @@
import { injectIntoDocumentHead } from './html-document';
/**
* In-memory localStorage/sessionStorage shim, injected as the FIRST thing in the
* document so the page's own scripts see working storage.
@@ -124,22 +126,5 @@ export function patchHtmlForIframe(html: string): string {
const injection = '\n' + ERROR_CAPTURE_SHIM + '\n' + STORAGE_SHIM + '\n' + iframeCss;
// Insert right after <head> or at the start of the document
const headIdx = html.indexOf('<head>');
if (headIdx !== -1) {
const insertPos = headIdx + 6; // after <head>
return html.substring(0, insertPos) + injection + html.substring(insertPos);
}
const headWithAttrs = html.indexOf('<head ');
if (headWithAttrs !== -1) {
const closeAngle = html.indexOf('>', headWithAttrs);
if (closeAngle !== -1) {
const insertPos = closeAngle + 1;
return html.substring(0, insertPos) + injection + html.substring(insertPos);
}
}
// Fallback: prepend
return injection + html;
return injectIntoDocumentHead(html, injection);
}
+20 -4
View File
@@ -77,7 +77,9 @@ function configuredVideoExportCta() {
* build and the subtitles-only path go through here so their timing/assets/
* geometry wiring can never drift.
*/
async function compileStageIr(options: { skipGeometry?: boolean } = {}): Promise<{
async function compileStageIr(
options: { skipGeometry?: boolean; skipInteractiveHtml?: boolean } = {},
): Promise<{
ir: ReturnType<typeof compileVideoTimeline>;
stageName: string;
scenes: ReturnType<typeof useStageStore.getState>['scenes'];
@@ -95,10 +97,16 @@ async function compileStageIr(options: { skipGeometry?: boolean } = {}): Promise
stage: { id: stage.id },
scenes,
skipGeometry: options.skipGeometry,
skipInteractiveHtml: options.skipInteractiveHtml,
});
const ir = compileVideoTimeline(
{ stage: { id: stage.id, name: stageName }, scenes },
{ timing: deps.timing, assets: deps.assets, geometry: deps.geometry },
{
timing: deps.timing,
assets: deps.assets,
geometry: deps.geometry,
interactive: deps.interactive,
},
);
return { ir, stageName, scenes, deps };
@@ -152,7 +160,12 @@ export async function buildExportZip(
const zipBlob = await packageVideoZip(project, blobs);
const errorCount = ir.diagnostics.filter((d) => d.severity !== 'info').length;
return { zipBlob, stageName, missingCount: missing.length, errorCount };
return {
zipBlob,
stageName,
missingCount: missing.length,
errorCount,
};
}
export function sanitizeFilename(name: string): string {
@@ -180,7 +193,10 @@ export interface CompiledSubtitles {
* so these cues match the ones the burned-in video would carry.
*/
export async function compileSubtitles(): Promise<CompiledSubtitles> {
const { ir, stageName } = await compileStageIr({ skipGeometry: true });
const { ir, stageName } = await compileStageIr({
skipGeometry: true,
skipInteractiveHtml: true,
});
return {
srt: toSrt(ir.subtitles),
+4
View File
@@ -417,6 +417,10 @@ export async function collectVideoAssets(
const bytes = await resolveBytes(record?.poster, record?.posterOssKey);
if (bytes) blobs.set(entry.path, bytes);
else missing.push(entry.path);
} else if (entry.kind === 'html') {
const html = records.interactiveHtml.content(entry.assetId);
if (html) blobs.set(entry.path, new Blob([html], { type: 'text/html;charset=utf-8' }));
else missing.push(entry.path);
}
} catch {
missing.push(entry.path);
+9 -2
View File
@@ -9,7 +9,7 @@ import ptBR from '@/lib/i18n/locales/pt-BR.json';
import ruRU from '@/lib/i18n/locales/ru-RU.json';
import zhCN from '@/lib/i18n/locales/zh-CN.json';
import zhTW from '@/lib/i18n/locales/zh-TW.json';
import type { CoverCardLabels, VideoExportCta } from '@/lib/video-export';
import type { VideoExportLabels, VideoExportCta } from '@/lib/video-export';
const DEFAULT_DESTINATION = 'open.maic.chat';
const MAX_RAW_DESTINATION_LENGTH = 96;
@@ -173,7 +173,7 @@ export function resolveVideoExportCta(raw: string | undefined): VideoExportCta |
}
/** Resolve every learner-facing cover label synchronously for one export locale. */
export function getVideoExportCoverLabels(locale: Locale): CoverCardLabels {
export function getVideoExportCoverLabels(locale: Locale): VideoExportLabels {
const resource = LOCALE_RESOURCES[locale];
const at = (key: string): string => {
const value = key.split('.').reduce<unknown>((current, part) => {
@@ -200,5 +200,12 @@ export function getVideoExportCoverLabels(locale: Locale): CoverCardLabels {
quizCtaPrompt: at('export.videoQuizCtaPrompt'),
pblCtaPrompt: at('export.videoPblCtaPrompt'),
ctaVisit: at('export.videoCtaVisit'),
interactive: {
fallback: at('export.videoFailed'),
readyTimeout: at('export.videoRendering'),
loadFailure: at('export.videoFailed'),
readyFailure: at('export.videoFailed'),
runtimeFailure: at('export.videoFailed'),
},
};
}
+3 -1
View File
@@ -57,7 +57,9 @@ export async function packageVideoZip(
// Collected binary assets under assets/<planPath>.
for (const [planPath, blob] of assetBlobs) {
zip.file(assetUrl(planPath), blob);
// 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());
}
// Vendored GSAP at the path the HTML loads it from.
@@ -0,0 +1,295 @@
'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 `
<meta data-openmaic-static-csp http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval' data: blob:; style-src 'unsafe-inline' data:; img-src data: blob:; font-src data:; media-src data: blob:; worker-src 'none'; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'">
<script data-openmaic-static-capture>
(function () {
var FLAG = ${flag};
var frozen = false;
var timeouts = new Set();
var intervals = new Set();
var rafs = new Set();
var nativeSetTimeout = window.setTimeout.bind(window);
var nativeClearTimeout = window.clearTimeout.bind(window);
var nativeSetInterval = window.setInterval.bind(window);
var nativeClearInterval = window.clearInterval.bind(window);
var nativeRaf = window.requestAnimationFrame.bind(window);
var nativeCancelRaf = window.cancelAnimationFrame.bind(window);
function disabledWorker() { throw new Error('interactive-worker-disabled'); }
try { window.Worker = disabledWorker; } catch (_) {}
try { window.SharedWorker = disabledWorker; } catch (_) {}
function post(kind, code, message) {
try {
var payload = {
kind: kind,
code: code,
message: String(message || '').slice(0, 1200)
};
payload[FLAG] = true;
window.parent.postMessage(payload, '*');
} catch (_) {}
}
window.setTimeout = function (fn, delay) {
if (frozen) return 0;
var args = Array.prototype.slice.call(arguments, 2);
var id = nativeSetTimeout(function () {
timeouts.delete(id);
if (!frozen) {
if (typeof fn === 'function') fn.apply(window, args);
else Function(String(fn))();
}
}, delay);
timeouts.add(id);
return id;
};
window.clearTimeout = function (id) { timeouts.delete(id); nativeClearTimeout(id); };
window.setInterval = function (fn, delay) {
if (frozen) return 0;
var args = Array.prototype.slice.call(arguments, 2);
var id = nativeSetInterval(function () {
if (!frozen) {
if (typeof fn === 'function') fn.apply(window, args);
else Function(String(fn))();
}
}, delay);
intervals.add(id);
return id;
};
window.clearInterval = function (id) { intervals.delete(id); nativeClearInterval(id); };
window.requestAnimationFrame = function (fn) {
if (frozen) return 0;
var id = nativeRaf(function (time) {
rafs.delete(id);
if (!frozen) fn(time);
});
rafs.add(id);
return id;
};
window.cancelAnimationFrame = function (id) { rafs.delete(id); nativeCancelRaf(id); };
function waitForImages() {
return Promise.all(Array.from(document.images || []).map(function (img) {
if (img.complete) {
if ((img.currentSrc || img.src || img.getAttribute('srcset')) && img.naturalWidth === 0)
return Promise.reject(new Error('interactive-image-load-failure'));
return typeof img.decode === 'function' ? img.decode() : Promise.resolve();
}
return new Promise(function (resolve, reject) {
img.addEventListener('load', resolve, { once: true });
img.addEventListener(
'error',
function () { reject(new Error('interactive-image-load-failure')); },
{ once: true },
);
});
}));
}
function waitForVideos() {
return Promise.all(Array.from(document.querySelectorAll('video')).map(function (video) {
if (!video.currentSrc && !video.getAttribute('src') && !video.querySelector('source')) return Promise.resolve();
if (video.readyState >= 2) return Promise.resolve();
return new Promise(function (resolve, reject) {
video.addEventListener('loadeddata', resolve, { once: true });
video.addEventListener(
'error',
function () { reject(new Error('interactive-video-load-failure')); },
{ once: true },
);
});
}));
}
function freeze() {
if (frozen) return;
frozen = true;
timeouts.forEach(nativeClearTimeout); timeouts.clear();
intervals.forEach(nativeClearInterval); intervals.clear();
rafs.forEach(nativeCancelRaf); rafs.clear();
try { document.getAnimations().forEach(function (animation) { animation.pause(); }); } catch (_) {}
Array.from(document.querySelectorAll('video,audio')).forEach(function (media) {
try { media.pause(); } catch (_) {}
});
var style = document.createElement('style');
style.setAttribute('data-openmaic-static-frozen', '');
style.textContent = '*,*::before,*::after{animation-play-state:paused!important;transition:none!important;caret-color:transparent!important}';
(document.head || document.documentElement).appendChild(style);
document.documentElement.setAttribute('data-openmaic-static-state', 'frozen');
}
window.__openmaicFreezeInteractive = freeze;
window.addEventListener('load', function () {
var fonts = document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve();
var readiness = Promise.all([fonts, waitForImages(), waitForVideos()]);
var deadlineTimer;
var deadline = new Promise(function (_, reject) {
deadlineTimer = nativeSetTimeout(function () { reject(new Error('interactive-readiness-timeout')); }, ${internalTimeoutMs});
});
Promise.race([readiness, deadline])
.then(function () {
nativeClearTimeout(deadlineTimer);
return new Promise(function (resolve) { nativeSetTimeout(resolve, ${settleMs}); });
})
.then(function () { freeze(); post('frozen', 'interactive-static-ready', 'ready'); })
.catch(function (error) {
nativeClearTimeout(deadlineTimer);
freeze();
post('failure', 'interactive-ready-failure', error && error.message || error);
});
}, { once: true });
})();
</script>`;
}
/** 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<string> {
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<string, InteractiveHtmlMeta>,
private readonly byAssetId: ReadonlyMap<string, string>,
) {}
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<PreparedInteractiveHtmlSet> {
const bySceneId = new Map<string, InteractiveHtmlMeta>();
const byAssetId = new Map<string, string>();
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;
}
}
+24 -3
View File
@@ -26,11 +26,22 @@ import type {
SpotlightAction,
LaserAction,
} from '@openmaic/dsl';
import type { AssetMeta, AssetSource, GeometryProbe, TimingProbe } from '@/lib/video-export';
import type {
AssetMeta,
AssetSource,
GeometryProbe,
InteractiveHtmlSource,
TimingProbe,
} from '@/lib/video-export';
import type { Scene, SlideContent } from '@/lib/types/stage';
import { isMediaPlaceholder } from '@/lib/store/media-generation';
import { measureSlideElementGeometry, type MeasuredGeometry } from '@openmaic/renderer/snapshot';
import { db, type AudioFileRecord, type MediaFileRecord } from '@/lib/utils/database';
import {
emptyPreparedInteractiveHtmlSet,
prepareInteractiveHtmlScenes,
type PreparedInteractiveHtmlSet,
} from './prepare-interactive-html';
import { useMediaGenerationStore } from '@/lib/store/media-generation';
import { resolveVideoMediaForElement } from '@/lib/media/media-task-resolution';
import { resolveAudioBlob } from '@/lib/media/resolve-audio-bytes';
@@ -43,12 +54,15 @@ export interface VideoTimelineRecords {
mediaByElementId: Map<string, MediaFileRecord>;
/** Probed video durations (ms) by `elementId`; absent when unprobeable. */
videoDurationMsByElementId: Map<string, number>;
/** Prepared self-contained HTML pages, addressable by asset id. */
interactiveHtml: PreparedInteractiveHtmlSet;
}
export interface VideoTimelineDeps {
timing: TimingProbe;
assets: AssetSource;
geometry: GeometryProbe;
interactive: InteractiveHtmlSource;
records: VideoTimelineRecords;
}
@@ -198,8 +212,14 @@ export async function createVideoTimelineDeps(input: {
* the burned-in video. Defaults to false (full geometry for the ZIP/render).
*/
skipGeometry?: boolean;
/** Skip HTML inlining for subtitle-only compilation. */
skipInteractiveHtml?: boolean;
}): Promise<VideoTimelineDeps> {
const { stage, scenes, skipGeometry = false } = input;
const { stage, scenes, skipGeometry = false, skipInteractiveHtml = false } = input;
const interactiveHtml = skipInteractiveHtml
? emptyPreparedInteractiveHtmlSet()
: await prepareInteractiveHtmlScenes(scenes);
// Audio: load only the records referenced by speech actions.
const audioIds = new Set<string>();
@@ -409,6 +429,7 @@ export async function createVideoTimelineDeps(input: {
timing,
assets,
geometry,
records: { audioById, mediaByElementId, videoDurationMsByElementId },
interactive: interactiveHtml,
records: { audioById, mediaByElementId, videoDurationMsByElementId, interactiveHtml },
};
}
+18 -8
View File
@@ -3,7 +3,7 @@
*
* Composes the ordered passes into the `VideoTimeline` IR:
*
* normalize → probe → timeline → visuals → geometry → assets → (unsupported) → assemble
* normalize → probe → timeline → visuals → interactive → geometry → assets → (unsupported) → assemble
*
* Every pass is a pure function; live app state enters only through the injected
* {@link TimingProbe} / {@link AssetSource} (issue #864 DI boundary), so the whole
@@ -33,6 +33,7 @@ import { normalizeScenes } from './passes/normalize';
import { buildTimelineOptions } from './passes/probe';
import { buildTimeline } from './passes/timeline';
import { applyVisuals } from './passes/visuals';
import { applyInteractiveHtml } from './passes/interactive';
import { applyGeometry } from './passes/geometry';
import { planAssets } from './passes/assets';
@@ -53,6 +54,8 @@ export interface CompileDeps {
* the deterministic authored-box calc.
*/
geometry?: GeometryProbe;
/** Optional prepared-HTML adapter; omitted callers degrade interactive scenes. */
interactive?: import('./deps').InteractiveHtmlSource;
config?: CompileConfig;
}
@@ -60,7 +63,7 @@ export interface CompileDeps {
function unsupportedReason(type: SceneType): string {
switch (type) {
case 'interactive':
return 'Interactive/widget scenes require runtime playback; represented by markers in this slice.';
return 'Interactive HTML was not prepared for static rendering; represented by markers.';
default:
return 'This scene family is preserved as markers but is not rendered by this compiler slice.';
}
@@ -77,7 +80,10 @@ function markUnsupported(
): VideoTimelineScene[] {
return scenes.map((scene) => {
if (scene.supported) return scene;
const reason = unsupportedReason(scene.type);
const reason =
scene.base.kind === 'placeholder' && scene.base.reason
? scene.base.reason
: unsupportedReason(scene.type);
diagnostics.push({
severity: 'warn',
code: 'unsupported-scene',
@@ -152,14 +158,17 @@ export function compileVideoTimeline(input: CompileInput, deps: CompileDeps): Vi
// 4. visuals — turn Quiz/PBL authored data into whole-scene static covers.
const visuals = applyVisuals(timeline.scenes, normalized.scenes);
// 5. geometry — resolve effect + video element placement (degrade on miss).
// Prefers measured content-box geometry when a GeometryProbe is supplied.
const geometry = applyGeometry(visuals.scenes, normalized.scenes, deps.geometry);
// 5. interactive HTML — promote successfully prepared pages to a first-class base.
const interactive = applyInteractiveHtml(visuals.scenes, normalized.scenes, deps.interactive);
// 6. assets — dedup + naming plan; stamp asset refs onto segments.
// 6. geometry — resolve effect + video element placement (degrade on miss).
// Prefers measured content-box geometry when a GeometryProbe is supplied.
const geometry = applyGeometry(interactive.scenes, normalized.scenes, deps.geometry);
// 7. assets — dedup + naming plan; stamp asset refs onto segments.
const assets = planAssets(normalized.scenes, geometry.scenes, deps.assets);
// 7. remaining unsupported scene families → markers + diagnostics.
// 8. remaining unsupported scene families → markers + diagnostics.
const unsupportedDiagnostics: Diagnostic[] = [];
const scenes = markUnsupported(assets.scenes, unsupportedDiagnostics);
@@ -167,6 +176,7 @@ export function compileVideoTimeline(input: CompileInput, deps: CompileDeps): Vi
...normalized.diagnostics,
...timeline.diagnostics,
...visuals.diagnostics,
...interactive.diagnostics,
...geometry.diagnostics,
...assets.diagnostics,
...unsupportedDiagnostics,
+21
View File
@@ -25,6 +25,7 @@ import type {
PPTElement,
} from '@openmaic/dsl';
import type { PercentageGeometry } from './ir';
import type { InteractiveHtmlFailure } from './interactive-static';
/**
* The compiler's scene input — the structural slice it reads. Deliberately
@@ -42,6 +43,8 @@ export interface CompilerSceneContent {
/** App-owned PBL payloads enter as unknown and are narrowed by the pure visual pass. */
projectV2?: unknown;
projectConfig?: unknown;
/** Embedded interactive HTML; prepared by the app-side adapter before compile. */
html?: string;
}
export type CompilerScene = SceneCore & {
@@ -102,6 +105,24 @@ export interface AssetSource {
media(elementId: string, scene: SceneCore): AssetMeta | null;
}
/** Prepared interactive HTML metadata exposed synchronously to the pure compiler. */
export interface InteractiveHtmlMeta {
/** Stable id used by the asset plan and byte collector. */
id: string;
present: boolean;
/** SHA-256 of the exact packaged HTML, present only on success. */
contentHash?: string;
/** Stable failure category when `present` is false. */
failure?: InteractiveHtmlFailure;
/** Bounded, human-readable detail for the export report. */
message?: string;
}
/** Synchronous adapter over HTML that the app prepared before pure compilation. */
export interface InteractiveHtmlSource {
html(scene: SceneCore): InteractiveHtmlMeta | null;
}
/**
* Geometry source — resolves a slide element's **rendered** percentage geometry
* (0–100 space) so spotlight/laser/video placement matches where the element
+200 -28
View File
@@ -28,7 +28,9 @@ import type {
VideoTimelineScene,
VisualSegment,
} from '../ir';
import { INTERACTIVE_STATIC_MESSAGE_FLAG } from '../interactive-static';
import { emitManifestJson } from '../passes/emit';
import { RUNTIME_DIAGNOSTIC_CODES } from '../runtime-diagnostics';
import { toSrt, toVtt } from '../subtitles';
import { EASE_DEFS, emitEffect } from './effects';
import { escapeHtml, sec } from './format';
@@ -46,14 +48,27 @@ export interface VideoExportCta {
destination: string;
}
export interface InteractiveFallbackLabels {
/** Localized fallback copy shown when a static interactive page cannot load. */
fallback: string;
/** Localized readiness-timeout copy shown in the static fallback. */
readyTimeout: string;
/** Localized load-failure copy shown in the static fallback. */
loadFailure: string;
/** Localized readiness-failure copy shown in the static fallback. */
readyFailure: string;
/** Localized runtime-failure copy shown in the static fallback. */
runtimeFailure: string;
}
/**
* Learner-facing chrome on the Quiz/PBL cover cards — everything on the card
* Learner-facing chrome on exported video cards and fallback scenes — everything
* that is *not* authored scene data. The defaults are the `en-US` values of the
* very i18n keys the live QuizView/PBL Hero use; the app passes the classroom's
* active locale so an exported video reads like the lesson it came from. Kept as
* injected strings (not an i18n import) so the emitter stays pure.
*/
export interface CoverCardLabels {
export interface VideoExportLabels {
/** `quiz.title` — the Quiz card's eyebrow. */
quiz: string;
/** `quiz.questionsCount` — unit after the question count. */
@@ -82,8 +97,14 @@ export interface CoverCardLabels {
pblCtaPrompt: string;
/** Verb preceding the configured destination. */
ctaVisit: string;
/** Localized fallback and failure copy for static interactive scenes. */
interactive: InteractiveFallbackLabels;
}
type VideoExportLabelOverrides = Partial<Omit<VideoExportLabels, 'interactive'>> & {
interactive?: Partial<InteractiveFallbackLabels>;
};
export interface EmitHyperframesOptions {
/** Render width in px. Default 1920. Height is derived from the IR's 16:9 aspect. */
width?: number;
@@ -96,7 +117,7 @@ export interface EmitHyperframesOptions {
/** Manifest filename. Default `openmaic-video-manifest.json`. */
manifestPath?: string;
/** Cover-card chrome; each omitted key falls back to its `en-US` default. */
labels?: Partial<CoverCardLabels>;
labels?: VideoExportLabelOverrides;
/** Informational destination for Quiz/PBL covers. Omitted or null disables it. */
cta?: VideoExportCta | null;
/**
@@ -105,7 +126,7 @@ export interface EmitHyperframesOptions {
* right-to-left direction is scoped to text-bearing cover panels because
* Hyperframes cannot safely render a document-level RTL direction. The locale
* is recorded in the project README so a re-render can reproduce this exact
* output. Default `en-US`, matching {@link DEFAULT_COVER_LABELS}.
* output. Default `en-US`, matching {@link DEFAULT_VIDEO_EXPORT_LABELS}.
*/
locale?: string;
/**
@@ -140,7 +161,7 @@ function isRtl(locale: string): boolean {
return RTL_LANGUAGES.has(locale.split('-')[0].toLowerCase());
}
const DEFAULT_COVER_LABELS: CoverCardLabels = {
const DEFAULT_VIDEO_EXPORT_LABELS: VideoExportLabels = {
quiz: 'Quiz',
questions: 'questions',
points: 'pts',
@@ -155,6 +176,13 @@ const DEFAULT_COVER_LABELS: CoverCardLabels = {
quizCtaPrompt: 'Want to try an interactive quiz?',
pblCtaPrompt: 'Want to explore project-based learning?',
ctaVisit: 'Visit',
interactive: {
fallback: 'interactive-static-fallback',
readyTimeout: 'interactive-ready-timeout',
loadFailure: 'interactive-load-failure',
readyFailure: 'interactive-ready-failure',
runtimeFailure: 'interactive-runtime-failure',
},
};
/**
@@ -171,8 +199,22 @@ export function assetUrl(planPath: string): string {
return `${ASSETS_DIR}/${planPath}`;
}
/** The base layer for one scene: a slide-snapshot `<img>` clip, or a true unsupported placeholder. */
function renderBase(scene: VideoTimelineScene): string {
function placeholderContent(scene: VideoTimelineScene, reason: string, reasonAttrs = ''): string {
const reasonAttributeText = reasonAttrs ? ` ${reasonAttrs}` : '';
return [
`<div style="position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;background:#0f172a;color:#e2e8f0;font-family:system-ui,sans-serif;text-align:center;padding:8%">`,
` <div dir="auto" style="font-size:2.2vw;font-weight:700">${escapeHtml(scene.title)}</div>`,
reason
? ` <div${reasonAttributeText} dir="auto" style="font-size:1.2vw;color:#94a3b8;max-width:70%">${escapeHtml(reason)}</div>`
: '',
`</div>`,
]
.filter(Boolean)
.join('\n');
}
/** The base layer for one scene: snapshot, packaged frozen HTML, or placeholder. */
function renderBase(scene: VideoTimelineScene, labels: VideoExportLabels): string {
const start = sec(scene.startMs);
const duration = sec(scene.durationMs);
const id = `scene-${scene.index + 1}-base`;
@@ -181,17 +223,128 @@ function renderBase(scene: VideoTimelineScene): string {
return `<img ${clip} src="${escapeHtml(assetUrl(scene.base.assetRef))}" alt="" style="position:absolute;left:0;top:0;width:100%;height:100%;object-fit:contain" />`;
}
if (scene.base.kind === 'visual-segments') return '';
const reason = scene.base.reason ? escapeHtml(scene.base.reason) : '';
return [
`<div ${clip} style="position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;background:#0f172a;color:#e2e8f0;font-family:system-ui,sans-serif;text-align:center;padding:8%">`,
` <div dir="auto" style="font-size:2.2vw;font-weight:700">${escapeHtml(scene.title)}</div>`,
reason
? ` <div dir="auto" style="font-size:1.2vw;color:#94a3b8;max-width:70%">${reason}</div>`
: '',
`</div>`,
]
.filter(Boolean)
.join('\n');
if (scene.base.kind === 'interactive-html' && scene.base.assetRef) {
const fallback = placeholderContent(
scene,
labels.interactive.fallback,
'data-interactive-fallback-reason',
);
return [
`<div ${clip} data-interactive-static-host data-scene-id="${escapeHtml(scene.id)}" data-ready-timeout-ms="${scene.base.readyTimeoutMs}" data-content-hash="${escapeHtml(scene.base.contentHash)}" style="position:absolute;inset:0;background:#0f172a">`,
` <div data-interactive-fallback>${fallback}</div>`,
` <iframe data-interactive-static-frame data-src="${escapeHtml(assetUrl(scene.base.assetRef))}" title="${escapeHtml(scene.title)}" sandbox="allow-scripts" style="position:absolute;inset:0;width:100%;height:100%;border:0;visibility:hidden;pointer-events:none;background:#fff"></iframe>`,
`</div>`,
].join('\n');
}
const reason =
scene.type === 'interactive'
? labels.interactive.fallback
: scene.base.kind === 'placeholder'
? (scene.base.reason ?? '')
: '';
return `<div ${clip}>${placeholderContent(scene, reason)}</div>`;
}
/** Parent-side readiness/fallback bridge for every packaged interactive iframe. */
function interactiveStaticBridgeScript(labels: InteractiveFallbackLabels): string {
const flag = JSON.stringify(INTERACTIVE_STATIC_MESSAGE_FLAG);
const localized = JSON.stringify(labels);
const diagnosticCodes = JSON.stringify(RUNTIME_DIAGNOSTIC_CODES);
return `
function initializeOpenMaicInteractiveStaticFrames() {
var hosts = Array.from(document.querySelectorAll('[data-interactive-static-host]'));
window.__openmaicVideoDiagnostics = window.__openmaicVideoDiagnostics || [];
window.__openmaicVideoManifest = window.__openmaicVideoManifest || { runtimeDiagnostics: [] };
var labels = ${localized};
var diagnosticCodes = new Set(${diagnosticCodes});
var runtimeReport = document.querySelector('[data-openmaic-runtime-diagnostics]');
function record(sceneId, code, message) {
var normalizedCode = diagnosticCodes.has(code) ? code : 'interactive-ready-failure';
var diagnostic = { sceneId: sceneId, code: normalizedCode, message: String(message || '').slice(0, 1200) };
window.__openmaicVideoDiagnostics.push(diagnostic);
window.__openmaicVideoManifest.runtimeDiagnostics = window.__openmaicVideoDiagnostics.slice();
if (runtimeReport) runtimeReport.textContent = JSON.stringify(window.__openmaicVideoDiagnostics);
console.error('interactive-static-diagnostic', diagnostic);
}
function messageFor(code, detail) {
var prefix = code === 'interactive-load-failure' ? labels.loadFailure
: code === 'interactive-ready-timeout' || code === 'interactive-load-timeout' ? labels.readyTimeout
: code === 'interactive-runtime-failure' ? labels.runtimeFailure
: labels.readyFailure;
return detail && detail !== 'ready' ? prefix + ': ' + detail : prefix;
}
return Promise.all(hosts.map(function (host) {
return new Promise(function (resolve) {
var sceneId = host.getAttribute('data-scene-id') || 'interactive';
var timeoutMs = Number(host.getAttribute('data-ready-timeout-ms')) || 8000;
var iframe = host.querySelector('[data-interactive-static-frame]');
var fallback = host.querySelector('[data-interactive-fallback]');
var reason = host.querySelector('[data-interactive-fallback-reason]');
var loaded = false;
var settled = false;
var runtimeErrors = [];
function finish(ok, code, message) {
if (settled) return;
settled = true;
clearTimeout(timer);
window.removeEventListener('message', onMessage);
if (ok) {
iframe.style.visibility = 'visible';
fallback.style.display = 'none';
host.setAttribute('data-interactive-static-state', 'frozen');
} else {
iframe.style.visibility = 'hidden';
fallback.style.display = 'block';
if (reason) reason.textContent = message;
host.setAttribute('data-interactive-static-state', 'fallback');
host.setAttribute('data-interactive-diagnostic', code);
record(sceneId, code, message);
iframe.remove();
}
resolve({ sceneId: sceneId, ok: ok, code: code });
}
function onMessage(event) {
if (event.source !== iframe.contentWindow) return;
var data = event.data || {};
if (data.__maicInteractive === true && data.kind === 'runtime-error') {
runtimeErrors.push('[' + (data.errorKind || 'error') + '] ' + String(data.message || 'runtime error'));
return;
}
if (data[${flag}] !== true) return;
if (data.kind === 'failure') {
var failureCode = data.code || 'interactive-ready-failure';
finish(false, failureCode, messageFor(failureCode, data.message));
} else if (data.kind === 'frozen') {
if (runtimeErrors.length > 0) {
finish(false, 'interactive-runtime-failure', messageFor('interactive-runtime-failure', runtimeErrors[0]));
} else {
finish(true, 'interactive-static-ready', 'ready');
}
}
}
window.addEventListener('message', onMessage);
iframe.addEventListener('load', function () {
loaded = true;
try { iframe.contentWindow.postMessage({ __maicErrorReplayRequest: true }, '*'); } catch (_) {}
}, { once: true });
iframe.addEventListener('error', function () {
finish(false, 'interactive-load-failure', messageFor('interactive-load-failure'));
}, { once: true });
var timer = setTimeout(function () {
finish(
false,
loaded ? 'interactive-ready-timeout' : 'interactive-load-timeout',
messageFor(loaded ? 'interactive-ready-timeout' : 'interactive-load-timeout')
);
}, timeoutMs);
iframe.setAttribute('src', iframe.getAttribute('data-src'));
});
}));
}
`;
}
/**
@@ -232,7 +385,7 @@ function renderQuizCover(
scene: VideoTimelineScene,
visual: QuizCoverVisual,
index: number,
labels: CoverCardLabels,
labels: VideoExportLabels,
cta: VideoExportCta | null,
direction: 'ltr' | 'rtl',
): string {
@@ -274,7 +427,7 @@ function renderPblCover(
scene: VideoTimelineScene,
visual: PblCoverVisual,
index: number,
labels: CoverCardLabels,
labels: VideoExportLabels,
plan: PblCoverPlan,
cta: VideoExportCta | null,
direction: 'ltr' | 'rtl',
@@ -338,7 +491,7 @@ function renderPblCover(
/** Render first-class track-0 visuals independently from the scene base. */
function renderVisuals(
scene: VideoTimelineScene,
labels: CoverCardLabels,
labels: VideoExportLabels,
frame: { width: number; height: number; burnInSubtitles: boolean },
cta: VideoExportCta | null,
direction: 'ltr' | 'rtl',
@@ -647,7 +800,7 @@ interface PblCoverPlan {
* alongside the authored text: an eyebrow or section heading that wraps in one
* language and not another moves the whole card down with it.
*/
function pblPlanHeight(title: string, plan: PblCoverPlan, labels: CoverCardLabels): number {
function pblPlanHeight(title: string, plan: PblCoverPlan, labels: VideoExportLabels): number {
const gainColumn = (PANEL_CONTENT_WIDTH - GAIN_ROW_GAP) / 2 - 44;
const gainRows = Math.ceil(plan.gains.length / 2);
const tallestGainRow = Math.max(1, ...plan.gains.map((g) => lineCount(g, 13, gainColumn, 2)));
@@ -692,7 +845,7 @@ function pblPlanHeight(title: string, plan: PblCoverPlan, labels: CoverCardLabel
*/
function planPblCover(
visual: PblCoverVisual,
labels: CoverCardLabels,
labels: VideoExportLabels,
frame: {
width: number;
height: number;
@@ -830,7 +983,7 @@ function renderReadme(project: {
stageName: string;
locale: string;
burnInSubtitles: boolean;
labels: CoverCardLabels;
labels: VideoExportLabels;
cta: VideoExportCta | null;
}): string {
const seconds = (project.totalDurationMs / 1000).toFixed(1);
@@ -865,8 +1018,9 @@ folder — no network access, no CDN.
- \`index.html\` — the composition (one data-composition-id=${optionValue(project.compositionId)} stage, one paused GSAP timeline on \`window.__timelines\`).
- ${optionValue(project.manifestPath)} — the \`VideoTimeline\` manifest / export report (scenes, timing, assets, diagnostics).
- Runtime interactive diagnostics are exposed live through \`window.__openmaicVideoManifest.runtimeDiagnostics\` and the machine-readable DOM report in \`index.html\`.
- \`subtitles.srt\` / \`subtitles.vtt\` — narration subtitles.
- \`assets/frames\`, \`assets/audio\`, \`assets/media\` — slide snapshots, narration audio, embedded video clips.
- \`assets/frames\`, \`assets/audio\`, \`assets/media\`, \`assets/interactive\` — slide snapshots, narration audio, embedded video clips, frozen interactive pages.
- ${optionValue(project.gsapVendorPath)} — vendored GSAP (determinism: no CDN at render time).
- \`LICENSES/Inter-OFL-1.1.txt\` — license for the font embedded in \`index.html\`.
@@ -923,10 +1077,18 @@ export function emitHyperframes(
const compositionId = options.compositionId ?? 'openmaic';
const gsapVendorPath = options.gsapVendorPath ?? DEFAULT_GSAP_PATH;
const manifestPath = options.manifestPath ?? DEFAULT_MANIFEST;
const labels: CoverCardLabels = { ...DEFAULT_COVER_LABELS, ...options.labels };
const labels: VideoExportLabels = {
...DEFAULT_VIDEO_EXPORT_LABELS,
...options.labels,
interactive: {
...DEFAULT_VIDEO_EXPORT_LABELS.interactive,
...options.labels?.interactive,
},
};
const cta = options.cta ?? null;
const locale = options.locale ?? DEFAULT_LOCALE;
const totalSec = sec(ir.totalDurationMs);
const hasInteractiveHtml = ir.scenes.some((scene) => scene.base.kind === 'interactive-html');
const sceneHtml: string[] = [];
const effectHtml: string[] = [];
@@ -934,7 +1096,7 @@ export function emitHyperframes(
for (const scene of ir.scenes) {
sceneHtml.push(`<!-- scene ${scene.index + 1}: ${escapeHtml(scene.title)} -->`);
sceneHtml.push(renderBase(scene));
sceneHtml.push(renderBase(scene, labels));
sceneHtml.push(
...renderVisuals(
scene,
@@ -991,14 +1153,24 @@ ${coverCardCss(width)}
${sceneHtml.filter(Boolean).join('\n')}
${effectHtml.join('\n')}
${subtitles.html}
<script type="application/json" data-openmaic-runtime-diagnostics>[]</script>
</div>
<script src="${escapeHtml(gsapVendorPath)}"></script>
<script>
${EASE_DEFS}
var tl = gsap.timeline({ paused: true });
${statements.join('\n')}
window.__openmaicVideoManifest = { runtimeDiagnostics: [], manifestPath: ${JSON.stringify(manifestPath)} };
window.__timelines = window.__timelines || {};
window.__timelines[${JSON.stringify(compositionId)}] = tl;
${
hasInteractiveHtml
? `${interactiveStaticBridgeScript(labels.interactive)}
window.__openmaicInteractiveReady = initializeOpenMaicInteractiveStaticFrames();
window.__openmaicInteractiveReady.then(function () {
window.__timelines[${JSON.stringify(compositionId)}] = tl;
});`
: `window.__timelines[${JSON.stringify(compositionId)}] = tl;`
}
</script>
</body>
</html>
+11 -2
View File
@@ -22,11 +22,14 @@
export * from './ir';
export * from './deps';
export * from './geometry';
export * from './interactive-static';
export * from './runtime-diagnostics';
export { compileVideoTimeline, type CompileInput, type CompileDeps } from './compile';
export { normalizeScenes, type NormalizeResult } from './passes/normalize';
export { buildTimelineOptions } from './passes/probe';
export { buildTimeline, type TimelineResult } from './passes/timeline';
export { applyVisuals, type VisualsResult } from './passes/visuals';
export { applyInteractiveHtml, type InteractiveResult } from './passes/interactive';
export {
applyGeometry,
resolveEffectGeometry,
@@ -34,7 +37,12 @@ export {
type GeometryResult,
} from './passes/geometry';
export { planAssets, sanitizeFilenamePart, type AssetsResult } from './passes/assets';
export { emitManifest, emitManifestJson } from './passes/emit';
export {
emitManifest,
emitManifestJson,
VideoExportManifestSchema,
type VideoExportManifest,
} from './passes/emit';
export { toSrt, toVtt, usableCues } from './subtitles';
export { splitCue, splitCues, splitCueText, textUnits } from './split-cue';
export {
@@ -44,6 +52,7 @@ export {
type EmitHyperframesOptions,
type EmittedProject,
type EmittedFile,
type CoverCardLabels,
type VideoExportLabels,
type InteractiveFallbackLabels,
type VideoExportCta,
} from './emit-hyperframes';
+17
View File
@@ -0,0 +1,17 @@
/** Shared contract for frozen interactive HTML in video export. */
/** Maximum wall-clock wait for one packaged iframe to load and freeze. */
export const INTERACTIVE_READY_TIMEOUT_MS = 8_000;
/** Quiet period after resources are ready, before timers/animations are frozen. */
export const INTERACTIVE_SETTLE_MS = 250;
/** postMessage discriminator shared by the packaged child page and parent composition. */
export const INTERACTIVE_STATIC_MESSAGE_FLAG = '__openmaicInteractiveStatic';
/** Stable preparation failures recorded by the browser-side adapter. */
export type InteractiveHtmlFailure =
| 'missing-html'
| 'packaging-failed'
| 'unresolved-resource'
| 'too-large';
+38 -11
View File
@@ -32,7 +32,7 @@ import { SCENE_TYPES } from '@openmaic/dsl';
export const VIDEO_TIMELINE_SCHEMA = 'openmaic.videoTimeline';
/** IR/manifest version. Bump on any breaking shape change. */
export const VIDEO_TIMELINE_VERSION = 2;
export const VIDEO_TIMELINE_VERSION = 3;
/** Compiler identity stamped into the manifest for provenance. */
export const VIDEO_TIMELINE_COMPILER = 'openmaic-video-timeline';
@@ -70,6 +70,10 @@ export const DiagnosticSeveritySchema = z.enum(['info', 'warn', 'error']);
* - `cover-card` — a Quiz/PBL scene is rendered as a deterministic static cover.
* - `unknown-action` — an action with an unrecognized `type` was dropped.
* - `invalid-action` — an action missing a required field was dropped.
* - `interactive-static-html` — an interactive scene uses packaged frozen HTML.
* - `missing-interactive-html` — an interactive scene has no embedded HTML.
* - `interactive-html-packaging` — HTML preparation failed or exceeded its bound.
* - `unresolved-interactive-resource` — an external/relative resource remained.
*/
export const DiagnosticCodeSchema = z.enum([
'estimated-duration',
@@ -80,6 +84,10 @@ export const DiagnosticCodeSchema = z.enum([
'cover-card',
'unknown-action',
'invalid-action',
'interactive-static-html',
'missing-interactive-html',
'interactive-html-packaging',
'unresolved-interactive-resource',
]);
/** A recorded compile-time degradation or note. Never thrown away — first-class in the IR. */
@@ -98,14 +106,33 @@ export const DiagnosticSchema = z.object({
/** Where a segment's audio duration came from. */
export const DurationSourceSchema = z.enum(['stored', 'estimated']);
/** The scene's visual base layer — a slide snapshot, timed visual segments, or an unsupported placeholder. */
export const BaseSegmentSchema = z.object({
kind: z.enum(['slide-snapshot', 'visual-segments', 'placeholder']),
/** Asset-plan path for the base frame image, when one is planned. */
assetRef: z.string().optional(),
/** Why a placeholder was used (unsupported scene family). */
reason: z.string().optional(),
});
/** The scene's visual base layer. */
export const BaseSegmentSchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('slide-snapshot'),
/** Asset-plan path for the base frame image, when one is planned. */
assetRef: z.string().optional(),
}),
z.object({ kind: z.literal('visual-segments') }),
z.object({
kind: z.literal('placeholder'),
/** Why a placeholder was used (unsupported scene family or failed HTML capture). */
reason: z.string().optional(),
}),
z.object({
kind: z.literal('interactive-html'),
/** Stable prepared-content identity; the asset pass maps it to assetRef. */
assetId: z.string(),
/** Asset-plan path for the packaged HTML page. */
assetRef: z.string().optional(),
/** SHA-256 of the exact packaged HTML bytes. */
contentHash: z.string(),
/** Bounded load/readiness deadline used by the emitted parent bridge. */
readyTimeoutMs: z.number().int().positive(),
/** Quiet period before the child page is frozen. */
settleMs: z.number().int().nonnegative(),
}),
]);
const TimedVisualSegmentSchema = z.object({
startMs: z.number(),
@@ -246,7 +273,7 @@ export const VideoTimelineSceneSchema = z.object({
type: z.enum(SCENE_TYPES),
startMs: z.number(),
durationMs: z.number(),
/** False for scene families the compiler cannot render (quiz/interactive/pbl). */
/** False for scene families the compiler cannot render or that failed preparation. */
supported: z.boolean(),
base: BaseSegmentSchema,
visuals: z.array(VisualSegmentSchema),
@@ -267,7 +294,7 @@ export const SubtitleCueSchema = z.object({
});
/** The kind of asset a plan entry bundles. */
export const AssetKindSchema = z.enum(['audio', 'image', 'video', 'poster', 'frame']);
export const AssetKindSchema = z.enum(['audio', 'image', 'video', 'poster', 'frame', 'html']);
/**
* A single planned asset in the export zip. The plan is layout + naming only —
+9 -1
View File
@@ -146,11 +146,19 @@ export function planAssets(
const seq = String(scene.index + 1).padStart(3, '0');
const sceneSlug = `${seq}-${sanitizeFilenamePart(scene.title)}`;
// Base frame — planned for renderable (slide) scenes; the exporter renders it.
// Base frame / packaged HTML — planned for renderable scene bases.
let base = scene.base;
if (scene.base.kind === 'slide-snapshot') {
const { path } = planner.plan(`frame:${scene.id}`, 'frame', `frames/${sceneSlug}.png`, true);
base = { ...scene.base, assetRef: path };
} else if (scene.base.kind === 'interactive-html') {
const { path } = planner.plan(
scene.base.assetId,
'html',
`interactive/${sceneSlug}.html`,
true,
);
base = { ...scene.base, assetRef: path };
}
// Narration audio.
+11 -2
View File
@@ -13,14 +13,23 @@
* Pure: validation only, no IO.
*/
import { type VideoTimeline, VideoTimelineSchema } from '../ir';
import { RuntimeDiagnosticSchema } from '../runtime-diagnostics';
export const VideoExportManifestSchema = VideoTimelineSchema.extend({
runtimeDiagnostics: RuntimeDiagnosticSchema.array(),
});
export type VideoExportManifest = ReturnType<typeof VideoExportManifestSchema.parse>;
/**
* Validate and return the IR as a manifest object. Throws (via zod) if the IR
* does not satisfy {@link VideoTimelineSchema} — a compiler bug, since the IR is
* assembled internally, so failing loud is correct.
*/
export function emitManifest(ir: VideoTimeline): VideoTimeline {
return VideoTimelineSchema.parse(ir);
export function emitManifest(ir: VideoTimeline): VideoExportManifest {
return VideoExportManifestSchema.parse({
...VideoTimelineSchema.parse(ir),
runtimeDiagnostics: [],
});
}
/** Serialize the manifest to a JSON string (pretty-printed by default). */
+77
View File
@@ -0,0 +1,77 @@
/** Pure pass that promotes prepared interactive HTML to a first-class scene base. */
import type { CompilerScene, InteractiveHtmlMeta, InteractiveHtmlSource } from '../deps';
import { INTERACTIVE_READY_TIMEOUT_MS, INTERACTIVE_SETTLE_MS } from '../interactive-static';
import type { Diagnostic, DiagnosticCode, VideoTimelineScene } from '../ir';
export interface InteractiveResult {
scenes: VideoTimelineScene[];
diagnostics: Diagnostic[];
}
function failureCode(meta: InteractiveHtmlMeta | null): DiagnosticCode {
if (!meta || meta.failure === 'missing-html') return 'missing-interactive-html';
if (meta.failure === 'unresolved-resource') return 'unresolved-interactive-resource';
return 'interactive-html-packaging';
}
function failureReason(meta: InteractiveHtmlMeta | null): string {
if (!meta || meta.failure === 'missing-html') {
return 'missing-interactive-html';
}
return meta.message || 'interactive-html-packaging';
}
export function applyInteractiveHtml(
timelineScenes: readonly VideoTimelineScene[],
sourceScenes: readonly CompilerScene[],
source?: InteractiveHtmlSource,
): InteractiveResult {
const diagnostics: Diagnostic[] = [];
const scenes = timelineScenes.map((timeline, index): VideoTimelineScene => {
const scene = sourceScenes[index];
if (!scene || scene.type !== 'interactive') return timeline;
const hasEmbeddedHtml =
typeof scene.content?.html === 'string' && scene.content.html.trim() !== '';
const meta = hasEmbeddedHtml ? (source?.html(scene) ?? null) : null;
if (!hasEmbeddedHtml || !meta?.present || !meta.contentHash) {
const reason = failureReason(
hasEmbeddedHtml
? (meta ?? {
id: `interactive:${scene.id}`,
present: false,
failure: 'packaging-failed',
message: 'interactive-html-packaging',
})
: null,
);
diagnostics.push({
severity: 'warn',
code: hasEmbeddedHtml ? failureCode(meta) : 'missing-interactive-html',
sceneId: timeline.id,
message: reason,
});
return { ...timeline, base: { kind: 'placeholder', reason } };
}
diagnostics.push({
severity: 'info',
code: 'interactive-static-html',
sceneId: timeline.id,
message: 'interactive-static-html',
});
return {
...timeline,
supported: true,
base: {
kind: 'interactive-html',
assetId: meta.id,
contentHash: meta.contentHash,
readyTimeoutMs: INTERACTIVE_READY_TIMEOUT_MS,
settleMs: INTERACTIVE_SETTLE_MS,
},
};
});
return { scenes, diagnostics };
}
+19
View File
@@ -0,0 +1,19 @@
import { z } from 'zod';
export const RUNTIME_DIAGNOSTIC_CODES = [
'interactive-load-failure',
'interactive-load-timeout',
'interactive-ready-failure',
'interactive-ready-timeout',
'interactive-runtime-failure',
] as const;
export const RuntimeDiagnosticCodeSchema = z.enum(RUNTIME_DIAGNOSTIC_CODES);
export type RuntimeDiagnosticCode = z.infer<typeof RuntimeDiagnosticCodeSchema>;
export const RuntimeDiagnosticSchema = z.object({
sceneId: z.string().min(1),
code: RuntimeDiagnosticCodeSchema,
message: z.string().max(1200),
});
export type RuntimeDiagnostic = z.infer<typeof RuntimeDiagnosticSchema>;
+5
View File
@@ -88,6 +88,7 @@
"dexie": "^4.2.1",
"echarts": "^6.0.0",
"embla-carousel-react": "^8.6.0",
"es-module-lexer": "^2.0.0",
"file-saver": "^2.0.5",
"geist": "^1.7.0",
"i18next": "^26.0.1",
@@ -107,7 +108,11 @@
"next-themes": "^0.4.6",
"openai": "^4.104.0",
"partial-json": "^0.1.7",
"parse5": "^8.0.1",
"parse-srcset": "^1.0.2",
"pg": "^8.16.3",
"postcss": "^8.5.15",
"postcss-value-parser": "^4.2.0",
"pptxgenjs": "workspace:*",
"pptxtojson": "^1.11.0",
"prosemirror-commands": "^1.7.1",
+20
View File
@@ -185,6 +185,9 @@ importers:
embla-carousel-react:
specifier: ^8.6.0
version: 8.6.0(react@19.2.3)
es-module-lexer:
specifier: ^2.0.0
version: 2.0.0
file-saver:
specifier: ^2.0.5
version: 2.0.5
@@ -242,9 +245,21 @@ importers:
partial-json:
specifier: ^0.1.7
version: 0.1.7
parse5:
specifier: ^8.0.1
version: 8.0.1
parse-srcset:
specifier: ^1.0.2
version: 1.0.2
pg:
specifier: ^8.16.3
version: 8.22.0
postcss:
specifier: ^8.5.15
version: 8.5.15
postcss-value-parser:
specifier: ^4.2.0
version: 4.2.0
pptxgenjs:
specifier: workspace:*
version: link:packages/pptxgenjs
@@ -11685,6 +11700,9 @@ packages:
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
engines: {node: '>=4'}
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
postcss@7.0.39:
resolution: {integrity: sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==}
engines: {node: '>=6.0.0'}
@@ -26495,6 +26513,8 @@ snapshots:
cssesc: 3.0.0
util-deprecate: 1.0.2
postcss-value-parser@4.2.0: {}
postcss@7.0.39:
dependencies:
picocolors: 0.2.1
+44 -1
View File
@@ -1,9 +1,35 @@
import { describe, it, expect } from 'vitest';
import { buildInlinedImportmap } from '@/lib/export/inline-assets-importmap';
import {
buildInlinedImportmap,
extractSpecifiers,
rewriteModuleSpecifiers,
} from '@/lib/export/inline-assets-importmap';
const enc = (s: string) => ({ bytes: new TextEncoder().encode(s), contentType: 'text/javascript' });
describe('buildInlinedImportmap', () => {
it('ignores import-like text in comments and strings', async () => {
const code = `
const example = "import 'string-only'";
// import 'comment-only';
/* export { value } from 'block-comment-only'; */
import 'real-module';
`;
expect(extractSpecifiers(code)).toEqual(['real-module']);
expect(
await rewriteModuleSpecifiers(code, (specifier) =>
specifier === 'real-module' ? 'packaged-module' : 'unexpected-rewrite',
),
).toContain("import 'packaged-module'");
expect(await rewriteModuleSpecifiers(code, () => undefined)).toBe(code);
const escaped = String.raw`import './\u0061.js';`;
expect(await rewriteModuleSpecifiers(escaped, () => undefined)).toBe(escaped);
expect(await rewriteModuleSpecifiers("import('real-module')", () => 'packaged-module')).toBe(
"import('packaged-module')",
);
});
it('inlines a direct module entry to a data: URI', async () => {
const fetchAsset = async (url: string) =>
url === 'https://unpkg.com/three@0.160.0/build/three.module.js'
@@ -88,4 +114,21 @@ describe('buildInlinedImportmap', () => {
);
expect(imports.three).toMatch(/^data:/);
});
it('rejects cyclic external modules as an explicit unresolved resource', async () => {
const modules: Record<string, string> = {
'https://cdn.example/a.js': "import './b.js'; export const a = 1;",
'https://cdn.example/b.js': "import './a.js'; export const b = 1;",
};
const { report } = await buildInlinedImportmap(
{ entry: 'https://cdn.example/a.js' },
["import 'entry';"],
async (url) => (modules[url] ? enc(modules[url]) : null),
);
expect(report.failed).toContainEqual({
url: 'https://cdn.example/a.js',
reason: 'cyclic module dependency',
});
});
});
+441
View File
@@ -23,6 +23,40 @@ describe('collectAssetRefs', () => {
expect(refs).toContainEqual({ kind: 'img', url: 'https://cdn.example/c.png' });
});
it('collects every responsive image candidate', () => {
const refs = collectAssetRefs(
'<img srcset="https://cdn.example/c.png 1x, https://cdn.example/c@2x.png 2x">',
);
expect(refs).toContainEqual({ kind: 'srcset', url: 'https://cdn.example/c.png' });
expect(refs).toContainEqual({ kind: 'srcset', url: 'https://cdn.example/c@2x.png' });
});
it('collects external video poster URLs', () => {
const refs = collectAssetRefs('<video poster="https://cdn.example/poster.jpg"></video>');
expect(refs).toContainEqual({ kind: 'poster', url: 'https://cdn.example/poster.jpg' });
});
it('collects unsupported embedded-document resources for rejection', () => {
const refs = collectAssetRefs(
'<iframe src="https://embed.example/frame"></iframe>' +
'<object data="https://embed.example/object"></object>' +
'<embed src="https://embed.example/plugin">' +
'<iframe src="data:text/html,%3Cp%3Eframe%3C/p%3E"></iframe>' +
'<object data="data:image/svg+xml,%3Csvg%3E%3C/svg%3E"></object>' +
'<embed src="blob:https://embed.example/plugin-id">',
);
expect(refs).toEqual(
expect.arrayContaining([
{ kind: 'iframe-src', url: 'https://embed.example/frame' },
{ kind: 'object-data', url: 'https://embed.example/object' },
{ kind: 'embed-src', url: 'https://embed.example/plugin' },
{ kind: 'iframe-src', url: 'data:text/html,%3Cp%3Eframe%3C/p%3E' },
{ kind: 'object-data', url: 'data:image/svg+xml,%3Csvg%3E%3C/svg%3E' },
{ kind: 'embed-src', url: 'blob:https://embed.example/plugin-id' },
]),
);
});
it('collects source srcs (video/audio)', () => {
const refs = collectAssetRefs('<video><source src="https://cdn.example/d.mp4"></video>');
expect(refs).toContainEqual({ kind: 'source', url: 'https://cdn.example/d.mp4' });
@@ -48,6 +82,35 @@ describe('collectAssetRefs', () => {
expect(refs.map((r) => r.url)).not.toContain('http://www.w3.org/2000/svg');
});
it('collects external SVG image and use references but keeps local fragments local', () => {
const refs = collectAssetRefs(
'<svg><image href="https://cdn.example/image.png" />' +
'<use xlink:href="https://cdn.example/icons.svg#star" />' +
'<use href="#local" /></svg>',
);
expect(refs).toContainEqual({ kind: 'svg-image', url: 'https://cdn.example/image.png' });
expect(refs).toContainEqual({
kind: 'svg-use',
url: 'https://cdn.example/icons.svg#star',
});
expect(refs.map((ref) => ref.url)).not.toContain('#local');
});
it('collects CSS resources only from real url tokens', () => {
const realUrl = 'https://cdn.example/real.png';
const stringUrl = 'https://cdn.example/string.png';
const commentUrl = 'https://cdn.example/comment.png';
const refs = collectAssetRefs(
`<style>.x{content:"url(${stringUrl})";background:url(${realUrl})}/* url(${commentUrl}) */</style>`,
{ includeRelative: true },
);
expect(refs).toContainEqual({ kind: 'css-url', url: realUrl });
expect(refs.map((ref) => ref.url)).not.toContain(stringUrl);
expect(refs.map((ref) => ref.url)).not.toContain(commentUrl);
});
it('IGNORES data: and relative URLs', () => {
const html =
'<img src="data:image/png;base64,AAAA"><link rel="stylesheet" href="/local.css"><script src="./rel.js"></script>';
@@ -67,6 +130,15 @@ describe('collectAssetRefs', () => {
expect(a).toEqual([]);
expect(b).toEqual([]);
});
it('collects direct module imports and CSS @import URLs', () => {
const refs = collectAssetRefs(
'<style>@import "https://cdn.example/theme.css";</style>' +
'<script type="module">import "https://cdn.example/dep.js";</script>',
);
expect(refs).toContainEqual({ kind: 'css-import', url: 'https://cdn.example/theme.css' });
expect(refs).toContainEqual({ kind: 'module-import', url: 'https://cdn.example/dep.js' });
});
});
describe('createAssetFetcher', () => {
@@ -170,6 +242,41 @@ describe('toDataUri', () => {
});
describe('inlineCssUrls', () => {
it('preserves @import layer, supports, and media conditions when inlining', async () => {
const css =
'@import "theme.css" layer(theme) supports(display: grid) screen and (min-width: 600px);';
const { css: out } = await inlineCssUrls(
css,
'https://cdn.example/styles/base.css',
async (url) =>
url === 'https://cdn.example/styles/theme.css'
? { bytes: new TextEncoder().encode('.card{display:grid}'), contentType: 'text/css' }
: null,
);
expect(out).toContain('@layer theme');
expect(out).toContain('@supports (display: grid)');
expect(out).toContain('@media screen and (min-width: 600px)');
expect(out).toContain('.card{display:grid}');
expect(out).not.toContain('@import');
});
it('preserves repeated imports of one stylesheet under different conditions', async () => {
const css = '@import "theme.css" screen; @import "theme.css" print;';
const { css: out } = await inlineCssUrls(
css,
'https://cdn.example/styles/base.css',
async () => ({
bytes: new TextEncoder().encode('.theme{color:blue}'),
contentType: 'text/css',
}),
);
expect(out).toContain('@media screen{.theme{color:blue}}');
expect(out).toContain('@media print{.theme{color:blue}}');
expect(out.match(/\.theme\{color:blue\}/g)).toHaveLength(2);
});
it('inlines relative font url() resolved against the css base url', async () => {
const css = "@font-face{font-family:K;src:url(fonts/K.woff2) format('woff2')}";
const fetchAsset = async (url: string) => {
@@ -220,6 +327,67 @@ describe('inlineCssUrls', () => {
expect(out).toContain('data:font/woff2;base64,AAAA');
});
it('leaves local CSS fragments untouched and does not fetch them', async () => {
const calls: string[] = [];
const { css: out, failed } = await inlineCssUrls(
'svg{filter:url(#blur);mask:url("#mask")}',
'about:blank',
async (url) => {
calls.push(url);
return null;
},
);
expect(out).toContain('url(#blur)');
expect(out).toContain('url("#mask")');
expect(calls).toEqual([]);
expect(failed).toEqual([]);
});
it('preserves external SVG fragments while fetching only the resource URL', async () => {
const calls: string[] = [];
const { css: out, failed } = await inlineCssUrls(
'svg{filter:url(https://cdn.test/filters.svg#blur)}',
'about:blank',
async (url) => {
calls.push(url);
return url === 'https://cdn.test/filters.svg'
? {
bytes: new TextEncoder().encode('<svg><filter id="blur" /></svg>'),
contentType: 'image/svg+xml',
}
: null;
},
);
expect(calls).toEqual(['https://cdn.test/filters.svg']);
expect(out).toContain('url(data:image/svg+xml;base64,');
expect(out).toContain('#blur)');
expect(failed).toEqual([]);
});
it('ignores url-like text in CSS comments and quoted strings', async () => {
const realUrl = 'https://cdn.test/real.png';
const stringUrl = 'https://cdn.test/string.png';
const commentUrl = 'https://cdn.test/comment.png';
const calls: string[] = [];
const { css: out } = await inlineCssUrls(
`.x{content:"url(${stringUrl})";background:url(${realUrl})}/* url(${commentUrl}) */`,
'about:blank',
async (url) => {
calls.push(url);
return { bytes: new Uint8Array([1]), contentType: 'image/png' };
},
);
expect(out).toContain(`content:"url(${stringUrl})"`);
expect(out).toContain(`/* url(${commentUrl}) */`);
expect(out).toContain('background:url(data:image/png;base64,');
expect(calls).toContain(realUrl);
expect(calls).not.toContain(stringUrl);
expect(calls).not.toContain(commentUrl);
});
it('handles quoted url() and multiple refs, fetching each unique once', async () => {
let calls = 0;
const css = `src:url("a.woff2"),url('a.woff2'),url(b.woff2)`;
@@ -289,6 +457,26 @@ describe('inlineCssUrls', () => {
});
describe('inlineHtmlAssets — importmap integration', () => {
it('preserves supported top-level import-map fields while rewriting imports', async () => {
const html =
'<script type="importmap">' +
'{"imports":{"dep":"https://cdn.test/dep.js"},"scopes":{},"integrity":{"https://cdn.test/dep.js":"sha384-example"}}' +
'</script><script type="module">import "dep";</script>';
const { html: out } = await inlineHtmlAssets(html, {
fetcher: async (url) =>
url === 'https://cdn.test/dep.js'
? {
bytes: new TextEncoder().encode('export const value = 42;'),
contentType: 'text/javascript',
}
: null,
});
expect(out).toContain('"dep":"data:text/javascript;base64,');
expect(out).toContain('"scopes":{}');
expect(out).toContain('"integrity":{"https://cdn.test/dep.js":"sha384-example"}');
});
it('inlines three + three/addons via importmap and retains the prefix as fallback', async () => {
const base = 'https://unpkg.com/three@0.160.0/examples/jsm/';
const fetchImpl = (async (url: string) => {
@@ -353,6 +541,192 @@ function fetchFromMap(map: Record<string, { body: string; ct: string }>): typeof
}
describe('inlineHtmlAssets', () => {
it('rewrites real elements without touching resource-like authored script text', async () => {
const realUrl = 'https://cdn.test/real.png';
const inertUrl = 'https://cdn.test/inert.png';
const calls: string[] = [];
const authoredScript = `const template = '<img src="${inertUrl}">';`;
const { html: out } = await inlineHtmlAssets(
`<script>${authoredScript}</script><img src="${realUrl}">`,
{
fetcher: async (url) => {
calls.push(url);
return { bytes: new Uint8Array([1, 2, 3]), contentType: 'image/png' };
},
},
);
expect(out).toContain(`<script>${authoredScript}</script>`);
expect(out).toContain('<img src="data:image/png;base64,');
expect(calls).toContain(realUrl);
expect(calls).not.toContain(inertUrl);
});
it('inlines external SVG href resources and preserves use fragments', async () => {
const calls: string[] = [];
const { html: out } = await inlineHtmlAssets(
'<svg><defs><g id="local"></g></defs><use href="#local" />' +
'<image href="https://cdn.test/image.png" />' +
'<use xlink:href="https://cdn.test/icons.svg#star" /></svg>',
{
fetcher: async (url) => {
calls.push(url);
return {
bytes: new TextEncoder().encode(url.endsWith('.svg') ? '<svg></svg>' : 'PNG'),
contentType: url.endsWith('.svg') ? 'image/svg+xml' : 'image/png',
};
},
},
);
expect(out).toContain('<use href="#local"');
expect(out).toContain('<image href="data:image/png;base64,');
expect(out).toContain('xlink:href="data:image/svg+xml;base64,');
expect(out).toContain('#star"');
expect(calls).toContain('https://cdn.test/image.png');
expect(calls).toContain('https://cdn.test/icons.svg');
expect(calls).not.toContain('https://cdn.test/icons.svg#star');
});
it('rewrites real style blocks without touching style-like authored script text', async () => {
const realUrl = 'https://cdn.test/real.css-image.png';
const inertUrl = 'https://cdn.test/inert.css-image.png';
const authoredScript = `const template = '<style>.x{background:url(${inertUrl})}</style>';`;
const calls: string[] = [];
const { html: out } = await inlineHtmlAssets(
`<script>${authoredScript}</script><style>.x{background:url(${realUrl})}</style>`,
{
fetcher: async (url) => {
calls.push(url);
return { bytes: new Uint8Array([1]), contentType: 'image/png' };
},
},
);
expect(out).toContain(`<script>${authoredScript}</script>`);
expect(out).toContain('data:image/png;base64,');
expect(calls).toContain(realUrl);
expect(calls).not.toContain(inertUrl);
});
it('rewrites real script and link elements without touching authored tag text', async () => {
const realScript = 'https://cdn.test/real.js';
const realCss = 'https://cdn.test/real.css';
const inertScript = 'https://cdn.test/inert.js';
const inertCss = 'https://cdn.test/inert.css';
const authoredScript =
`const scriptTag = '<script src="${inertScript}">';` +
`const linkTag = '<link rel="stylesheet" href="${inertCss}">';`;
const calls: string[] = [];
const { html: out } = await inlineHtmlAssets(
`<script>${authoredScript}</script>` +
`<script src="${realScript}"></script>` +
`<link rel="stylesheet" href="${realCss}">`,
{
fetcher: async (url) => {
calls.push(url);
return {
bytes: new TextEncoder().encode(
url.endsWith('.css') ? '.real{color:red}' : 'window.real = true;',
),
contentType: url.endsWith('.css') ? 'text/css' : 'text/javascript',
};
},
},
);
expect(out).toContain(`<script>${authoredScript}</script>`);
expect(out).toContain('<script src="data:text/javascript;base64,');
expect(out).toContain('<style data-inlined-from=""');
expect(calls).toContain(realScript);
expect(calls).toContain(realCss);
expect(calls).not.toContain(inertScript);
expect(calls).not.toContain(inertCss);
});
it('inlines style-attribute resources while preserving local fragments', async () => {
const url = 'https://cdn.test/background.png';
const { html: out, report } = await inlineHtmlAssets(
`<div style="background:url(${url});filter:url(#blur)"></div>`,
{
fetcher: async (requested) =>
requested === url ? { bytes: new Uint8Array([1]), contentType: 'image/png' } : null,
},
);
expect(out).toContain('background:url(data:image/png;base64,');
expect(out).toContain('filter:url(#blur)');
expect(report.failed).toEqual([]);
});
it('inlines URL-valued SVG presentation attributes and preserves fragments', async () => {
const url = 'https://cdn.test/paint.svg';
const { html: out, unresolved } = await inlineHtmlAssets(
`<svg><rect fill="url(${url}#pattern)" filter="url(${url}#blur)" /></svg>`,
{
fetcher: async (requested) =>
requested === url
? {
bytes: new TextEncoder().encode('<svg></svg>'),
contentType: 'image/svg+xml',
}
: null,
},
);
expect(out).toContain('fill="url(data:image/svg+xml;base64,');
expect(out).toContain('#pattern)"');
expect(out).toContain('filter="url(data:image/svg+xml;base64,');
expect(out).toContain('#blur)"');
expect(out).not.toContain(url);
expect(unresolved).toEqual([]);
});
it('inlines every responsive image candidate and preserves its descriptor', async () => {
const { html, report } = await inlineHtmlAssets(
'<img srcset="https://cdn.example/small.png 1x, https://cdn.example/large.png 2x">',
{
fetcher: async (url) => ({
bytes: new TextEncoder().encode(url.endsWith('large.png') ? 'LARGE' : 'SMALL'),
contentType: 'image/png',
}),
},
);
expect(html).not.toContain('https://cdn.example/');
expect(html).toMatch(
/srcset="data:image\/png;base64,[^\s]+ 1x, data:image\/png;base64,[^\s]+ 2x"/,
);
expect(report.inlined).toEqual([
'https://cdn.example/small.png',
'https://cdn.example/large.png',
]);
});
it('inlines external video posters', async () => {
const url = 'https://cdn.example/poster.jpg';
const { html, report } = await inlineHtmlAssets(`<video poster="${url}"></video>`, {
fetcher: async (requested) =>
requested === url
? { bytes: new TextEncoder().encode('POSTER'), contentType: 'image/jpeg' }
: null,
});
expect(html).toContain('poster="data:image/jpeg;base64,');
expect(html).not.toContain(url);
expect(report.failed).toEqual([]);
});
it('reports a responsive image candidate that cannot be packaged', async () => {
const url = 'https://cdn.example/missing.png';
const { html, report } = await inlineHtmlAssets(`<img srcset="${url} 2x">`, {
fetcher: async () => null,
});
expect(html).toContain(url);
expect(report.failed).toContainEqual({ url, reason: 'fetch failed' });
});
it('inlines a stylesheet link into a <style> with fonts inlined', async () => {
const html = '<head><link rel="stylesheet" href="https://cdn/x/katex.min.css"></head>';
const fetchImpl = fetchFromMap({
@@ -392,6 +766,22 @@ describe('inlineHtmlAssets', () => {
expect(report.inlined).toContain('https://cdn.tailwindcss.com');
});
it('inlines direct video and audio src attributes', async () => {
const html =
'<video src="https://cdn.test/demo.mp4"></video><audio src="https://cdn.test/demo.mp3"></audio>';
const fetchImpl = fetchFromMap({
'https://cdn.test/demo.mp4': { body: 'video', ct: 'video/mp4' },
'https://cdn.test/demo.mp3': { body: 'audio', ct: 'audio/mpeg' },
});
const { html: out, report } = await inlineHtmlAssets(html, { fetchImpl });
expect(out).toContain('<video src="data:video/mp4;base64,');
expect(out).toContain('<audio src="data:audio/mpeg;base64,');
expect(report.inlined).toEqual(
expect.arrayContaining(['https://cdn.test/demo.mp4', 'https://cdn.test/demo.mp3']),
);
});
it('records failures and leaves the URL in place', async () => {
const html = '<img src="https://oss.example/blocked.png">';
const fetchImpl = fetchFromMap({});
@@ -452,4 +842,55 @@ describe('inlineHtmlAssets', () => {
expect(report.inlined).toContain('https://cdn/x.css'); // sheet itself inlined
expect(report.failed.map((f) => f.url)).toContain('https://cdn/f.woff2'); // nested font reported
});
it('inlines nested CSS @import files and their assets', async () => {
const fetchImpl = fetchFromMap({
'https://cdn/x.css': { body: '@import "nested.css"; .a{color:red}', ct: 'text/css' },
'https://cdn/nested.css': { body: '.b{background:url(bg.png)}', ct: 'text/css' },
'https://cdn/bg.png': { body: 'IMG', ct: 'image/png' },
});
const { html: out, report } = await inlineHtmlAssets(
'<link rel="stylesheet" href="https://cdn/x.css">',
{ fetchImpl },
);
expect(out).not.toContain('@import');
expect(out).toContain('data:image/png;base64,');
expect(report.inlined).toEqual(
expect.arrayContaining(['https://cdn/nested.css', 'https://cdn/bg.png']),
);
});
it('inlines relative imports in an external module without an importmap', async () => {
const fetchImpl = fetchFromMap({
'https://cdn/app.js': {
body: "import './dep.js'; export const app = 1",
ct: 'text/javascript',
},
'https://cdn/dep.js': { body: 'export const dep = 1', ct: 'text/javascript' },
});
const { html: out, report } = await inlineHtmlAssets(
'<script type="module" src="https://cdn/app.js"></script>',
{ fetchImpl },
);
expect(out).toContain('src="data:text/javascript;base64,');
expect(report.inlined).toContain('https://cdn/dep.js');
expect(out).not.toContain("'./dep.js'");
});
it('keeps an importmap usable when the entry module is an external script', async () => {
const fetchImpl = fetchFromMap({
'https://cdn/app.js': {
body: "import { dep } from 'dep'; export { dep }",
ct: 'text/javascript',
},
'https://cdn/dep.js': { body: 'export const dep = 1', ct: 'text/javascript' },
});
const { html: out } = await inlineHtmlAssets(
'<script type="importmap">{"imports":{"dep":"https://cdn/dep.js"}}</script>' +
'<script type="module" src="https://cdn/app.js"></script>',
{ fetchImpl, keepImportmapFallbacks: false },
);
expect(out).toContain('"dep":"data:text/javascript;base64,');
expect(out).toContain('src="data:text/javascript;base64,');
});
});
+12 -3
View File
@@ -50,10 +50,19 @@ describe('patchHtmlForIframe', () => {
expect(ls.getItem('a')).toBeNull();
});
it('falls back to prepending when there is no <head>', () => {
it('creates a head when the authored document has none', () => {
const out = patchHtmlForIframe('<div>no head</div>');
// The error-capture shim is injected first, so it leads the prepended block.
expect(out.startsWith('\n<script data-iframe-error-shim>')).toBe(true);
expect(out.startsWith('<head>\n<script data-iframe-error-shim>')).toBe(true);
expect(out.indexOf('</head>')).toBeLessThan(out.indexOf('<div>'));
});
it('does not treat head-like text in comments or scripts as the document head', () => {
const authoredScript = 'const template = "<head>"; window.__template = template;';
const html = `<!doctype html><!-- <head> --><html><body><script>${authoredScript}</script></body></html>`;
const out = patchHtmlForIframe(html);
expect(out).toContain(`<script>${authoredScript}</script>`);
expect(out.indexOf('data-iframe-error-shim')).toBeLessThan(out.indexOf('<body>'));
});
it('injects the error-capture shim before the storage shim and page scripts', () => {
+8
View File
@@ -48,4 +48,12 @@ describe('iframe sandbox safety', () => {
expect(isDangerousSandbox(sandbox)).toBe(false);
}
});
test('video-export emitter keeps packaged interactive HTML in a null-origin sandbox', () => {
const sandboxes = extractSandboxValues('lib/video-export/emit-hyperframes/index.ts');
expect(sandboxes).toContain('allow-scripts');
for (const sandbox of sandboxes) {
expect(isDangerousSandbox(sandbox)).toBe(false);
}
});
});
@@ -94,6 +94,7 @@ exports[`emitHyperframes > matches the HTML snapshot 1`] = `
</div>
</div>
<script type="application/json" data-openmaic-runtime-diagnostics>[]</script>
</div>
<script src="assets/vendor/gsap.min.js"></script>
<script>
@@ -142,6 +143,7 @@ tl.to('#fx-0-2',{autoAlpha:0,x:-1056,y:-591.6,duration:0.25,ease:EASE_IN},4.75);
tl.set('#fx-0-2',{autoAlpha:0},5);
tl.set('#fx-0-2-ring',{opacity:0},5);
tl.set({}, {}, 7);
window.__openmaicVideoManifest = { runtimeDiagnostics: [], manifestPath: "openmaic-video-manifest.json" };
window.__timelines = window.__timelines || {};
window.__timelines["openmaic"] = tl;
</script>
@@ -60,6 +60,14 @@ afterAll(() => {
});
describe('buildExportZip CTA boundary', () => {
it('does not expose a runtime diagnostics sidecar that cannot be updated after packaging', async () => {
mocks.accessDocument.mockResolvedValue(undefined);
const result = await buildExportZip({ resolution: '720p', locale: 'en-US' });
expect(result).not.toHaveProperty('runtimeDiagnosticsPath');
});
it('freezes the configured CTA and complete locale labels before its first await', async () => {
let releaseDocument!: (value: unknown) => void;
mocks.accessDocument.mockReturnValue(
+27
View File
@@ -85,6 +85,7 @@ function records(over: Partial<VideoTimelineRecords> = {}): VideoTimelineRecords
audioById: new Map(),
mediaByElementId: new Map(),
videoDurationMsByElementId: new Map(),
interactiveHtml: { html: () => null, content: () => undefined },
...over,
};
}
@@ -525,3 +526,29 @@ describe('collectVideoAssets — frame base restores evicted generated media', (
expect(capturedSlides[0].elements[0].src).toMatch(/^blob:mock\//);
});
});
describe('collectVideoAssets — interactive HTML', () => {
it('collects exact prepared HTML bytes for an html asset-plan entry', async () => {
const prepared = '<!doctype html><h1>Frozen fixture</h1>';
const { blobs, missing } = await collectVideoAssets(
irWith([
{
assetId: 'interactive:s1',
kind: 'html',
path: 'interactive/001-fixture.html',
present: true,
},
]),
[],
records({
interactiveHtml: {
html: () => null,
content: (assetId) => (assetId === 'interactive:s1' ? prepared : undefined),
},
}),
);
expect(await blobs.get('interactive/001-fixture.html')?.text()).toBe(prepared);
expect(missing).toEqual([]);
});
});
+1 -1
View File
@@ -36,7 +36,7 @@ describe('compileVideoTimeline — end-to-end golden', () => {
it('stamps the envelope + config', () => {
expect(ir).toMatchObject({
schema: 'openmaic.videoTimeline',
version: 2,
version: 3,
compiler: 'openmaic-video-timeline',
stage: { id: 'stg', name: 'Demo' },
config: { playbackSpeed: 1, ttsEnabled: true, whiteboardInitiallyOpen: false },
+33 -5
View File
@@ -10,13 +10,15 @@ import {
} from 'node:fs';
import { dirname, join } from 'node:path';
import type { Locale } from '@/lib/i18n';
import type { Scene } from '@/lib/types/stage';
import { compileVideoTimeline, emitHyperframes } from '@/lib/video-export';
import type { CompilerScene } from '@/lib/video-export';
import { prepareInteractiveHtmlScenes } from '@/lib/video-export-app/prepare-interactive-html';
import {
getVideoExportCoverLabels,
resolveVideoExportCta,
} from '@/lib/video-export-app/cover-config';
import { NO_ASSETS, NO_PROBE, slide, speech } from './helpers';
import { NO_ASSETS, NO_PROBE, interactive, slide, speech } from './helpers';
/**
* Materializes a fully-emitted Hyperframes project (index.html + manifest +
@@ -253,6 +255,7 @@ const EXPECTED_SAMPLE_NAMES = [
'pbl-dense',
'mixed',
'arabic',
'interactive-static',
] as const;
const COMPLETE_PROJECT_FILES = [
@@ -307,10 +310,23 @@ const samples: MaterializedSample[] = [
locale: 'ar-SA',
marker: 'إنشاء مشروع تعليمي قابل للتصدير والتحقق دون اتصال',
},
{
name: 'interactive-static',
scenes: [
{
...interactive(
'interactive-static-fixture',
'<!doctype html><html><head></head><body><h1>Frozen interactive fixture</h1></body></html>',
),
title: 'Frozen interactive fixture',
},
],
marker: 'Frozen interactive fixture',
},
];
describe('Hyperframes materialized sample contract', () => {
it('defines the exact six distinguishable sample directories', () => {
it('defines the exact seven distinguishable sample directories', () => {
expect(samples.map((sample) => sample.name)).toEqual(EXPECTED_SAMPLE_NAMES);
});
@@ -330,15 +346,20 @@ describe('Hyperframes materialized sample contract', () => {
});
describe.skipIf(!OUT_DIR)('materialize a Hyperframes project for real-CLI E2E', () => {
it('writes a complete, self-contained project', () => {
it('writes a complete, self-contained project', async () => {
const root = OUT_DIR!;
rmSync(root, { recursive: true, force: true });
for (const sample of samples) {
const dir = join(root, sample.name);
const preparedInteractive = await prepareInteractiveHtmlScenes(sample.scenes as Scene[]);
const ir = compileVideoTimeline(
{ stage: { id: 'stage', name: `E2E ${sample.name}` }, scenes: sample.scenes },
{ timing: NO_PROBE, assets: NO_ASSETS },
{
timing: NO_PROBE,
assets: NO_ASSETS,
interactive: preparedInteractive,
},
);
const project = emitHyperframes(ir, effectiveSampleOptions(sample));
mkdirSync(dir, { recursive: true });
@@ -355,7 +376,14 @@ describe.skipIf(!OUT_DIR)('materialize a Hyperframes project for real-CLI E2E',
if (!entry.present || entry.dedupOf) continue;
const target = join(dir, 'assets', entry.path);
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, entry.kind === 'frame' ? PNG_1x1 : Buffer.from(''));
writeFileSync(
target,
entry.kind === 'frame'
? PNG_1x1
: entry.kind === 'html'
? Buffer.from(preparedInteractive.content(entry.assetId) ?? '')
: Buffer.from(''),
);
}
// Vendored GSAP from the committed public copy.
+56 -1
View File
@@ -3,6 +3,9 @@ import { spotlightV1 } from '@/lib/choreography';
import { compileVideoTimeline, emitHyperframes } from '@/lib/video-export';
import type { AssetMeta, CompilerScene } from '@/lib/video-export';
import {
NO_ASSETS,
NO_PROBE,
interactive,
slide,
quiz,
el,
@@ -12,6 +15,7 @@ import {
playVideo,
stubProbe,
stubAssets,
stubInteractiveHtml,
} from './helpers';
const audioMeta = (id: string): AssetMeta => ({
@@ -112,7 +116,7 @@ describe('emitHyperframes', () => {
/<div dir="auto" style="font-size:2\.2vw[^"]*">Mixed fallback: English ثم العربية<\/div>/,
);
expect(fallbackHtml).toMatch(
/<div dir="auto" style="font-size:1\.2vw[^"]*">Interactive\/widget scenes require runtime playback/,
/<div dir="auto" style="font-size:1\.2vw[^"]*">interactive-static-fallback/,
);
});
@@ -190,6 +194,57 @@ describe('emitHyperframes', () => {
});
});
describe('emitHyperframes frozen interactive HTML', () => {
const ir = compileVideoTimeline(
{
stage: { id: 'stage', name: 'Interactive export' },
scenes: [interactive('widget', '<!doctype html><h1>Widget</h1>')],
},
{
timing: NO_PROBE,
assets: NO_ASSETS,
interactive: stubInteractiveHtml({
widget: {
id: 'interactive:widget',
present: true,
contentHash: 'b'.repeat(64),
},
}),
},
);
const html = emitHyperframes(ir, { width: 1280, height: 720 }).files.find(
(file) => file.path === 'index.html',
)!.content;
it('loads the packaged page in a script-only sandbox with the placeholder ready', () => {
expect(html).toContain('data-interactive-static-host');
expect(html).toContain('data-src="assets/interactive/001-widget.html"');
expect(html).toContain('sandbox="allow-scripts"');
expect(html).not.toMatch(/sandbox="[^"]*allow-same-origin/);
expect(html).toContain('data-interactive-fallback');
});
it('waits for every iframe to freeze or fall back before registering the timeline', () => {
const ready = html.indexOf(
'window.__openmaicInteractiveReady = initializeOpenMaicInteractiveStaticFrames()',
);
const registration = html.indexOf('window.__timelines["openmaic"] = tl;', ready);
expect(ready).toBeGreaterThan(0);
expect(registration).toBeGreaterThan(ready);
expect(html).toContain("'interactive-runtime-failure'");
expect(html).toContain("'interactive-ready-timeout'");
expect(html).toContain('window.__openmaicVideoDiagnostics');
expect(html).toContain('data-openmaic-runtime-diagnostics');
expect(html).toContain('window.__openmaicVideoManifest.runtimeDiagnostics');
});
it('keeps runtime diagnostics in the live report instead of an immutable sidecar', () => {
expect(html).toContain('data-openmaic-runtime-diagnostics');
expect(html).toContain('window.__openmaicVideoManifest.runtimeDiagnostics');
expect(html).not.toContain('openmaic-runtime-diagnostics.json');
});
});
describe('emitHyperframes static Quiz/PBL cover cards', () => {
const scenes = [
{
+23
View File
@@ -3,6 +3,7 @@ import {
compileVideoTimeline,
emitManifest,
emitManifestJson,
RuntimeDiagnosticSchema,
VideoTimelineSchema,
VIDEO_TIMELINE_SCHEMA,
VIDEO_TIMELINE_VERSION,
@@ -29,6 +30,28 @@ describe('emitManifest', () => {
const round = JSON.parse(json);
expect(round.schema).toBe(VIDEO_TIMELINE_SCHEMA);
expect(round.scenes[0].narration[0].text).toBe('hi');
expect(round.runtimeDiagnostics).toEqual([]);
});
it('models runtime diagnostic codes instead of accepting arbitrary strings', () => {
expect(
RuntimeDiagnosticSchema.parse({
sceneId: 'widget',
code: 'interactive-runtime-failure',
message: 'fixture boom',
}),
).toEqual({
sceneId: 'widget',
code: 'interactive-runtime-failure',
message: 'fixture boom',
});
expect(() =>
RuntimeDiagnosticSchema.parse({
sceneId: 'widget',
code: 'arbitrary-string',
message: 'fixture boom',
}),
).toThrow();
});
it('throws on a malformed IR (schema is enforced)', () => {
+33 -1
View File
@@ -1,5 +1,12 @@
import type { Action, PPTElement } from '@openmaic/dsl';
import type { AssetMeta, AssetSource, CompilerScene, TimingProbe } from '@/lib/video-export';
import type {
AssetMeta,
AssetSource,
CompilerScene,
InteractiveHtmlMeta,
InteractiveHtmlSource,
TimingProbe,
} from '@/lib/video-export';
/** Build an action-like object with a stable id (accepts invalid types for validation tests). */
export const act = (a: { id: string; type: string; [k: string]: unknown }): Action =>
@@ -54,6 +61,23 @@ export function quiz(id: string, actions: Action[] = [], order = 0): CompilerSce
} as CompilerScene;
}
export function interactive(
id: string,
html: string | undefined,
actions: Action[] = [],
order = 0,
): CompilerScene {
return {
id,
stageId: 'stage',
title: id,
order,
type: 'interactive',
content: { type: 'interactive', html },
actions,
} as CompilerScene;
}
/** TimingProbe stub: audio/video durations keyed by action id; anything else → estimate/null. */
export function stubProbe(
audioMs: Record<string, number> = {},
@@ -78,3 +102,11 @@ export function stubAssets(
export const NO_PROBE: TimingProbe = { audioDurationMs: () => null, videoDurationMs: () => null };
export const NO_ASSETS: AssetSource = { audio: () => null, media: () => null };
export function stubInteractiveHtml(
bySceneId: Record<string, InteractiveHtmlMeta> = {},
): InteractiveHtmlSource {
return { html: (scene) => bySceneId[scene.id] ?? null };
}
export const NO_INTERACTIVE_HTML: InteractiveHtmlSource = { html: () => null };
@@ -0,0 +1,245 @@
/** Isolated Chromium proof for packaged interactive HTML readiness, freeze, and fallback. */
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { chromium, type Browser, type Page } from '@playwright/test';
import { compileVideoTimeline, emitHyperframes } from '@/lib/video-export';
import { prepareInteractiveHtmlScenes } from '@/lib/video-export-app/prepare-interactive-html';
import type { Scene } from '@/lib/types/stage';
import { NO_ASSETS, NO_PROBE } from './helpers';
const REQUIRED = process.env.INTERACTIVE_STATIC_BROWSER === '1';
const BASE_URL = 'http://interactive-static.test';
const GSAP = readFileSync(join(process.cwd(), 'public/vendor/gsap.min.js'), 'utf8');
function interactiveScene(html: string): Scene {
return {
id: 'widget',
stageId: 'stage',
title: 'Static interactive fixture',
order: 0,
type: 'interactive',
content: { type: 'interactive', url: '', html },
actions: [],
} as Scene;
}
async function emittedFixture(html: string): Promise<{ index: string; child: string }> {
const scene = interactiveScene(html);
const prepared = await prepareInteractiveHtmlScenes([scene]);
const ir = compileVideoTimeline(
{ stage: { id: 'stage', name: 'Interactive smoke' }, scenes: [scene] },
{ timing: NO_PROBE, assets: NO_ASSETS, interactive: prepared },
);
const project = emitHyperframes(ir, { width: 1280, height: 720 });
return {
index: project.files.find((file) => file.path === 'index.html')!.content,
child: prepared.content('interactive:widget')!,
};
}
async function load(page: Page, fixture: { index: string; child: string }): Promise<void> {
await page.route(`${BASE_URL}/**`, async (route) => {
const path = new URL(route.request().url()).pathname;
if (path === '/index.html' || path === '/') {
await route.fulfill({ status: 200, contentType: 'text/html', body: fixture.index });
} else if (path === '/assets/vendor/gsap.min.js') {
await route.fulfill({ status: 200, contentType: 'text/javascript', body: GSAP });
} else if (path === '/assets/interactive/001-static-interactive-fixture.html') {
await route.fulfill({ status: 200, contentType: 'text/html', body: fixture.child });
} else {
await route.fulfill({ status: 404, body: 'not found' });
}
});
await page.goto(`${BASE_URL}/index.html`, { waitUntil: 'load' });
}
describe.skipIf(!REQUIRED)('frozen interactive HTML in Chromium', () => {
let browser: Browser;
beforeAll(async () => {
browser = await chromium.launch({
headless: true,
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
args: ['--disable-crash-reporter', '--disable-crashpad'],
});
});
afterAll(async () => {
await browser?.close();
});
it('freezes the settled initial state before exposing the Hyperframes timeline', async () => {
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
try {
await load(
page,
await emittedFixture(`<!doctype html><html><head>
<style>#box{width:120px;height:120px;background:#6d28d9;animation:pulse .1s infinite alternate}@keyframes pulse{to{transform:scale(1.2)}}</style>
</head><body><div id="box"></div><output id="counter">0</output><output id="worker-status">pending</output><script>
var count=0; setInterval(function(){ document.getElementById('counter').textContent=String(++count); },20);
try {
new Worker(URL.createObjectURL(new Blob(['setInterval(function(){postMessage("tick")},20)'])));
} catch (error) {
document.getElementById('worker-status').textContent = error && error.message || String(error);
}
</script></body></html>`),
);
await page.waitForFunction(() => {
const host = document.querySelector('[data-interactive-static-host]');
const timelines = (window as typeof window & { __timelines?: Record<string, unknown> })
.__timelines;
return (
host?.getAttribute('data-interactive-static-state') === 'frozen' && !!timelines?.openmaic
);
});
const child = page.frames().find((frame) => frame !== page.mainFrame())!;
const before = await child.evaluate(() => ({
counter: document.querySelector('#counter')?.textContent,
workerStatus: document.querySelector('#worker-status')?.textContent,
state: document.documentElement.getAttribute('data-openmaic-static-state'),
animations: document.getAnimations().map((animation) => animation.playState),
}));
await page.waitForTimeout(350);
const after = await child.evaluate(() => ({
counter: document.querySelector('#counter')?.textContent,
workerStatus: document.querySelector('#worker-status')?.textContent,
state: document.documentElement.getAttribute('data-openmaic-static-state'),
animations: document.getAnimations().map((animation) => animation.playState),
}));
expect(before.state).toBe('frozen');
expect(before.workerStatus).toContain('interactive-worker-disabled');
expect(after).toEqual(before);
expect(after.animations.every((state) => state === 'paused')).toBe(true);
} finally {
await page.close();
}
});
it('creates a standards-mode head without corrupting head-like script text', async () => {
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
try {
await load(
page,
await emittedFixture(
'<!doctype html><html><body><script>window.__template = "<head>";</script><main id="mode">Standards mode</main></body></html>',
),
);
await page.waitForFunction(() => {
return (
document
.querySelector('[data-interactive-static-host]')
?.getAttribute('data-interactive-static-state') === 'frozen'
);
});
const child = page.frames().find((frame) => frame !== page.mainFrame())!;
expect(await child.evaluate(() => document.compatMode)).toBe('CSS1Compat');
expect(
await child.evaluate(() => (window as typeof window & { __template?: string }).__template),
).toBe('<head>');
} finally {
await page.close();
}
});
it('switches a runtime failure to the visible placeholder without failing the composition', async () => {
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
try {
await load(
page,
await emittedFixture(
'<!doctype html><html><head></head><body><h1>Broken</h1><script>throw new Error("fixture boom")</script></body></html>',
),
);
await page.waitForFunction(() => {
const host = document.querySelector('[data-interactive-static-host]');
const timelines = (window as typeof window & { __timelines?: Record<string, unknown> })
.__timelines;
return (
host?.getAttribute('data-interactive-static-state') === 'fallback' &&
!!timelines?.openmaic
);
});
const result = await page.evaluate(() => {
const host = document.querySelector<HTMLElement>('[data-interactive-static-host]')!;
const fallback = host.querySelector<HTMLElement>('[data-interactive-fallback]')!;
const diagnostics = (
window as typeof window & {
__openmaicVideoDiagnostics?: Array<{ code: string; message: string }>;
}
).__openmaicVideoDiagnostics;
const manifest = (
window as typeof window & {
__openmaicVideoManifest?: { runtimeDiagnostics?: Array<{ code: string }> };
}
).__openmaicVideoManifest;
const runtimeReport = document.querySelector<HTMLElement>(
'[data-openmaic-runtime-diagnostics]',
);
return {
state: host.getAttribute('data-interactive-static-state'),
code: host.getAttribute('data-interactive-diagnostic'),
fallbackDisplay: getComputedStyle(fallback).display,
fallbackText: fallback.textContent,
diagnostics,
manifestDiagnostics: manifest?.runtimeDiagnostics,
runtimeReport: runtimeReport?.textContent,
};
});
expect(result).toMatchObject({
state: 'fallback',
code: 'interactive-runtime-failure',
fallbackDisplay: 'block',
fallbackText: expect.stringContaining('fixture boom'),
diagnostics: [
expect.objectContaining({
code: 'interactive-runtime-failure',
message: expect.stringContaining('fixture boom'),
}),
],
manifestDiagnostics: [expect.objectContaining({ code: 'interactive-runtime-failure' })],
runtimeReport: expect.stringContaining('interactive-runtime-failure'),
});
expect(page.frames()).toHaveLength(1);
} finally {
await page.close();
}
});
it('unloads the child document after a readiness failure', async () => {
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
try {
await load(
page,
await emittedFixture(
'<!doctype html><html><body><img src="data:image/png;base64,AAAA"><script>setInterval(function(){ window.__ticks = (window.__ticks || 0) + 1; }, 10)</script></body></html>',
),
);
await page.waitForFunction(() => {
return (
document
.querySelector('[data-interactive-static-host]')
?.getAttribute('data-interactive-static-state') === 'fallback'
);
});
await page.waitForTimeout(100);
expect(page.frames()).toHaveLength(1);
expect(
await page
.locator('[data-interactive-static-host]')
.getAttribute('data-interactive-diagnostic'),
).toBe('interactive-ready-failure');
} finally {
await page.close();
}
});
});
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest';
import { compileVideoTimeline, VideoTimelineSchema } from '@/lib/video-export';
import { NO_ASSETS, NO_PROBE, interactive, stubInteractiveHtml } from './helpers';
describe('compileVideoTimeline — static interactive HTML', () => {
it('promotes prepared HTML to a supported base and plans its asset', () => {
const ir = compileVideoTimeline(
{
stage: { id: 'stage', name: 'Interactive' },
scenes: [interactive('widget', '<!doctype html><h1>Widget</h1>')],
},
{
timing: NO_PROBE,
assets: NO_ASSETS,
interactive: stubInteractiveHtml({
widget: {
id: 'interactive:widget',
present: true,
contentHash: 'a'.repeat(64),
},
}),
},
);
expect(() => VideoTimelineSchema.parse(ir)).not.toThrow();
expect(ir.scenes[0]).toMatchObject({
supported: true,
base: {
kind: 'interactive-html',
assetId: 'interactive:widget',
assetRef: 'interactive/001-widget.html',
contentHash: 'a'.repeat(64),
readyTimeoutMs: 8000,
settleMs: 250,
},
});
expect(ir.assets.entries).toContainEqual({
assetId: 'interactive:widget',
kind: 'html',
path: 'interactive/001-widget.html',
present: true,
});
expect(ir.diagnostics).toContainEqual(
expect.objectContaining({ code: 'interactive-static-html', sceneId: 'widget' }),
);
expect(ir.diagnostics).not.toContainEqual(
expect.objectContaining({ code: 'unsupported-scene', sceneId: 'widget' }),
);
});
it('uses the existing placeholder for missing embedded HTML', () => {
const ir = compileVideoTimeline(
{
stage: { id: 'stage', name: 'Interactive' },
scenes: [interactive('missing', '')],
},
{ timing: NO_PROBE, assets: NO_ASSETS },
);
expect(ir.scenes[0]).toMatchObject({
supported: false,
base: { kind: 'placeholder', reason: expect.stringContaining('missing') },
});
expect(ir.diagnostics).toContainEqual(
expect.objectContaining({ code: 'missing-interactive-html', sceneId: 'missing' }),
);
});
it('surfaces unresolved resources and falls back without failing compile', () => {
const ir = compileVideoTimeline(
{
stage: { id: 'stage', name: 'Interactive' },
scenes: [interactive('remote', '<img src="https://example.test/a.png">')],
},
{
timing: NO_PROBE,
assets: NO_ASSETS,
interactive: stubInteractiveHtml({
remote: {
id: 'interactive:remote',
present: false,
failure: 'unresolved-resource',
message: 'Interactive HTML has unresolved resources: https://example.test/a.png.',
},
}),
},
);
expect(ir.scenes[0].base).toMatchObject({
kind: 'placeholder',
reason: expect.stringContaining('unresolved resources'),
});
expect(ir.diagnostics).toContainEqual(
expect.objectContaining({ code: 'unresolved-interactive-resource', sceneId: 'remote' }),
);
});
});
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import JSZip from 'jszip';
import { packageVideoZip } from '@/lib/video-export-app/package-zip';
describe('packageVideoZip — interactive HTML', () => {
it('writes the prepared page under assets/interactive in the self-contained ZIP', async () => {
const blob = await packageVideoZip(
{
files: [{ path: 'index.html', content: '<!doctype html><main>composition</main>' }],
width: 1280,
height: 720,
compositionId: 'openmaic',
totalDurationMs: 1000,
gsapVendorPath: 'assets/vendor/gsap.min.js',
},
new Map([
[
'interactive/001-widget.html',
new Blob(['<!doctype html><h1>Frozen widget</h1>'], { type: 'text/html' }),
],
]),
{ gsapSource: 'window.gsap={};' },
);
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
expect(await zip.file('assets/interactive/001-widget.html')?.async('string')).toContain(
'Frozen widget',
);
expect(await zip.file('index.html')?.async('string')).toContain('composition');
expect(await zip.file('assets/vendor/gsap.min.js')?.async('string')).toContain('window.gsap');
});
});
@@ -0,0 +1,233 @@
import { describe, expect, it } from 'vitest';
import { prepareInteractiveHtmlScenes } from '@/lib/video-export-app/prepare-interactive-html';
import type { Scene } from '@/lib/types/stage';
function scene(html?: string): Scene {
return {
id: 'widget',
stageId: 'stage',
title: 'Widget',
order: 0,
type: 'interactive',
content: { type: 'interactive', url: '', html },
actions: [],
} as Scene;
}
describe('prepareInteractiveHtmlScenes', () => {
it('produces a bounded packaged page with CSP, iframe shims, and freeze runtime', async () => {
const prepared = await prepareInteractiveHtmlScenes([
scene('<!doctype html><html><head></head><body><h1>Ready</h1></body></html>'),
]);
const meta = prepared.html(scene());
const html = prepared.content('interactive:widget');
expect(meta).toMatchObject({
id: 'interactive:widget',
present: true,
contentHash: expect.stringMatching(/^[a-f0-9]{64}$/),
});
expect(html).toContain('data-openmaic-static-csp');
expect(html).toContain('data-iframe-storage-shim');
expect(html).toContain('data-iframe-error-shim');
expect(html).toContain('data-openmaic-static-capture');
expect(html).toContain(
"document.documentElement.setAttribute('data-openmaic-static-state', 'frozen')",
);
expect(html).toContain("connect-src 'none'");
expect(html).toContain("worker-src 'none'");
expect(html).toContain('interactive-worker-disabled');
});
it('keeps the doctype first when authored HTML omits an explicit head', async () => {
const prepared = await prepareInteractiveHtmlScenes([
scene('<!doctype html><html><body><h1>Standards mode</h1></body></html>'),
]);
const html = prepared.content('interactive:widget')!;
expect(html.toLowerCase().startsWith('<!doctype html><html><head>')).toBe(true);
expect(html.indexOf('data-openmaic-static-csp')).toBeGreaterThan(html.indexOf('<head>'));
expect(html.indexOf('</head>')).toBeLessThan(html.indexOf('<body>'));
});
it('does not inject into head-like text in comments or authored scripts', async () => {
const authoredScript = 'const template = "<head>"; window.__template = template;';
const prepared = await prepareInteractiveHtmlScenes([
scene(
`<!doctype html><!-- <head> --><html><body><script>${authoredScript}</script></body></html>`,
),
]);
const html = prepared.content('interactive:widget')!;
expect(prepared.html(scene())?.present).toBe(true);
expect(html).toContain(`<script>${authoredScript}</script>`);
expect(html.indexOf('data-openmaic-static-csp')).toBeLessThan(html.indexOf('<body>'));
});
it('ignores embedded-document markup inside authored script text', async () => {
const authoredScript =
'const template = `<iframe src="data:text/html,example"></iframe>`; window.__template = template;';
const prepared = await prepareInteractiveHtmlScenes([
scene(`<script>${authoredScript}</script><main>Ready</main>`),
]);
expect(prepared.html(scene())).toMatchObject({ present: true });
expect(prepared.content('interactive:widget')).toContain(`<script>${authoredScript}</script>`);
});
it('inlines supported remote assets and removes the network URL', async () => {
const prepared = await prepareInteractiveHtmlScenes(
[scene('<html><head></head><body><img src="https://cdn.test/pixel.png"></body></html>')],
{
fetcher: async (url) =>
url === 'https://cdn.test/pixel.png'
? { bytes: new Uint8Array([1, 2, 3]), contentType: 'image/png' }
: null,
},
);
expect(prepared.html(scene())?.present).toBe(true);
expect(prepared.content('interactive:widget')).toContain('src="data:image/png;base64,');
expect(prepared.content('interactive:widget')).not.toContain('https://cdn.test/pixel.png');
});
it('accepts bare module imports resolved by the final offline import map', async () => {
const prepared = await prepareInteractiveHtmlScenes(
[
scene(
'<script type="importmap">{"imports":{"dep":"https://cdn.test/dep.js"}}</script>' +
'<script type="module">import { value } from "dep"; window.__value = value;</script>',
),
],
{
fetcher: async (url) =>
url === 'https://cdn.test/dep.js'
? {
bytes: new TextEncoder().encode('export const value = 42;'),
contentType: 'text/javascript',
}
: null,
},
);
expect(prepared.html(scene())).toMatchObject({ present: true });
expect(prepared.content('interactive:widget')).toContain('"dep":"data:text/javascript;base64,');
});
it('explicitly rejects scoped import maps instead of changing their semantics', async () => {
const authored =
'<script type="importmap">' +
'{"imports":{},"scopes":{"https://app.test/":{"dep":"https://cdn.test/dep.js"}}}' +
'</script><script type="module" src="https://app.test/main.js"></script>';
const calls: string[] = [];
const prepared = await prepareInteractiveHtmlScenes([scene(authored)], {
fetcher: async (url) => {
calls.push(url);
return url === 'https://app.test/main.js'
? {
bytes: new TextEncoder().encode('import { value } from "dep";'),
contentType: 'text/javascript',
}
: null;
},
});
expect(prepared.html(scene())).toMatchObject({
present: false,
failure: 'unresolved-resource',
message: expect.stringContaining('unsupported-importmap-scopes'),
});
expect(prepared.content('interactive:widget')).toBeUndefined();
expect(calls).toEqual([]);
});
it('rejects unresolved remote or relative resources with an explicit failure', async () => {
const remote = await prepareInteractiveHtmlScenes(
[scene('<img src="https://cdn.test/missing.png">')],
{ fetcher: async () => null },
);
expect(remote.html(scene())).toMatchObject({
present: false,
failure: 'unresolved-resource',
message: expect.stringContaining('https://cdn.test/missing.png'),
});
expect(remote.content('interactive:widget')).toBeUndefined();
const relative = await prepareInteractiveHtmlScenes([
scene('<script src="./app.js"></script>'),
]);
expect(relative.html(scene())).toMatchObject({
present: false,
failure: 'unresolved-resource',
message: expect.stringContaining('./app.js'),
});
const responsive = await prepareInteractiveHtmlScenes(
[scene('<img srcset="https://cdn.test/missing.png 2x">')],
{ fetcher: async () => null },
);
expect(responsive.html(scene())).toMatchObject({
present: false,
failure: 'unresolved-resource',
message: expect.stringContaining('https://cdn.test/missing.png'),
});
const poster = await prepareInteractiveHtmlScenes(
[scene('<video poster="https://cdn.test/missing-poster.jpg"></video>')],
{ fetcher: async () => null },
);
expect(poster.html(scene())).toMatchObject({
present: false,
failure: 'unresolved-resource',
message: expect.stringContaining('https://cdn.test/missing-poster.jpg'),
});
for (const embedded of [
'<iframe src="https://embed.test/frame"></iframe>',
'<object data="https://embed.test/object"></object>',
'<embed src="https://embed.test/plugin">',
'<iframe src="data:text/html,%3Cp%3Eframe%3C/p%3E"></iframe>',
'<object data="data:image/svg+xml,%3Csvg%3E%3C/svg%3E"></object>',
'<embed src="blob:https://embed.test/plugin-id">',
'<iframe srcdoc="<script>setInterval(function(){}, 10)<\/script>"></iframe>',
]) {
const prepared = await prepareInteractiveHtmlScenes([scene(embedded)], {
fetcher: async () => null,
});
expect(prepared.html(scene())).toMatchObject({
present: false,
failure: 'unresolved-resource',
message: expect.stringMatching(/(?:https?|data|blob):|iframe\[srcdoc\]/),
});
}
});
it('rejects a page whose packaged bytes exceed the configured cap', async () => {
const prepared = await prepareInteractiveHtmlScenes([scene('<p>large</p>')], {
maxHtmlBytes: 32,
});
expect(prepared.html(scene())).toMatchObject({
present: false,
failure: 'too-large',
message: expect.stringContaining('/32'),
});
});
it('records missing embedded HTML without throwing', async () => {
const prepared = await prepareInteractiveHtmlScenes([scene('')]);
expect(prepared.html(scene())).toMatchObject({
present: false,
failure: 'missing-html',
});
});
it('removes KaTeX about:invalid font fallbacks from the packaged page', async () => {
const prepared = await prepareInteractiveHtmlScenes([
scene('<style>@font-face{src:url(about:invalid) format("woff")}</style>'),
]);
expect(prepared.html(scene())).toMatchObject({ present: true });
expect(prepared.content('interactive:widget')).not.toContain('about:invalid');
});
});
+2 -2
View File
@@ -111,7 +111,7 @@ describe('video-export cover visual pass', () => {
}),
);
expect(ir.version).toBe(2);
expect(ir.version).toBe(3);
expect(ir.scenes[0]).toMatchObject({
supported: true,
base: { kind: 'visual-segments' },
@@ -569,7 +569,7 @@ describe('video-export cover visual pass', () => {
expect(JSON.stringify(first)).not.toMatch(/Question Agent|Instructor/);
});
it('keeps interactive scenes unsupported while Quiz and PBL get informational diagnostics', () => {
it('keeps unprepared interactive scenes unsupported while Quiz and PBL get informational diagnostics', () => {
const interactive = {
id: 'interactive',
stageId: 'stage',
@@ -40,7 +40,7 @@ node scripts/check-package-version-bumps.mjs "$base"
const EXPECTED_HYPERFRAMES_LINT = `
set -euo pipefail
for sample in quiz pbl-v2 pbl-legacy pbl-dense mixed arabic; do
for sample in quiz pbl-v2 pbl-legacy pbl-dense mixed arabic interactive-static; do
dir="$HF_E2E_DIR/$sample"
if output="$(pnpm exec hyperframes lint "$dir" 2>&1)"; then
status=0
@@ -177,7 +177,18 @@ describe('CI video-export workflow contract', () => {
expect(guard['continue-on-error']).toBeUndefined();
});
it('materializes and warning-strict lints the exact six samples with Hyperframes 0.7.60', () => {
it('keeps the interactive static HTML Chromium smoke required', () => {
const guard = step(parseWorkflow(), 'e2e', 'Interactive static HTML Chromium smoke');
expect(guard.run).toBe(
'pnpm exec vitest run tests/video-export/interactive-static-html.browser.test.ts',
);
expect(guard.env).toEqual({ INTERACTIVE_STATIC_BROWSER: '1' });
expect(guard.if).toBeUndefined();
expect(guard['continue-on-error']).toBeUndefined();
});
it('materializes and warning-strict lints the exact seven samples with Hyperframes 0.7.60', () => {
assertHyperframesGateContract(parseWorkflow());
});