mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-14 18:01:20 +08:00
286 lines
15 KiB
Markdown
286 lines
15 KiB
Markdown
# Verification report — v1 → v2
|
||
|
||
Project: `examples/docs-reference-project` · **1920×1080 · 10.000s · 30 fps · 300 frames**
|
||
CLI: `hyperframes@0.7.90` (project pin bumped from `0.7.88` during this pass and
|
||
re-verified — see "Toolchain" below).
|
||
|
||
---
|
||
|
||
## 1. Gate results
|
||
|
||
All commands were run in the project directory. These are the actual results.
|
||
|
||
| Gate | Result |
|
||
| ----------------------------------------------- | ----------------------------------------------------------- |
|
||
| `npx hyperframes lint --verbose` | **0 errors, 0 warnings** (2 files scanned) |
|
||
| `npx hyperframes check` | **passed** — `ok: true` |
|
||
| › lint | 0 errors · 0 warnings · 1 info |
|
||
| › runtime | 0 errors · 0 warnings · 0 info |
|
||
| › layout | 0 errors · 0 warnings · 1 info |
|
||
| › motion | 0 findings (no `*.motion.json` sidecars in this project) |
|
||
| › contrast | 0 errors · 0 warnings · 0 info |
|
||
| `check --caption-zone "…y0=.83…severity=error"` | **passed** — 0 caption-band collisions across 8 seek points |
|
||
| `npx hyperframes snapshot --at …` (19 frames) | captured + inspected; 3 contact sheets |
|
||
| `npx hyperframes render` | **passed** — H.264 1080p + AAC stereo, exactly 10.000s |
|
||
|
||
Final media inspection: 1920×1080 H.264, 30 fps, AAC stereo at 48 kHz,
|
||
10.000 seconds. Mean volume is −22.9 dB and the peak is −3.8 dB.
|
||
|
||
### The two info-level findings, and why they stay
|
||
|
||
Neither gates the exit code. Both are deliberate.
|
||
|
||
1. `pointer_events_none` on `compositions/captions.html` → `#root`.
|
||
The caption overlay spans the whole canvas above the artwork, so its root must be
|
||
click-through or nothing underneath is selectable in Studio. The pills themselves
|
||
carry `pointer-events: auto`, so the editable content _is_ selectable — which is
|
||
exactly what the finding's own fix hint asks for. Keeping `pointer-events: none` is
|
||
correct; the alternative is an invisible full-canvas div that eats every click.
|
||
|
||
2. `container_overflow` on `#title` at `t=0.556`, inside `span.title-mask`.
|
||
This is the mask doing its job: the title starts at `yPercent: 106` and rises into
|
||
view, so for the first ~0.7s its box is below the mask it is clipped by. Marking the
|
||
mask `data-layout-allow-overflow` would silence it, but that attribute is inherited
|
||
and would also disable `text-clipping`, `content-cramped-container` and
|
||
`foreground-over-panel` on the hero title for the whole composition. A transient info
|
||
finding is the cheaper price. The finding is reported once, at one sample.
|
||
|
||
### What "inspected snapshots" means here
|
||
|
||
19 frames on the **30 fps frame grid** (not arbitrary decimals), chosen to cover every
|
||
beat plus both caption cuts and the caption clear:
|
||
|
||
`0 · 0.533 · 1.3 · 1.967 · 2.6 · 2.8 · 2.867 · 3.133 · 4.5 · 5.0 · 5.433 · 5.5 · 5.733 · 6.4 · 7.1 · 7.933 · 8.067 · 9.5 · 9.967`
|
||
|
||
The root declares `data-fps="30"`, so those are real frame times. Requesting an
|
||
off-grid time (e.g. `2.833`) quantises to the nearest frame and renders `2.800` twice —
|
||
which briefly looked like a caption bleed until it was measured. It was not one.
|
||
|
||
Checked and confirmed:
|
||
|
||
- The plate lands at 1:1 and the page's real 16px body copy is legible — the headline,
|
||
the full sentence, and the `Learn more` link all read.
|
||
- The accent marker draws beside the page's own paragraph without touching the link
|
||
below it (6px clearance, measured).
|
||
- Both caption cuts hand off cleanly. At `2.800` only group 0 is drawn (fading out); at
|
||
`2.867` only group 1 (fading in). Same at `5.433` / `5.500`. This also holds by
|
||
construction: group _n_'s hard kill sits at exactly group _n+1_'s start, so before that
|
||
instant only _n_ can be non-zero and at/after it _n_ is set to `opacity: 0;
|
||
visibility: hidden`.
|
||
- Captions clear by `8.033s`: sampling the whole caption band at `8.067s` gives a
|
||
darkest pixel of `233` — pure canvas, nothing drawn. The frame then holds a still,
|
||
caption-free end card through the final frame at `9.967s`.
|
||
- No black frame, no blank panel, no clipped text, no element in the caption band.
|
||
|
||
`snapshot`'s optional Gemini frame-description pass failed (`API key not valid`) — the
|
||
ambient `GEMINI_API_KEY` is rejected. That is an optional annotation, not a gate; the
|
||
empty `descriptions.md` was deleted rather than shipped as a wall of identical errors.
|
||
Frames were inspected directly.
|
||
|
||
---
|
||
|
||
## 2. v1 → v2
|
||
|
||
v1 is the verified original at `quickstart/example-intro`: same request, same source,
|
||
same 10.0s / 1920×1080 output. v2 keeps its concept — _show the site as it is, the real
|
||
page is the proof_ — and its split composition. What changed, and why.
|
||
|
||
### 2.1 The capture actually reads now — the one that mattered
|
||
|
||
v1's biggest defect was invisible in the source and obvious in the render. The capture
|
||
was 1440×810 displayed inside a 940×529 card: **0.65×**. The page's 16px body text
|
||
rendered at roughly 10px in a 1080p frame, so the "real captured page" — the entire
|
||
argument of the video — was an unreadable grey smudge with a tiny cluster in one corner.
|
||
|
||
v2 shows the capture at **exactly 1:1**. A fresh 1920×1080 1x capture is displayed
|
||
through a 1000×400 window with `object-fit: none; object-position: -300px -104px`, so the
|
||
page's own type renders at the size it renders at in a browser. Three hard rules fall out
|
||
of that, and they are written into `frame.md`:
|
||
|
||
- the plate **never scales** (a 1x capture has no headroom above 1:1),
|
||
- the plate **never rotates** (v1 tilted it `rotationY: -10° → -4° → -1.5°`, resampling
|
||
the page text for the entire shot),
|
||
- the entrance is **`x` translate + opacity only**.
|
||
|
||
`object-fit: none` also means the `<img>` box is exactly 1000×400 instead of a 1920×1080
|
||
element hanging out of an `overflow: hidden` parent, so it needs no
|
||
`data-layout-allow-overflow` and trips no layout finding.
|
||
|
||
### 2.2 Decorative noise removed
|
||
|
||
| Removed from v1 | Why |
|
||
| ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||
| 160px background grid **and** 4px dot "paper" | Two textures stacked under a third layer (the light wash). Visible busywork on a flat page. |
|
||
| The sheen sweep across the card (`7.55 → 8.65s`) | A stock shine gimmick. It decorated the evidence instead of reading it. |
|
||
| The ambient accent bloom (`5.0s`, then `scale 1.05`) | Fired 2.5s after the plate landed, cued to nothing. |
|
||
| `https://example.com` caption under the card | A label repeating the `example.com` line already on screen, in near-invisible grey. |
|
||
| The `Learn more →` pill button | Invented UI. The page's action is a plain text link — and it is already visible _in the plate_, as itself. |
|
||
| The 1.9s `rotationY` "settle" + glow scale at the end | Lazy breathing. v2 holds completely still instead. |
|
||
|
||
What replaced them is **one** device with a job: a single 5px accent marker that draws
|
||
down the left edge of the page's own content block, on the narration cue
|
||
_"…documentation examples."_ It is the film's argument — _those are the page's words, not
|
||
ours_ — and it is the only new graphic element in v2.
|
||
|
||
### 2.3 Narration, captions and pacing (new in v2)
|
||
|
||
v1 was silent except for a music bed, so nothing cued anything; its beats were spaced by
|
||
feel (`0.1 · 0.45 · 1.55 · 2.5 · 3.9 · 5.0 · 6.5 · 7.55`).
|
||
|
||
v2 has narration, and **every visual cue is a measured word timing**, not an estimate:
|
||
`assets/narration.wav` → `npx hyperframes transcribe --model small.en` →
|
||
`transcript.json` → the tween positions in `index.html` and the group boundaries in
|
||
`compositions/captions.html`. Three phrase captions for three sentences, fixed position,
|
||
one visible at a time, per-word emphasis by luminance only.
|
||
|
||
Pacing consequence: v1 front-loaded the lockup then idled with decoration. v2's reveals
|
||
land at `1.00 · 1.55 · 1.78 · 2.66 · 4.55 · 6.42` — the last one inside the final 30% —
|
||
with two **deliberately empty** holds (`5.15 → 6.42` mid-film breather, `8.03 → 10.0`
|
||
still end card). The holds are left visibly empty in the timeline source, with comments
|
||
saying so, because a held read beats bad motion.
|
||
|
||
### 2.4 Determinism fix: non-embedded font weights
|
||
|
||
v1 asked for `font-weight: 500` on IBM Plex Mono and `600` on Inter. The renderer embeds
|
||
`inter` at **400/700/900** and `ibm-plex-mono` at **400/700** only, so both requests were
|
||
synthesised or substituted on a clean render machine — preview and output could disagree.
|
||
v2 uses only bundled weights: Inter 900 (title) / 400 (body), IBM Plex Mono 400
|
||
(address), Inter 700 (captions). `frame.md` states the rule so an editor cannot
|
||
reintroduce it.
|
||
|
||
### 2.5 Parameterisation (new in v2)
|
||
|
||
v1 had no variables — it was a one-off. v2 declares five with useful defaults and wires
|
||
them declaratively (`data-var-text`, `data-var-src`, `var(--accent)`), with no
|
||
`getVariables()` call anywhere, so the composition can front another site by flags alone.
|
||
|
||
### 2.6 Palette: one derived value, stated as derived
|
||
|
||
v1's brief claimed the palette was read from the capture, then set the canvas to
|
||
`#eeeeee` — the page's own background. The plate therefore had the same fill as the
|
||
ground and only its border separated them, which is why it read as a faint rectangle.
|
||
|
||
v2 keeps ink `#1b1b1b` and accent `#334488` from the captured page (its CSS literally
|
||
says `a { color:#348 }`) and steps the **canvas** down to `#e9e9e7`
|
||
so the plate has something to lift off. `frame.md` labels that as the one derived value
|
||
rather than pretending it was read.
|
||
|
||
### 2.7 One thing v2 gives up
|
||
|
||
v1's title was two hand-split `<span>`s waterfalling in 0.17s apart — a nicer reveal than
|
||
v2's single masked rise. That split is incompatible with `data-var-text`, which replaces
|
||
an element's own text and cannot drive per-line spans. v2 trades the waterfall for a
|
||
title that is actually a parameter. Called out here because it is a real regression, not
|
||
an oversight.
|
||
|
||
---
|
||
|
||
## 3. Two framework bugs found, with reproductions
|
||
|
||
Both were hit while building this project and both are fixed _in_ this project. Neither
|
||
is a blocker for it.
|
||
|
||
### 3.1 `window.getComputedStyle()` throws inside a sub-composition
|
||
|
||
**Severity: high** — the failure is near-silent and ships broken video.
|
||
|
||
The captions doctrine (`media-use/audio/references/captions/authoring.md` → "Self-lint
|
||
after building timeline") prescribes this snippet verbatim:
|
||
|
||
```js
|
||
var computed = window.getComputedStyle(el);
|
||
```
|
||
|
||
Inside a sub-composition it raises `TypeError: Illegal invocation`. The script dies
|
||
mid-self-lint, so `window.__timelines["captions"] = tl` never runs, the runtime waits out
|
||
its registration timeout, and the render captures whatever DOM state the throw left
|
||
behind. Observed symptom: caption group 0 frozen at ~14% opacity for the whole video,
|
||
with `check` reporting it only indirectly as 30 `contrast_aa_failure` errors against a
|
||
background of `rgb(205,205,203)` — a colour that exists nowhere in the design.
|
||
|
||
Root cause, measured from inside a sub-composition script:
|
||
|
||
```
|
||
window === globalThis → false
|
||
window.getComputedStyle === globalThis.getComputedStyle → true
|
||
window.__hyperframes.fitTextFontSize → function
|
||
window.__timelines → object
|
||
```
|
||
|
||
The runtime evaluates a sub-composition's inline script with a **`window` wrapper
|
||
object**. Property reads and writes proxy through to the real window, which is why
|
||
`__timelines` and `__hyperframes` work. But retrieving the _same_ native function through
|
||
the wrapper and calling it as a method makes the wrapper the receiver, and native code
|
||
rejects it.
|
||
|
||
Fix in this project (`compositions/captions.html`), with the reason in a comment:
|
||
|
||
```js
|
||
var computed = getComputedStyle(el); // bare — not window.getComputedStyle
|
||
```
|
||
|
||
`globalThis.getComputedStyle(el)` also works. Suggested upstream actions: make the
|
||
wrapper bind native `Window` methods, and fix the snippet in the captions doctrine —
|
||
every agent that follows it inside a sub-composition inherits this bug.
|
||
|
||
### 3.2 `lint`'s `missing_local_asset` mis-parses `data-var-src`
|
||
|
||
**Severity: low** — loud, harmless, one-line workaround.
|
||
|
||
```html
|
||
<img src="assets/example-com.png" data-var-src="pageImage" />
|
||
```
|
||
|
||
fails lint with `missing_local_asset: <img> element references local file(s) not found in
|
||
the project: pageImage`. The rule's regex is
|
||
`/<(video|img|source)\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi`; the greedy `[^>]*`
|
||
takes the **last** `src=` in the tag, and `data-var-src` ends in `src` with a `-` before
|
||
it, so `\b` matches and the variable id is read as a filename.
|
||
|
||
Workaround used here: author `data-var-src` **before** `src`. Suggested upstream fix:
|
||
require a whitespace or `"` boundary before `src` (`(?<=[\s"'])src\s*=`), or explicitly
|
||
skip `data-var-src`.
|
||
|
||
---
|
||
|
||
## 4. One documented authoring constraint
|
||
|
||
Not a bug, but a real trap worth stating: **keep `data-composition-variables` pure
|
||
ASCII.** It lives on `<html>`, which is consumed before `<meta charset>` is in effect, so
|
||
a literal em dash in a `default` renders as `â€"`. Verified both ways by snapshot: the
|
||
literal character mojibakes, the JSON escape `\u2014` renders a correct em dash. Text in
|
||
the document body and inside a sub-composition `<template>` is unaffected (the runtime
|
||
`fetch`es sub-compositions and decodes them as UTF-8).
|
||
|
||
v2 ships ASCII-only variable defaults and states the rule in a comment in `index.html`.
|
||
|
||
---
|
||
|
||
## 5. Toolchain
|
||
|
||
The project's `package.json` pinned `hyperframes@0.7.88`. Per the CLI's own upgrade
|
||
protocol the pin was probed before any render-affecting command:
|
||
|
||
```
|
||
npx hyperframes@latest upgrade --project . --check
|
||
→ would bump project scripts 0.7.88 → 0.7.90
|
||
```
|
||
|
||
Applied, then verified: `npx hyperframes check` passes on **0.7.90**. A passing check
|
||
confirms the compositions still validate on the new version — not that output is
|
||
frame-identical to the old pin. The project now runs on `0.7.90`; `hyperframes info`
|
||
reports `updateAvailable: false`.
|
||
|
||
---
|
||
|
||
## 6. Blockers
|
||
|
||
**None.** `lint` and `check` pass with zero errors and zero warnings, snapshots are
|
||
captured and inspected, and the final MP4 passed the media gate.
|
||
|
||
Two non-blocking environment notes: the ambient `GEMINI_API_KEY` is rejected by the API,
|
||
so `snapshot`'s optional vision descriptions are unavailable; and `hyperframes feedback`
|
||
was not sent, because the CLI's protocol sends it only after verifying a successful
|
||
render. Both framework findings in §3 are written up here in reproducible form so they
|
||
can be filed with that render.
|