mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-08 10:46:06 +00:00
* fix(studio): load @hyperframes/player lazily to support SSR Player.tsx had a bare `import "@hyperframes/player"` at module scope. The player package registers a class that extends HTMLElement as a side effect, and HTMLElement doesn't exist in a Node server runtime. Any consumer that imported from @hyperframes/studio during server-side rendering (e.g. the Next.js App Router evaluating a client component for SSR) threw `HTMLElement is not defined`. Move the import inside the mount effect via dynamic `import(...)` so it only runs in the browser, and wire up a cancellation flag and deferred cleanup so a fast unmount doesn't leak listeners or DOM nodes. * fix(studio): remove .js extensions from captions-internal imports The captions module imported sibling files as `./types.js` and `./parser.js`. That's legal ESM TypeScript, but Turbopack (and other bundlers) refuse to resolve those specifiers against .ts files when the package is consumed from node_modules — the rest of @hyperframes/studio uses extensionless imports for that reason. Align captions with the rest of the codebase so the package builds without bundler-specific configuration in consumers. * chore: release @hyperframes/player@0.2.7 and @hyperframes/studio@0.2.9 Ships the root-timeline resolution fix (#247), the SSR-safe player load, and the captions import cleanup.
377 lines
12 KiB
TypeScript
377 lines
12 KiB
TypeScript
// Caption HTML Generator
|
|
// Serializes a CaptionModel into a complete captions.html HyperFrames composition.
|
|
|
|
import type {
|
|
CaptionModel,
|
|
CaptionSegment,
|
|
CaptionStyle,
|
|
CaptionContainerStyle,
|
|
CaptionShadow,
|
|
CaptionGlow,
|
|
} from "./types";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Serializes a CaptionModel into a complete captions.html composition string.
|
|
*
|
|
* Output format:
|
|
* ```html
|
|
* <template id="captions-template">
|
|
* <div data-composition-id="captions" data-width="..." data-height="..." data-duration="...">
|
|
* <div id="captions-container"></div>
|
|
* <style>/* generated CSS *\/</style>
|
|
* <script>/* generated JS *\/</script>
|
|
* </div>
|
|
* </template>
|
|
* ```
|
|
*/
|
|
export function generateCaptionHtml(model: CaptionModel): string {
|
|
const css = generateCss(model);
|
|
const js = generateJs(model);
|
|
|
|
const durationStr = model.duration.toString();
|
|
|
|
return [
|
|
`<template id="captions-template">`,
|
|
` <div data-composition-id="captions" data-width="${model.width}" data-height="${model.height}" data-duration="${durationStr}">`,
|
|
` <div id="captions-container"></div>`,
|
|
` <style>`,
|
|
indent(css, 6),
|
|
` </style>`,
|
|
` <script>`,
|
|
indent(js, 6),
|
|
` </script>`,
|
|
` </div>`,
|
|
`</template>`,
|
|
].join("\n");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CSS generation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function generateCss(model: CaptionModel): string {
|
|
const lines: string[] = [];
|
|
|
|
// Base composition styles
|
|
lines.push(
|
|
`[data-composition-id="captions"] {`,
|
|
` position: relative;`,
|
|
` width: ${model.width}px;`,
|
|
` height: ${model.height}px;`,
|
|
` background: transparent;`,
|
|
` overflow: hidden;`,
|
|
`}`,
|
|
``,
|
|
);
|
|
|
|
// Container styles
|
|
lines.push(`#captions-container {`, ` position: absolute;`, ` inset: 0;`, `}`, ``);
|
|
|
|
// .caption-group base styles
|
|
lines.push(
|
|
`.caption-group {`,
|
|
` position: absolute;`,
|
|
` display: flex;`,
|
|
` flex-wrap: wrap;`,
|
|
` gap: 0.25em;`,
|
|
` opacity: 0;`,
|
|
`}`,
|
|
``,
|
|
);
|
|
|
|
// .word base styles
|
|
lines.push(`.word {`, ` display: inline-block;`, `}`, ``);
|
|
|
|
// Per-group CSS classes
|
|
for (const groupId of model.groupOrder) {
|
|
const group = model.groups.get(groupId);
|
|
if (!group) continue;
|
|
|
|
const className = groupId.replace(/[^a-zA-Z0-9-_]/g, "-");
|
|
const styleDecls = buildGroupStyleDecls(group.style, group.containerStyle);
|
|
|
|
if (styleDecls.length > 0) {
|
|
lines.push(`.caption-group.${className} {`);
|
|
for (const decl of styleDecls) {
|
|
lines.push(` ${decl}`);
|
|
}
|
|
lines.push(`}`, ``);
|
|
}
|
|
}
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function buildGroupStyleDecls(
|
|
style: CaptionStyle,
|
|
containerStyle: CaptionContainerStyle,
|
|
): string[] {
|
|
const decls: string[] = [];
|
|
|
|
// Typography
|
|
if (style.fontFamily) {
|
|
decls.push(`font-family: ${style.fontFamily};`);
|
|
}
|
|
if (style.fontSize) {
|
|
decls.push(`font-size: ${style.fontSize}px;`);
|
|
}
|
|
if (style.fontWeight) {
|
|
decls.push(`font-weight: ${style.fontWeight};`);
|
|
}
|
|
if (style.fontStyle && style.fontStyle !== "normal") {
|
|
decls.push(`font-style: ${style.fontStyle};`);
|
|
}
|
|
if (style.textDecoration && style.textDecoration !== "none") {
|
|
decls.push(`text-decoration: ${style.textDecoration};`);
|
|
}
|
|
if (style.textTransform && style.textTransform !== "none") {
|
|
decls.push(`text-transform: ${style.textTransform};`);
|
|
}
|
|
if (style.letterSpacing !== 0) {
|
|
decls.push(`letter-spacing: ${style.letterSpacing}em;`);
|
|
}
|
|
if (style.lineHeight) {
|
|
decls.push(`line-height: ${style.lineHeight};`);
|
|
}
|
|
|
|
// Color / fill
|
|
if (style.color) {
|
|
decls.push(`color: ${style.color};`);
|
|
}
|
|
if (style.opacity !== undefined && style.opacity !== 1) {
|
|
// opacity is managed by GSAP animations, but non-default base opacity can be declared
|
|
decls.push(`--caption-base-opacity: ${style.opacity};`);
|
|
}
|
|
|
|
// Stroke (via text-stroke / webkit-text-stroke)
|
|
if (style.strokeWidth > 0) {
|
|
decls.push(`-webkit-text-stroke: ${style.strokeWidth}px ${style.strokeColor};`);
|
|
}
|
|
|
|
// Shadows
|
|
if (style.shadows && style.shadows.length > 0) {
|
|
const shadowStr = style.shadows.map(shadowToCss).join(", ");
|
|
decls.push(`text-shadow: ${shadowStr};`);
|
|
}
|
|
|
|
// Glow (implemented as additional text-shadow)
|
|
if (style.glow) {
|
|
const glowStr = glowToCss(style.glow);
|
|
const existingShadow =
|
|
style.shadows && style.shadows.length > 0
|
|
? style.shadows.map(shadowToCss).join(", ") + ", "
|
|
: "";
|
|
// Only emit if not already emitted shadows (override the text-shadow if both present)
|
|
if (!(style.shadows && style.shadows.length > 0)) {
|
|
decls.push(`text-shadow: ${glowStr};`);
|
|
} else {
|
|
// Replace the last text-shadow declaration with combined
|
|
const idx = decls.findLastIndex((d) => d.startsWith("text-shadow:"));
|
|
if (idx >= 0) {
|
|
decls[idx] = `text-shadow: ${existingShadow}${glowStr};`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Blend mode
|
|
if (style.blendMode && style.blendMode !== "normal") {
|
|
decls.push(`mix-blend-mode: ${style.blendMode};`);
|
|
}
|
|
|
|
// Container: background
|
|
if (
|
|
containerStyle.backgroundColor &&
|
|
containerStyle.backgroundColor !== "transparent" &&
|
|
containerStyle.backgroundOpacity > 0
|
|
) {
|
|
const bg = hexToRgba(containerStyle.backgroundColor, containerStyle.backgroundOpacity);
|
|
decls.push(`background-color: ${bg};`);
|
|
}
|
|
|
|
// Container: padding
|
|
const { paddingTop, paddingRight, paddingBottom, paddingLeft } = containerStyle;
|
|
if (paddingTop > 0 || paddingRight > 0 || paddingBottom > 0 || paddingLeft > 0) {
|
|
decls.push(`padding: ${paddingTop}px ${paddingRight}px ${paddingBottom}px ${paddingLeft}px;`);
|
|
}
|
|
|
|
// Container: border radius
|
|
if (containerStyle.borderRadius > 0) {
|
|
decls.push(`border-radius: ${containerStyle.borderRadius}px;`);
|
|
}
|
|
|
|
// Container: border
|
|
if (containerStyle.borderWidth > 0) {
|
|
decls.push(
|
|
`border: ${containerStyle.borderWidth}px ${containerStyle.borderStyle} ${containerStyle.borderColor};`,
|
|
);
|
|
}
|
|
|
|
// Container: box shadow
|
|
if (containerStyle.boxShadow && containerStyle.boxShadow !== "none") {
|
|
decls.push(`box-shadow: ${containerStyle.boxShadow};`);
|
|
}
|
|
|
|
return decls;
|
|
}
|
|
|
|
function shadowToCss(shadow: CaptionShadow): string {
|
|
return `${shadow.offsetX}px ${shadow.offsetY}px ${shadow.blur}px ${shadow.color}`;
|
|
}
|
|
|
|
function glowToCss(glow: CaptionGlow): string {
|
|
// Glow is represented as a spread text-shadow with opacity applied to color
|
|
return `0 0 ${glow.blur}px ${hexToRgba(glow.color, glow.opacity)}`;
|
|
}
|
|
|
|
/** Converts a hex color and opacity into rgba(...) for CSS */
|
|
function hexToRgba(color: string, opacity: number): string {
|
|
// If it's already rgb/rgba, just return it (can't easily inject opacity)
|
|
if (color.startsWith("rgb")) {
|
|
return color;
|
|
}
|
|
// Named colors and other non-hex values — return as-is
|
|
if (!color.startsWith("#")) {
|
|
return color;
|
|
}
|
|
// Try to parse hex
|
|
const hex = color.replace("#", "");
|
|
if (hex.length === 3 || hex.length === 6) {
|
|
const full =
|
|
hex.length === 3
|
|
? hex
|
|
.split("")
|
|
.map((c) => c + c)
|
|
.join("")
|
|
: hex;
|
|
const r = parseInt(full.slice(0, 2), 16);
|
|
const g = parseInt(full.slice(2, 4), 16);
|
|
const b = parseInt(full.slice(4, 6), 16);
|
|
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
|
}
|
|
return color;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// JS generation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function generateJs(model: CaptionModel): string {
|
|
// Collect all segments across all groups in order
|
|
const allSegments: Array<{ text: string; start: number; end: number }> = [];
|
|
for (const groupId of model.groupOrder) {
|
|
const group = model.groups.get(groupId);
|
|
if (!group) continue;
|
|
for (const segId of group.segmentIds) {
|
|
const seg = model.segments.get(segId);
|
|
if (!seg) continue;
|
|
allSegments.push({ text: seg.text, start: seg.start, end: seg.end });
|
|
}
|
|
}
|
|
|
|
const transcriptJson = JSON.stringify(allSegments, null, 2);
|
|
|
|
const groupBlocks: string[] = [];
|
|
|
|
for (const groupId of model.groupOrder) {
|
|
const group = model.groups.get(groupId);
|
|
if (!group) continue;
|
|
|
|
const className = groupId.replace(/[^a-zA-Z0-9-_]/g, "-");
|
|
|
|
// Compute group start/end from its segments
|
|
const groupSegments = group.segmentIds
|
|
.map((id) => model.segments.get(id))
|
|
.filter((s): s is CaptionSegment => s !== undefined);
|
|
|
|
if (groupSegments.length === 0) continue;
|
|
|
|
const firstSeg = groupSegments[0];
|
|
const lastSeg = groupSegments[groupSegments.length - 1];
|
|
const groupStart = firstSeg.start;
|
|
const groupEnd = lastSeg.end;
|
|
|
|
const groupVar = className.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
|
|
// Build word spans
|
|
const wordLines: string[] = groupSegments.map((seg) => {
|
|
const escaped = seg.text.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
const segVar = `w_${seg.id.replace(/[^a-zA-Z0-9_]/g, "_")}`;
|
|
return (
|
|
` const ${segVar} = document.createElement('span');` +
|
|
`\n ${segVar}.className = 'word clip';` +
|
|
`\n ${segVar}.textContent = '${escaped}';` +
|
|
`\n ${segVar}.dataset.start = '${seg.start}';` +
|
|
`\n ${segVar}.dataset.end = '${seg.end}';` +
|
|
`\n groupEl_${groupVar}.appendChild(${segVar});`
|
|
);
|
|
});
|
|
|
|
// Position: if x/y non-zero, use absolute with left/top; otherwise center
|
|
const groupVarName = `groupEl_${groupVar}`;
|
|
const hasExplicitPosition = group.style.x !== 0 || group.style.y !== 0;
|
|
|
|
let positionLines: string;
|
|
if (hasExplicitPosition) {
|
|
positionLines = [
|
|
` ${groupVarName}.style.left = '${group.style.x}px';`,
|
|
` ${groupVarName}.style.top = '${group.style.y}px';`,
|
|
].join("\n");
|
|
} else {
|
|
positionLines = [
|
|
` ${groupVarName}.style.left = '50%';`,
|
|
` ${groupVarName}.style.top = '80%';`,
|
|
` ${groupVarName}.style.transform = 'translateX(-50%) translateY(-50%)';`,
|
|
` ${groupVarName}.style.justifyContent = 'center';`,
|
|
` ${groupVarName}.style.maxWidth = '90%';`,
|
|
].join("\n");
|
|
}
|
|
|
|
const block = [
|
|
`// Group: ${groupId}`,
|
|
`const ${groupVarName} = document.createElement('div');`,
|
|
`${groupVarName}.className = 'caption-group clip ${className}';`,
|
|
`${groupVarName}.dataset.start = '${groupStart}';`,
|
|
`${groupVarName}.dataset.end = '${groupEnd}';`,
|
|
`container.appendChild(${groupVarName});`,
|
|
wordLines.join("\n"),
|
|
positionLines,
|
|
`// Entrance: fade in at group start`,
|
|
`tl.to(${groupVarName}, { opacity: 1, duration: 0.2, ease: 'power2.out' }, ${groupStart});`,
|
|
`// Exit: fade out at group end`,
|
|
`tl.to(${groupVarName}, { opacity: 0, duration: 0.2, ease: 'power2.in' }, ${groupEnd} - 0.2);`,
|
|
].join("\n");
|
|
|
|
groupBlocks.push(block);
|
|
}
|
|
|
|
return `(function () {
|
|
const TRANSCRIPT = ${transcriptJson};
|
|
|
|
const container = document.getElementById('captions-container');
|
|
if (!container) return;
|
|
|
|
const tl = gsap.timeline({ paused: true });
|
|
|
|
${groupBlocks.join("\n\n ")}
|
|
|
|
if (!window.__timelines) window.__timelines = {};
|
|
window.__timelines["captions"] = tl;
|
|
})();`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Utilities
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function indent(text: string, spaces: number): string {
|
|
const pad = " ".repeat(spaces);
|
|
return text
|
|
.split("\n")
|
|
.map((line) => (line.trim() === "" ? "" : pad + line))
|
|
.join("\n");
|
|
}
|