fix: address QA report P0-P2 issues for 10/10 agent experience (#208)

## Summary

Addresses all 8 issues from the QA report to improve agent and user experience.

### P0 — Must Fix

- **Blank template broken captions**: Removed `compositions/captions.html` and its reference from the blank template. Every agent (10/10) hit 404 errors during render.
- **Inner-wrapper example**: Added a clear structural comment in the blank template showing the correct `class="clip"` + inner wrapper pattern.
- **Sub-composition introspection**: `hyperframes compositions` now reads external HTML files referenced via `data-composition-src` and shows their real duration/element count instead of `0.0s / 0 elements`.

### P1 — Fix Soon

- **Font mapping warnings**: Now lists all mapped fonts, suggests alternatives (use a mapped font, add @font-face, install locally), and links to docs.
- **Browser 404s**: Non-font "Failed to load resource" 404s now prefixed with `[non-blocking]` instead of `[Browser:ERROR]`.
- **Render concurrency**: Default workers increased from `cores/2` (max 4) to `cores*3/4` (max 6). Added `--concurrency` alias.

### P2 — Nice to Have

- **Transform conflict fix suggestion**: `gsap_css_transform_conflict` now suggests exact GSAP property replacements (e.g., `xPercent: -50, yPercent: -50`).
- **Upgrade --yes**: Now actually runs the install instead of just printing the command.

## Before / After

### Font mapping warning

**Before:**

```
[Compiler] No deterministic font mapping for: DM Sans
```

**After:**

```
[Compiler] No deterministic font mapping for: DM Sans
  Mapped fonts: arial → inter, courier → jetbrains-mono, ...
  To fix, pick one:
    1. Use a mapped font name instead (see list above)
    2. Add a @font-face block in your HTML with a local or hosted font file
    3. Install the font locally on the render machine (Docker: add to Dockerfile)
    4. Add an alias to FONT_ALIASES in deterministicFonts.ts (for contributors)
```

### Browser 404s during render

**Before:** `[Browser:ERROR] Failed to load resource: the server responded with a status of 404`
**After:** `[non-blocking] Failed to load resource: the server responded with a status of 404`

### Transform conflict lint

**Before:** `Fix: Remove the transform from CSS and use tl.fromTo...`
**After:** `Fix: Remove transform: translate(-50%, -50%) from CSS and replace with GSAP properties: xPercent: -50, yPercent: -50`

### Compositions command

**Before:**

```
overlay   0.0s   1920×1080   0 elements
```

**After:**

```
overlay   8.0s   1920×1080   2 elements ← compositions/overlay.html
```

## Test plan

- [x] All 422 core tests pass
- [x] TypeScript compiles cleanly (all 4 packages)
- [x] Full monorepo build succeeds
- [x] `hyperframes init --template blank` ships without broken captions reference
This commit is contained in:
Miguel Ángel
2026-04-06 18:56:39 +02:00
committed by GitHub
parent c47e710ffc
commit baa3d813be
8 changed files with 198 additions and 136 deletions
+75 -4
View File
@@ -1,6 +1,7 @@
import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
export const examples: Example[] = [
["List compositions in the current project", "hyperframes compositions"],
@@ -17,9 +18,10 @@ interface CompositionInfo {
width: number;
height: number;
elementCount: number;
source?: string;
}
function parseCompositions(html: string): CompositionInfo[] {
function parseCompositions(html: string, baseDir: string): CompositionInfo[] {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
@@ -30,6 +32,18 @@ function parseCompositions(html: string): CompositionInfo[] {
const id = div.getAttribute("data-composition-id") ?? "unknown";
const width = parseInt(div.getAttribute("data-width") ?? "1920", 10);
const height = parseInt(div.getAttribute("data-height") ?? "1080", 10);
const compositionSrc = div.getAttribute("data-composition-src");
// If this references an external sub-composition, parse that file
if (compositionSrc) {
const subPath = resolve(baseDir, compositionSrc);
if (existsSync(subPath)) {
const subHtml = readFileSync(subPath, "utf-8");
const subInfo = parseSubComposition(subHtml, id, width, height);
compositions.push({ ...subInfo, source: compositionSrc });
return;
}
}
const timedChildren = div.querySelectorAll("[data-start]");
let maxEnd = 0;
@@ -67,6 +81,62 @@ function parseCompositions(html: string): CompositionInfo[] {
return compositions;
}
function parseSubComposition(
html: string,
fallbackId: string,
fallbackWidth: number,
fallbackHeight: number,
): CompositionInfo {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
// Sub-compositions may use <template> wrappers or direct divs
const compDiv =
doc.querySelector("[data-composition-id]") ??
doc.querySelector("template [data-composition-id]");
const id = compDiv?.getAttribute("data-composition-id") ?? fallbackId;
const width = parseInt(compDiv?.getAttribute("data-width") ?? String(fallbackWidth), 10);
const height = parseInt(compDiv?.getAttribute("data-height") ?? String(fallbackHeight), 10);
// Count timed elements inside the sub-composition
const searchRoot = compDiv ?? doc;
const timedChildren = searchRoot.querySelectorAll("[data-start], .clip, .caption-group");
let elementCount = timedChildren.length;
// Parse duration from the composition's own data-duration attribute
let duration = 0;
const durationAttr = compDiv?.getAttribute("data-duration");
if (durationAttr && !durationAttr.startsWith("__")) {
duration = parseFloat(durationAttr) || 0;
}
// Also check timed children for max end time
if (compDiv) {
const timedEls = compDiv.querySelectorAll("[data-start]");
timedEls.forEach((el) => {
elementCount = Math.max(elementCount, timedEls.length);
const start = parseFloat(el.getAttribute("data-start") ?? "0");
const endAttr = el.getAttribute("data-end");
const durAttr = el.getAttribute("data-duration");
let end: number;
if (endAttr) {
end = parseFloat(endAttr);
} else if (durAttr) {
end = start + parseFloat(durAttr);
} else {
end = start + 5;
}
if (end > duration) {
duration = end;
}
});
}
return { id, duration, width, height, elementCount };
}
export default defineCommand({
meta: { name: "compositions", description: "List all compositions in a project" },
args: {
@@ -78,7 +148,7 @@ export default defineCommand({
const html = readFileSync(project.indexPath, "utf-8");
ensureDOMParser();
const compositions = parseCompositions(html);
const compositions = parseCompositions(html, dirname(project.indexPath));
if (compositions.length === 0) {
console.log(`${c.success("◇")} ${c.accent(project.name)} — no compositions found`);
@@ -107,8 +177,9 @@ export default defineCommand({
const elements = c.dim(
`${comp.elementCount} ${comp.elementCount === 1 ? "element" : "elements"}`,
);
const source = comp.source ? c.dim(`${comp.source}`) : "";
console.log(` ${id} ${duration} ${resolution} ${elements}`);
console.log(` ${id} ${duration} ${resolution} ${elements}${source}`);
}
},
});
+8 -8
View File
@@ -7,7 +7,7 @@ export const examples: Example[] = [
["Render transparent WebM overlay", "hyperframes render --format webm --output overlay.webm"],
["High quality at 60fps", "hyperframes render --fps 60 --quality high --output hd.mp4"],
["Deterministic render via Docker", "hyperframes render --docker --output deterministic.mp4"],
["Parallel rendering with 4 workers", "hyperframes render --workers 4 --output fast.mp4"],
["Parallel rendering with 6 workers", "hyperframes render --workers 6 --output fast.mp4"],
];
import { cpus, freemem } from "node:os";
import { resolve, dirname, join } from "node:path";
@@ -28,9 +28,9 @@ const VALID_FORMAT = new Set(["mp4", "webm"]);
const CPU_CORE_COUNT = cpus().length;
/** Half of CPU cores, capped at 4. Each worker spawns a Chrome process (~256 MB). */
/** 3/4 of CPU cores, capped at 8. Each worker spawns a Chrome process (~256 MB). */
function defaultWorkerCount(): number {
return Math.max(1, Math.min(Math.floor(CPU_CORE_COUNT / 2), 4));
return Math.max(1, Math.min(Math.floor((CPU_CORE_COUNT * 3) / 4), 8));
}
export default defineCommand({
@@ -66,8 +66,8 @@ export default defineCommand({
workers: {
type: "string",
description:
"Parallel render workers (1-8 or 'auto'). Default: half your CPU cores, max 4. " +
"Each worker launches a separate Chrome process.",
"Parallel render workers (number or 'auto'). Default: auto. " +
"Each worker launches a separate Chrome process (~256 MB RAM).",
},
docker: {
type: "boolean",
@@ -123,8 +123,8 @@ export default defineCommand({
let workers: number | undefined;
if (args.workers != null && args.workers !== "auto") {
const parsed = parseInt(args.workers, 10);
if (isNaN(parsed) || parsed < 1 || parsed > 8) {
errorBox("Invalid workers", `Got "${args.workers}". Must be 1-8 or "auto".`);
if (isNaN(parsed) || parsed < 1) {
errorBox("Invalid workers", `Got "${args.workers}". Must be a positive number or "auto".`);
process.exit(1);
}
workers = parsed;
@@ -155,7 +155,7 @@ export default defineCommand({
const workerLabel =
args.workers != null
? `${workerCount} workers`
: `${workerCount} workers (auto \u2014 half of ${CPU_CORE_COUNT} cores)`;
: `${workerCount} workers (auto ${CPU_CORE_COUNT} cores detected)`;
console.log("");
console.log(
c.accent("\u25C6") +
+23 -8
View File
@@ -1,12 +1,13 @@
import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
import * as clack from "@clack/prompts";
import { execSync } from "node:child_process";
import { c } from "../ui/colors.js";
export const examples: Example[] = [
["Check for updates interactively", "hyperframes upgrade"],
["Check for updates without prompting", "hyperframes upgrade --check"],
["Show upgrade commands directly", "hyperframes upgrade --yes"],
["Upgrade non-interactively", "hyperframes upgrade --yes"],
];
import { VERSION } from "../version.js";
import { checkForUpdate, withMeta } from "../utils/updateCheck.js";
@@ -66,12 +67,26 @@ export default defineCommand({
}
}
console.log();
console.log(` ${c.accent("npm install -g hyperframes@" + result.latest)}`);
console.log(` ${c.dim("or")}`);
console.log(` ${c.accent("npx hyperframes@" + result.latest + " --version")}`);
console.log();
clack.outro(c.success("Run one of the commands above to upgrade."));
const installCmd = `npm install -g hyperframes@${result.latest}`;
if (autoYes) {
console.log();
console.log(` ${c.dim("Running:")} ${c.accent(installCmd)}`);
console.log();
try {
execSync(installCmd, { stdio: "inherit" });
clack.outro(c.success(`Upgraded to v${result.latest}`));
} catch {
clack.outro(c.dim("Install failed. Try running manually:"));
console.log(` ${c.accent(installCmd)}`);
process.exitCode = 1;
}
} else {
console.log();
console.log(` ${c.accent(installCmd)}`);
console.log(` ${c.dim("or")}`);
console.log(` ${c.accent("npx hyperframes@" + result.latest + " --version")}`);
console.log();
clack.outro(c.success("Run one of the commands above to upgrade."));
}
},
});
@@ -1,95 +0,0 @@
<template id="captions-template">
<div
data-composition-id="captions"
data-width="1920"
data-height="1080"
data-duration="__VIDEO_DURATION__"
>
<div id="captions-container"></div>
<style>
[data-composition-id="captions"] {
width: 1920px;
height: 1080px;
pointer-events: none;
}
[data-composition-id="captions"] #captions-container {
position: absolute;
bottom: 100px;
left: 50%;
transform: translateX(-50%);
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 150px;
}
.caption-group {
position: absolute;
opacity: 0;
font-family: "Inter", sans-serif;
font-size: 48px;
font-weight: 700;
color: #ffffff;
text-shadow:
0 2px 8px rgba(0, 0, 0, 0.8),
0 0 2px rgba(0, 0, 0, 0.9);
white-space: nowrap;
max-width: 1600px;
overflow: hidden;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
(function () {
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
const script = [];
if (script.length === 0) {
window.__timelines["captions"] = tl;
return;
}
const container = document.getElementById("captions-container");
// Group words into lines (max 5 words per line)
const lines = [];
for (let i = 0; i < script.length; i += 5) {
const lineWords = script.slice(i, i + 5);
lines.push({
text: lineWords.map((w) => w.text).join(" "),
start: lineWords[0].start,
end: lineWords[lineWords.length - 1].end,
});
}
lines.forEach((line, index) => {
const el = document.createElement("div");
el.className = "caption-group";
el.textContent = line.text;
container.appendChild(el);
tl.fromTo(
el,
{ opacity: 0, y: 20 },
{ opacity: 1, y: 0, duration: 0.3, ease: "power3.out" },
line.start,
);
const hideTime =
index < lines.length - 1 ? Math.min(line.end, lines[index + 1].start) : line.end;
tl.to(el, { opacity: 0, y: -10, duration: 0.25, ease: "power2.in" }, hideTime - 0.25);
tl.set(el, { opacity: 0, visibility: "hidden" }, hideTime);
});
window.__timelines["captions"] = tl;
})();
</script>
</div>
</template>
+9 -10
View File
@@ -48,21 +48,20 @@
data-volume="1"
></audio>
<div
id="captions-comp"
data-composition-id="captions"
data-composition-src="compositions/captions.html"
data-start="0"
data-duration="__VIDEO_DURATION__"
data-track-index="3"
data-width="1920"
data-height="1080"
></div>
<!--
ANIMATION PATTERN: The clip div controls timing/visibility.
Always put your content in a CHILD element and animate THAT.
<div class="clip" ...> ← timing only, don't animate this
<div id="my-title">...</div> ← animate this with GSAP
</div>
-->
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// tl.from("#my-title", { opacity: 0, y: -50, duration: 1 }, 0);
window.__timelines["main"] = tl;
</script>
</body>
+47 -4
View File
@@ -184,6 +184,44 @@ function getSingleClassSelector(selector: string): string | null {
return match?.groups?.name || null;
}
function cssTransformToGsapProps(cssTransform: string): string | null {
const parts: string[] = [];
// translate(-50%, -50%) or translate(X, Y)
const translateMatch = cssTransform.match(
/translate\(\s*(-?[\d.]+)(%|px)?\s*,\s*(-?[\d.]+)(%|px)?\s*\)/,
);
if (translateMatch) {
const [, xVal, xUnit, yVal, yUnit] = translateMatch;
if (xUnit === "%") parts.push(`xPercent: ${xVal}`);
else parts.push(`x: ${xVal}`);
if (yUnit === "%") parts.push(`yPercent: ${yVal}`);
else parts.push(`y: ${yVal}`);
}
// translateX(-50%) or translateX(px)
const txMatch = cssTransform.match(/translateX\(\s*(-?[\d.]+)(%|px)?\s*\)/);
if (txMatch) {
const [, val, unit] = txMatch;
parts.push(unit === "%" ? `xPercent: ${val}` : `x: ${val}`);
}
// translateY(-50%) or translateY(px)
const tyMatch = cssTransform.match(/translateY\(\s*(-?[\d.]+)(%|px)?\s*\)/);
if (tyMatch) {
const [, val, unit] = tyMatch;
parts.push(unit === "%" ? `yPercent: ${val}` : `y: ${val}`);
}
// scale(N)
const scaleMatch = cssTransform.match(/scale\(\s*([\d.]+)\s*\)/);
if (scaleMatch) {
parts.push(`scale: ${scaleMatch[1]}`);
}
return parts.length > 0 ? parts.join(", ") : null;
}
// ── GSAP rules ─────────────────────────────────────────────────────────────
export const gsapRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
@@ -334,6 +372,14 @@ export const gsapRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
for (const [sel, { cssTransform, props, raw }] of conflicts) {
const propList = [...props].join("/");
const gsapEquivalent = cssTransformToGsapProps(cssTransform);
const fixHint = gsapEquivalent
? `Remove \`transform: ${cssTransform}\` from CSS and replace with GSAP properties: ${gsapEquivalent}. ` +
`Example: tl.fromTo('${sel}', { ${gsapEquivalent} }, { ${gsapEquivalent}, ...yourAnimation }). ` +
`tl.fromTo is exempt from this rule.`
: `Remove the transform from CSS and use tl.fromTo('${sel}', ` +
`{ xPercent: -50, x: -1000 }, { xPercent: -50, x: 0 }) so GSAP owns ` +
`the full transform state. tl.fromTo is exempt from this rule.`;
findings.push({
code: "gsap_css_transform_conflict",
severity: "warning",
@@ -342,10 +388,7 @@ export const gsapRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
`${propList}. GSAP will overwrite the full CSS transform, discarding any ` +
`translateX(-50%) centering or CSS scale value.`,
selector: sel,
fixHint:
`Remove the transform from CSS and use tl.fromTo('${sel}', ` +
`{ xPercent: -50, x: -1000 }, { xPercent: -50, x: 0 }) so GSAP owns ` +
`the full transform state. tl.fromTo is exempt from this rule.`,
fixHint,
snippet: truncateSnippet(raw),
});
}
+14 -5
View File
@@ -150,17 +150,26 @@ export async function initializeSession(session: CaptureSession): Promise<void>
const type = msg.type();
const text = msg.text();
// Suppress font-loading 404s only. These are expected when deterministic
// Suppress font-loading 404s entirely. These are expected when deterministic
// font injection replaces Google Fonts @import URLs with embedded base64.
// Narrowed to font CDN domains and font file extensions to avoid hiding
// real asset failures (images, scripts, videos).
const isFontLoadError =
type === "error" &&
text.startsWith("Failed to load resource") &&
/fonts\.googleapis|fonts\.gstatic|\.woff2?(\b|$)/i.test(text);
const prefix =
type === "error" ? "[Browser:ERROR]" : type === "warn" ? "[Browser:WARN]" : "[Browser]";
// Other "Failed to load resource" 404s are typically non-blocking (e.g.
// favicon, sourcemaps, optional assets). Prefix them so users know they
// are harmless and don't confuse them with real render errors.
const isResourceLoadError =
type === "error" && text.startsWith("Failed to load resource") && !isFontLoadError;
const prefix = isResourceLoadError
? "[non-blocking]"
: type === "error"
? "[Browser:ERROR]"
: type === "warn"
? "[Browser:WARN]"
: "[Browser]";
if (!isFontLoadError) {
console.log(`${prefix} ${text}`);
}
@@ -243,6 +243,26 @@ function buildFontFaceCss(requestedFamilies: Map<string, string>): {
};
}
function warnUnresolvedFonts(unresolved: string[]): void {
const mapped = Object.entries(FONT_ALIASES)
.reduce<string[]>((acc, [alias, canonical]) => {
const display = alias === canonical ? alias : `${alias}${canonical}`;
if (!acc.includes(display)) acc.push(display);
return acc;
}, [])
.sort();
console.warn(
`[Compiler] No deterministic font mapping for: ${unresolved.join(", ")}\n` +
` Mapped fonts: ${mapped.join(", ")}\n` +
` To fix, pick one:\n` +
` 1. Use a mapped font name instead (see list above)\n` +
` 2. Add a @font-face block in your HTML with a local or hosted font file\n` +
` 3. Install the font locally on the render machine (Docker: add to Dockerfile)\n` +
` 4. Add an alias to FONT_ALIASES in deterministicFonts.ts (for contributors)\n` +
` Docs: https://hyperframes.heygen.com/docs/fonts`,
);
}
export function injectDeterministicFontFaces(html: string): string {
const existingFaces = extractExistingFontFaces(html);
const requestedFamilies = extractRequestedFontFamilies(html);
@@ -261,7 +281,7 @@ export function injectDeterministicFontFaces(html: string): string {
const { css, unresolved } = buildFontFaceCss(pendingFamilies);
if (!css) {
if (unresolved.length > 0) {
console.warn(`[Compiler] No deterministic font mapping for: ${unresolved.join(", ")}`);
warnUnresolvedFonts(unresolved);
}
return html;
}
@@ -281,7 +301,7 @@ export function injectDeterministicFontFaces(html: string): string {
`[Compiler] Injected deterministic @font-face rules for ${pendingFamilies.size - unresolved.length} requested font families`,
);
if (unresolved.length > 0) {
console.warn(`[Compiler] Unresolved font families left dynamic: ${unresolved.join(", ")}`);
warnUnresolvedFonts(unresolved);
}
return document.toString();