/** * Hyperframes emitter — `VideoTimeline` IR → a self-contained composition project. * * The IR is the contract (issue #864); this pure emitter is a downstream * consumer (#865) that renders it to the files `npx hyperframes render` needs: * a single `index.html` whose stage is one Hyperframes composition, driven by one * `paused` GSAP timeline registered on `window.__timelines`. Because every IR * time is already absolute on the global playback clock (the compiler runs one * cursor across all scenes), the whole classroom is a single flat composition — * scene base frames and video clips are `class="clip"` elements laid out with * `data-start`/`data-duration`, and effects are overlay DOM the timeline reveals. * * This module emits **text only** (HTML/JS/JSON/SRT/VTT strings); the binary * assets it references by relative path (`frames/…`, `audio/…`, `media/…`, and * the vendored GSAP) are collected and written by the app-side packaging layer, * so the emitter stays pure and string-snapshot testable. * * Determinism red-lines (enforced downstream by `hyperframes lint`): GSAP is * vendored locally (no CDN), no `Date.now`/`Math.random`/network at render time, * explicit root `data-duration`, no infinite repeats. * * Pure: depends only on the IR, the subtitle serializer, and the effect emitter. */ import type { PblCoverVisual, QuizCoverVisual, QuizQuestionListVisual, VideoTimeline, 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'; import { INTER_FONT_FACE_CSS, INTER_OFL_LICENSE } from './inter-font'; import { KATEX_EXPORT_CSS, KATEX_FONT_ASSETS, KATEX_MIT_LICENSE } from './katex-assets'; import { NOTO_CJK_EXPORT_CSS, NOTO_CJK_FONT_ASSETS, NOTO_SANS_KR_OFL_LICENSE, NOTO_SANS_SC_OFL_LICENSE, } from './noto-cjk-assets'; import { quizQuestionListCss, renderQuizQuestionListSurface, type QuizQuestionListLabels, } from './quiz-question-list'; /** A file in the emitted project: a relative path and its text content. */ export interface EmittedFile { path: string; content: string; } /** Binary file copied from the app's vendored public assets into the export ZIP. */ export interface EmittedVendorAsset { /** Project-relative path referenced by emitted HTML/CSS. */ path: string; /** App-local URL used by the packaging boundary to load the committed bytes. */ sourceUrl: string; } /** Optional informational destination displayed on exported Quiz/PBL covers. */ export interface VideoExportCta { /** Normalized display destination without a URL scheme or trailing slash. */ 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 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 VideoExportLabels extends QuizQuestionListLabels { /** `quiz.title` — the Quiz card's eyebrow. */ quiz: string; /** `quiz.questionsCount` — unit after the question count. */ questions: string; /** `quiz.pointsSuffix` — unit after the total points. */ points: string; /** `quiz.singleChoice` — static question type label. */ singleChoice: string; /** `quiz.multipleChoice` — static question type label. */ multipleChoice: string; /** `quiz.shortAnswer` — static question type label. */ shortAnswer: string; /** `quiz.inputPlaceholder` — text shown inside the visual-only answer box. */ answerPlaceholder: string; /** `pbl.v2.hero.title` — the PBL card's eyebrow. */ pbl: string; /** `pbl.v2.hero.stage` — unit after the stage count. */ stages: string; /** `pbl.v2.hero.task` — unit after the task count. */ tasks: string; /** `pbl.v2.hero.youWillLearn` — heading above the gains list. */ gains: string; /** `pbl.v2.hero.tutor` — instructor row label and name fallback. */ instructor: string; /** `pbl.v2.hero.instructorTagline` — instructor description fallback. */ instructorTagline: string; /** `pbl.v2.hero.scenarioCharacter` — scenario-character row label. */ scenarioCharacter: string; /** `pbl.v2.hero.scenarioCharacterTagline` — scenario-character description. */ scenarioCharacterTagline: string; /** Prompt above the configured destination on a Quiz cover. */ quizCtaPrompt: string; /** Prompt above the configured destination on a PBL cover. */ pblCtaPrompt: string; /** Verb preceding the configured destination. */ ctaVisit: string; /** Localized fallback and failure copy for static interactive scenes. */ interactive: InteractiveFallbackLabels; } /** Backward-compatible name for app-side cover and Quiz measurement labels. */ export type CoverCardLabels = VideoExportLabels; type VideoExportLabelOverrides = Partial> & { interactive?: Partial; }; export interface EmitHyperframesOptions { /** Render width in px. Default 1920. Height is derived from the IR's 16:9 aspect. */ width?: number; /** Render height in px. Default derived from `width` at 16:9. */ height?: number; /** Composition id used for the root `data-composition-id` and the timeline key. Default `openmaic`. */ compositionId?: string; /** Relative path the emitted HTML loads GSAP from. Default `assets/vendor/gsap.min.js`. */ gsapVendorPath?: string; /** Manifest filename. Default `openmaic-video-manifest.json`. */ manifestPath?: string; /** Cover-card chrome; each omitted key falls back to its `en-US` default. */ labels?: VideoExportLabelOverrides; /** Informational destination for Quiz/PBL covers. Omitted or null disables it. */ cta?: VideoExportCta | null; /** * BCP-47 tag the emitted document is written in — the same locale the * {@link EmitHyperframesOptions.labels} were resolved from. Sets ``; * 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_VIDEO_EXPORT_LABELS}. */ locale?: string; /** * Burn the subtitle overlay into the composition (baked into the video by the * frame capture). Default `false`: the video renders clean and the narration * subtitles ship only as the sidecar `subtitles.srt` / `.vtt`, which a user * can add in an editor (#867 item 2 — burn-in off by default). When `true`, * the bottom caption band is emitted and driven by the paused timeline. */ burnInSubtitles?: boolean; } export interface EmittedProject { files: EmittedFile[]; /** Font/runtime bytes required by this project; empty for exports without a Quiz list. */ vendorAssets: EmittedVendorAsset[]; width: number; height: number; compositionId: string; totalDurationMs: number; /** Where the emitted HTML expects the vendored GSAP — the packaging layer fills it. */ gsapVendorPath: string; } const DEFAULT_WIDTH = 1920; const DEFAULT_GSAP_PATH = 'assets/vendor/gsap.min.js'; const DEFAULT_MANIFEST = 'openmaic-video-manifest.json'; const DEFAULT_LOCALE = 'en-US'; /** Language subtags written right-to-left; everything else renders LTR. */ const RTL_LANGUAGES = new Set(['ar', 'fa', 'he', 'ur', 'ps', 'sd', 'ug', 'yi']); function isRtl(locale: string): boolean { return RTL_LANGUAGES.has(locale.split('-')[0].toLowerCase()); } const DEFAULT_VIDEO_EXPORT_LABELS: VideoExportLabels = { quiz: 'Quiz', questions: 'questions', points: 'pts', singleChoice: 'Single', multipleChoice: 'Multiple', shortAnswer: 'Short answer', answerPlaceholder: 'Type your answer here...', pbl: 'Project-Based Learning', stages: 'Stages', tasks: 'Tasks', gains: "What you'll gain", instructor: 'Tutor', instructorTagline: 'Guides you through the whole project', scenarioCharacter: 'Role-play character', scenarioCharacterTagline: "The character you'll interact with in the scenario", 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', }, }; /** * Directory the collected binary assets live under in the export zip. The * compiler's asset plan uses bare paths (`frames/…`, `audio/…`, `media/…`); the * project places them all under `assets/` (matching the artifact layout and the * vendored GSAP at `assets/vendor/`). The packaging layer writes each plan blob * at this same `assets/`, so HTML references and zip entries agree. */ export const ASSETS_DIR = 'assets'; /** Map a compiler asset-plan path to its zip-relative URL under `assets/`. */ export function assetUrl(planPath: string): string { return `${ASSETS_DIR}/${planPath}`; } function placeholderContent(scene: VideoTimelineScene, reason: string, reasonAttrs = ''): string { const reasonAttributeText = reasonAttrs ? ` ${reasonAttrs}` : ''; return [ `
`, `
${escapeHtml(scene.title)}
`, reason ? ` ${escapeHtml(reason)}
` : '', ``, ] .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`; const clip = `id="${id}" class="clip" data-start="${start}" data-duration="${duration}" data-track-index="0"`; if (scene.base.kind === 'slide-snapshot' && scene.base.assetRef) { return ``; } if (scene.base.kind === 'visual-segments') return ''; if (scene.base.kind === 'interactive-html' && scene.base.assetRef) { const fallback = placeholderContent( scene, labels.interactive.fallback, 'data-interactive-fallback-reason', ); return [ `
`, `
${fallback}
`, ` `, `
`, ].join('\n'); } const reason = scene.type === 'interactive' ? labels.interactive.fallback : scene.base.kind === 'placeholder' ? (scene.base.reason ?? '') : ''; return `
${placeholderContent(scene, reason)}
`; } /** 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')); }); })); } `; } /** * One ` ` stat tile. The unit is a localized label, so it is used * verbatim rather than pluralized in English (`道题` / `questions` / `pts`). */ function statTile(count: number, unit: string): string { return `
${count}${escapeHtml(unit)}
`; } function renderCoverCta(prompt: string, visit: string, cta: VideoExportCta | null): string { if (!cta) return ''; return [ `
`, `
${escapeHtml(prompt)}
`, `
${escapeHtml(visit)} ${escapeHtml(cta.destination)}
`, `
`, ].join('\n'); } function visualClip( scene: VideoTimelineScene, visual: VisualSegment, index: number, className: string, trackIndex = 0, ): string { return [ `id="scene-${scene.index + 1}-visual-${index + 1}"`, `class="clip cover-card ${className}"`, `data-visual-kind="${visual.kind}"`, `data-start="${sec(visual.startMs)}"`, `data-duration="${sec(visual.durationMs)}"`, `data-track-index="${trackIndex}"`, ].join(' '); } function renderQuizCover( scene: VideoTimelineScene, visual: QuizCoverVisual, index: number, labels: VideoExportLabels, cta: VideoExportCta | null, direction: 'ltr' | 'rtl', ): string { const clip = visualClip(scene, visual, index, 'cover-quiz'); return [ `
`, `
`, `
`, `
? ${escapeHtml(labels.quiz)}
`, `

${escapeHtml(visual.title)}

`, `
`, ` ${statTile(visual.questionCount, labels.questions)}`, ` ${statTile(visual.totalPoints, labels.points)}`, `
`, renderCoverCta(labels.quizCtaPrompt, labels.ctaVisit, cta), `
`, `
`, ] .filter(Boolean) .join('\n'); } function renderQuizQuestionList( scene: VideoTimelineScene, visual: QuizQuestionListVisual, index: number, labels: CoverCardLabels, direction: 'ltr' | 'rtl', ): string { // A crossfade is intentionally an overlap. Hyperframes rejects overlapping // clips on one track, so the list owns track 3 (0=base/cover, 1=video, // 2=audio) while GSAP performs the resolved 600ms transition. const clip = visualClip(scene, visual, index, 'quiz-question-list', 3); const contentId = `scene-${scene.index + 1}-visual-${index + 1}-content`; return [ `
`, renderQuizQuestionListSurface(visual, labels, direction, contentId), `
`, ].join('\n'); } function quizQuestionListStatements( scene: VideoTimelineScene, visual: QuizQuestionListVisual, index: number, ): string[] { const id = `scene-${scene.index + 1}-visual-${index + 1}`; const coverIndex = scene.visuals.findIndex((candidate) => candidate.kind === 'quiz-cover'); const coverId = `scene-${scene.index + 1}-visual-${coverIndex + 1}`; const start = sec(visual.startMs); const transition = sec(visual.transitionDurationMs); const statements = [ `tl.fromTo('#${id}',{autoAlpha:0},{autoAlpha:1,duration:${transition},ease:'none'},${start});`, ]; if (coverIndex >= 0) { statements.push( `tl.to('#${coverId}',{autoAlpha:0,duration:${transition},ease:'none'},${start});`, ); } if (visual.scrollDistancePx > 0 && visual.scrollDurationMs > 0) { const scrollStart = sec( visual.startMs + visual.transitionDurationMs + visual.topHoldDurationMs, ); statements.push( `tl.to('#${id}-content',{y:-${visual.scrollDistancePx},duration:${sec(visual.scrollDurationMs)},ease:'none'},${scrollStart});`, ); } return statements; } function renderPerson( label: string, name: string, description: string, tone: 'instructor' | 'character', ): string { const initial = Array.from(name)[0] ?? '?'; return [ `
`, `
${escapeHtml(initial)}
`, `
${escapeHtml(label)}${escapeHtml(name)}${escapeHtml(description)}
`, `
`, ].join('\n'); } function renderPblCover( scene: VideoTimelineScene, visual: PblCoverVisual, index: number, labels: VideoExportLabels, plan: PblCoverPlan, cta: VideoExportCta | null, direction: 'ltr' | 'rtl', ): string { const clip = visualClip(scene, visual, index, 'cover-pbl'); const gains = plan.gains .map( (gain) => `
  • ✓${escapeHtml(gain)}
  • `, ) .join('\n'); // Each row is rendered only for a person the course actually authored: a card // with no instructor says nothing rather than introducing a generic "Tutor". const people = !plan.people ? '' : [ visual.instructorName ? renderPerson( labels.instructor, visual.instructorName, visual.instructorDescription ?? labels.instructorTagline, 'instructor', ) : '', visual.scenarioCharacterName ? renderPerson( labels.scenarioCharacter, visual.scenarioCharacterName, labels.scenarioCharacterTagline, 'character', ) : '', ] .filter(Boolean) .join('\n'); return [ `
    `, `
    `, `
    `, `
    ✦ ${escapeHtml(labels.pbl)}
    `, `

    ${escapeHtml(visual.title)}

    `, plan.description ? `

    ${escapeHtml(plan.description)}

    ` : '', gains ? `
      ${gains}
    ` : '', `
    `, `
    ${statTile(visual.stageCount, labels.stages)}${statTile(visual.taskCount, labels.tasks)}
    `, people ? `
    ${people}
    ` : '', `
    `, renderCoverCta(labels.pblCtaPrompt, labels.ctaVisit, cta), `
    `, `
    `, ] .filter(Boolean) .join('\n'); } /** Render first-class track-0 visuals independently from the scene base. */ function renderVisuals( scene: VideoTimelineScene, labels: VideoExportLabels, frame: { width: number; height: number; burnInSubtitles: boolean }, cta: VideoExportCta | null, direction: 'ltr' | 'rtl', ): { html: string[]; statements: string[] } { const html: string[] = []; const statements: string[] = []; scene.visuals.forEach((visual, index) => { if (visual.kind === 'quiz-cover') { html.push(renderQuizCover(scene, visual, index, labels, cta, direction)); return; } if (visual.kind === 'quiz-question-list') { html.push(renderQuizQuestionList(scene, visual, index, labels, direction)); statements.push(...quizQuestionListStatements(scene, visual, index)); return; } html.push( renderPblCover( scene, visual, index, labels, planPblCover(visual, labels, { ...frame, cta }), cta, direction, ), ); }); return { html, statements }; } /** A `play_video` clip, positioned at the target element's geometry (0–100 space). */ function renderVideo(scene: VideoTimelineScene): string[] { return scene.videos .filter((v) => v.present && v.assetRef) .map((v, i) => { const start = sec(v.startMs); const duration = sec(v.durationMs); const id = `scene-${scene.index + 1}-video-${i + 1}`; const clip = `id="${id}" class="clip" data-start="${start}" data-duration="${duration}" data-track-index="1"`; const g = v.geometry; const style = g ? `position:absolute;left:${g.x}%;top:${g.y}%;width:${g.w}%;height:${g.h}%;transform:rotate(${v.rotate}deg);object-fit:contain` : `position:absolute;left:0;top:0;width:100%;height:100%;object-fit:contain`; // data-has-audio: the clip contributes its own soundtrack, mixed at encode. return ``; }); } /** Narration `