Review follow-up on the STUDIO-5433 defense.
Correctness
- The sniff ran above the documented video/audio failure split, so an
<audio> src that resolved to an HTML payload aborted the whole render
instead of degrading to duration 0. It now runs inside the same try, so
video surfaces the typed error while audio still drops out, with a
warning naming the element.
- Raw fs errors (EISDIR on a directory src, EACCES, the existsSync->open
ENOENT race, EMFILE) escaped and failed the compile with an unclassified
error carrying an unredacted temp path. The sniff is now a classifier that
never throws: an unreadable file reports "not markup" and the real probe
produces the real error.
- Elements whose duration the compiler never resolves (a data-end video, a
looping audio) skipped the sniff entirely, so the original ffprobe error
still escaped, and looping audio was reported as owner "system" after
every frame had been captured. Video is now caught in the asset preflight,
which sees every local src regardless of authored timing; audio is
classified per-element in audioMixer as source/invalid_media/owner "user",
keeping audio failures non-fatal as they already were.
- Detection is a byte-level check for a leading "<" (BOM-, whitespace- and
NUL-tolerant, looped read) instead of a <!doctype|<html|<?xml string
prefix, which missed a NUL-prefixed payload, >256B of leading whitespace,
UTF-16-encoded HTML, and a prolog-less <svg. No supported container starts
with "<", so the allowlist no longer grows per payload shape.
- finally { await fh.close() } could replace the in-flight typed error with
the close error.
Routing and privacy
- MARKUP_NOT_MEDIA is now in SAFE_RENDER_ERROR_CODES, the Lambda terminal
name map, the CDK and SAM non-retryable plan lists, and the Cloud Run
non-retryable set, and the class carries owner/retryable. Previously the
API emitted errorCode: undefined and a deterministic authoring bug burned
the full distributed retry budget.
- The message no longer carries 32 raw payload bytes or the src.
redactTelemetryString preserves host and path for HTTP srcs, so
per-tenant CDN paths reached a message the server forwards to clients.
Correlation is a sha256 element fingerprint, matching
AssetMediaTypeMismatchError.
- The message names both causes (unresolved nested-composition URL, or an
HTML/XML error page served as 200) rather than misdiagnosing an S3 403
body as an authoring bug.
Tests
- Byte-level detection is unit-tested in engine: markup shapes, BOMs,
UTF-16, nine container signatures, unreadable inputs.
- Replaced the tautological assertions. The old checks for "html" in and
"moov" absent from a fixed message template could not fail for any input.
- New coverage for audio degradation, the audioMixer classification, the
preflight video/image/audio split, and the API error metadata.
- The sibling htmlCompiler.mediaType failure was a vitest-under-bun runner
mismatch, not a missing ffmpeg binary. It passes, including the 4-wide
probe-semaphore invariant the sniff now runs inside.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The user downloaded the actual daily posts and matched each to its day. Every
card now plays the real Day-N film — install, PR-to-video, keyframes in Studio,
background removal on a checkerboard, color grading, components, the lot — over
its own X-post link, no borrowed docs clips.
Day 7's five motion-graphics pieces are concatenated into one film. Day 26 had no
dedicated post, so it uses the media-effects showcase. Day 30 is built here: a
second from every one of the twenty-nine lessons, back to back — thirty for thirty.
All thirty are web-encoded (1280 wide, CRF 28) and live on the CDN. 0 dead URLs.
The "One project, open end to end" section on Examples was four cards linking into
a GitHub folder. A reader on the docs site does not want to leave for a repo tree
to read a BRIEF.md. The section is gone, and so is the examples/docs-reference-project
folder it pointed at.
Also removed the prose references that leaned on it: the Reference Project
paragraph on Go further, the two GitHub-inspect links on Developers, and the
mention on the Studio landing. Each was reworded to talk about "a project"
generally rather than that specific folder.
Examples is now purely the nineteen finished films plus Start from a template.
The changelog entry recording #2977 is left as history. 0 broken links.
The user is supplying the real thirty daily films. Until then this page must not
substitute launch or docs-demo clips for them — that is exactly the reuse the
whole video pass was meant to end.
Removed every borrowed clip: the four hero Frames that pulled the launch film and
the Day 3/4/23 demo cuts, and the docs thumbnails standing in for day posters.
What stays is honest and complete-looking: the thirty day cards, each linking its
real X post, each with a named slot — thirty-days-day-NN.mp4 — so the real files
drop straight in.
Preflight failed on skills/media-use/audio/references/tts.md — the section I added
was not oxfmt-clean. Formatted, and regenerated skills-manifest.json since the
media-use hash changed.
The other files oxfmt flags (package.json files, several skill .md files,
studio/parsers sources) are not touched by this branch and fail on main too — a
pre-existing whole-repo format debt, not introduced here.
**Examples was four films borrowed from other pages. It is nineteen now**, all
freshly rendered, grouped by capability rather than dumped in one wall: product &
launch films, the tooling shown working, motion/sound/effects, and workflows &
integrations. Every one is 100% HyperFrames; where the source project is public
the card links to it. This is the page a reader lands on to see the spread of
what the framework can do, so it should show the spread.
**30 Days of HyperFrames was its own top-level nav group for a single page.**
Moved into Explore alongside Examples, Product updates, Weekly updates and the
Changelog, where a once-a-day series belongs.
Added the house-narrator section to media-use/audio/references/tts.md and did not
regenerate skills-manifest.json, so 'Skills: manifest in sync' failed. Regenerated;
only the media-use content hash changed.
**Nine guide videos rebuilt in the light house style.** They were dark — a reader
clicking between sibling guides watched the theme flip. mcp, performance,
rendering, skills, html-in-canvas and remove-background were restyled to the bone
field; faceless-explainer, product-launch and voice-and-audio were re-narrated
with River, the house voice the user-journey films use. Measured luma is 177-230,
matching the journey films, and every one carries audio.
The restyle also surfaced a real bug in the frame itself: the house coral
#D96A4F is 2.95:1 on bone, under the WCAG large-text floor, and `hyperframes
check` failed on it. Darkened to #B44E33 (4.43:1) before any of these rendered.
**canary-rollouts trimmed from 2,443 words to 520.** The statistical-calibration
essay — pre-registered experiment design, a probability formula, rejected
hardware-fingerprinting alternatives — was never documentation for someone
shipping a staged rollout. Cut to Add one / Override one / Remove one / Where it
lives, the part that was already good.
**thirty-days now shows the films.** It was 431 words and 30 links to x.com for a
series entirely about videos, with no thumbnails. It now leads with real preview
clips where a film exists and marks the rest with a visual placeholder rather
than a dead off-site link.
STUDIO-5433 defense: when the downloaded media file begins with
<!DOCTYPE, <html, or <?xml, throw a typed HtmlNotVideoError naming
the offending src instead of letting ffprobe emit an inscrutable
moov-atom-not-found on a plain HTML page.
Complements #3033 diagnosability layer. Root-cause EF fix ships
separately.
Signed-off-by: Via <vance@heygen.com>
There are ten creation workflows. The Workflows nav group held eight of them plus
`guides/export-and-share`, which is not a workflow at all — there is no such
skill, it is a finishing task. The tenth, the Remotion port, sat under
Developers > Composition, design & animation, a different tab entirely.
So: export-and-share moved to Build the project where the other task pages live,
the Remotion page moved into Workflows, and the chooser gained a tenth route. The
chooser rendered `<video>` unconditionally, which would have drawn a black
rectangle for a route with no clip — it now renders a neutral tile instead, and
custom.css styles it to the same box.
**The Prompt Guide and the workflow guides did not know about each other.** Level 1
is organised one page per workflow, and five of its six pages linked to no guide
at all. A reader learning to prompt for motion graphics never discovered
/guides/motion-graphics, and the traffic went nowhere in both directions. Each
Level 1 page now names its workflow and says what that page adds.
Four links pointed at redirects rather than the real page — /guides/claude-design,
/guides/common-mistakes, /guides/website-to-video, /guides/media-overlays. Seven
files now link to the destination directly.
Checked after: 0 broken links, 0 links via redirect.
There are ten creation workflows — confirmed against both
skills/hyperframes/references/routes/ and the skill directories, which agree.
Nine were findable. Two were not.
This page covers `/embedded-captions` and `/talking-head-recut` and named neither
of them, anywhere, once. It described the difference well — "add captions" versus
"add designed overlays" — but a reader who wanted to invoke one directly had no
way to learn what it was called, and neither name appears in the navigation.
Both are now named where the distinction is drawn, with what each actually does:
words on screen versus designed cards on screen, and the reminder that both leave
the footage untouched.
remotion-to-hyperframes checks out — it is named on three pages including the
Remotion comparison, so it was reachable already.
The page promised "every prompt below has been run end-to-end", then a Note
admitted the Level 3 motion grammar was "stated once here rather than repeated in
all of them". So the printed prompts are not the prompts that produced the
embedded videos, and a reader copying one gets a different result with no way to
know why.
Rather than pretend, the page now says it plainly: each video came from the prompt
beneath it plus a shared preamble, copy both, and the preamble is labelled as the
thing to paste first. The title stays honest because the prompts *were* run — just
not alone.
Five prompts also ended on a frozen frame — "Hold on the card at the end.",
"settles on white, holds.", "then holds.", "hold.", "Hold the last 1.5s." — while
the same Note claimed every one asks for an ambient idle. motion.mdx:62 calls a
motionless hold the tell to avoid, and motion-graphics.mdx already carries the
corrected form of two of these. All five now end the way the rule says.
Five places across two SDK pages said this error means the GSAP parser engine "is
not yet available" / "ships in a later phase" / "until it ships". The parser is
shipped — sdk/src/document.ts:13 and engine/mutate.ts:58 both import and call
parseGsapScriptAcornForWrite.
What the code actually returns (mutate.ts:1745-1751):
"No gsap.timeline() declaration found in the GSAP script."
hint: "addGsapTween / addLabel require a timeline variable
(e.g. var tl = gsap.timeline(...))."
It is a fixable authoring problem in the reader's own composition, and the fix is
one line. Told it is an unshipped feature, a reader waits instead — and the Note
listing six ops as unavailable made a working part of the SDK look absent.
All five sites now say what the error means and how to clear it, including the
code comment in the guide's example.
**canvas.mdx pointed at a Design section that does not exist.** The table sent
readers to "3D". PropertyPanelFlat.tsx renders Text, Style, Layout, Motion,
Grade, Effects, Overlays, Media — no 3D. 3D Transform is a labelled block inside
Layout (propertyPanelFlatLayoutSection.tsx:302). The table also had no row for
Motion or Effects, while the approved media-effects guide tells readers to "open
Effects in the Inspector" — so the canonical map of the Inspector said that
section did not exist. Four rows fixed or added.
**The two-card reuse example could not do what its prose promised.** Both
concepts/compositions.mdx and concepts/variables.mdx omitted data-duration on the
nested-composition hosts, then said `data-start="card-pro"` means "start when
that one ends". With no duration, resolveDurationForElement returns null and
startResolver.ts:180-184 falls back to targetStart + offset = 0 — both cards
stacked at t=0 on one track, which html-schema.mdx:104 says must never happen and
:99 lists data-duration as required for nested-composition clips. Both copies now
carry it.
**`declareVariable`'s field is `declaration`, not `decl`.** Documented wrong in
three places across two SDK reference pages. This one fails *silently* — the
handler reads `op.declaration`, gets undefined, and returns EMPTY at
sdk/src/engine/mutate.ts:991. No throw, no patch, no error. A reader copies the
example and nothing happens. types.mdx opens by claiming it was verified against
types.ts, which is where the correct name is (line 134).
**Colour grading says eighteen presets; the Grade panel has thirteen.** Eighteen
is the combined total. The other five carry effects and live in the Effects
panel — colorGrading.ts derives GRADE_PRESETS by filtering EFFECT_PRESETS out,
and colorGrading.test.ts:25 asserts VHS Playback is *not* in Grade. I wrote that
sentence today and used VHS Playback as the example, so the page sent a reader to
Grade looking for something that is not there. The visual brief further down had
the same error baked in; fixed before anyone shoots it.
**`hyperframes render` on a slideshow does not fail — it lies.** The page said
export "is not currently supported", which reads like the command errors. The
skill is emphatic: a deck has no master root composition, so render resolves only
the first slide and writes a silently truncated MP4 — six seconds of a
forty-second deck, no warning. That is now a Warning saying exactly that.
I also swept every documented edit-operation field against sdk/src/types.ts.
Six looked wrong and five were my regex not stripping the `?` optional marker;
`easeEach` and `resolvedSelector` are real, at types.ts:178-179. One genuine
error out of that sweep, the one above.
**`bun unlink hyperframes` does not exist.** Bun answers "error: bun unlink
{packageName} not implemented yet" — I ran it. The command takes no package name;
it unregisters whatever directory you are standing in. The reader arrives at that
block having just been told to cd into a video project, so even the correct form
would unregister the wrong thing and leave the global link in place — which is
exactly the failure the same page's troubleshooting section then explains. The
page created the bug it diagnosed. Now `cd packages/cli && bun unlink`.
**`data-media-start` and `data-playback-start` are one value, not two features.**
The schema page gave them separate rows with different-sounding meanings, so a
reader would reasonably think they compose. Six read sites in core are all
`playbackStart ?? mediaStart` — runtime/media.ts:18 and :92, runtime/init.ts:751,
1993, 2928, 3010. One row now, alias named, and it says which wins.
**Canary rollouts contradicted itself.** Line 109 called enrolment a pure function
of `(feature, installId, percentage)`; line 195 said canaries bucket on a
dedicated `bucketSeed`, not the telemetry id. The second is right —
cli/src/telemetry/canary.ts:98 is `unitId: config.bucketSeed ?? config.anonymousId`.
Also on the Studio shortcuts page earlier: J/Shift+J for keyframe navigation,
Backspace to delete, and Ctrl+Y for redo were all missing.
`npx hyperframes compare` was shown with no arguments. The command needs two or
more paths ("pass 2+ paths" in its own help), so a reader copying that line gets
an error on their first try. It now shows two.
The closing section was six bullets joined by semicolons — a sentence pretending
to be a list. It is a sentence now.
While writing that sentence I claimed every command can print JSON. It cannot:
`snapshot` and bare `skills` have no --json, though `skills check` does. Changed
to "most commands", which is true. Checked twelve commands against their own
--help rather than against the page.
Read against packages/studio/src rather than against the page's own prose.
- `J` / `Shift + J` jump to the previous and next keyframe
(useKeyframeKeyboard.ts:60). The keyframe table had no row for J at all.
- `Backspace` deletes a keyframe, not only `Delete` (same file, line 56).
- `Ctrl + Y` is redo (useAppHotkeys.ts:44). Only Ctrl+Shift+Z was listed, and
Ctrl+Y is the first thing a Windows user reaches for.
The page already warned that arrow keys mean different things depending on
selection. `J` and `K` do exactly the same and it did not say so — `K` is stop
during playback and add-keyframe with keyframes selected, which is a surprising
way to lose your place. That note now names them.
Everything else on the page checks out: 20 single-letter bindings in the source,
all now accounted for.
Every user-journey film on the docs site is narrated by ElevenLabs River
(SAz9YHcvj6GT2YYXdXww) at 145-155 wpm with music about -31 LUFS under it. That
was recorded in one launch project's notes and nowhere an agent would look.
So when I briefed six new docs videos I asked for "a music bed plus SFX" and said
nothing about voice. Two fell back to local Kokoro (am_michael, bm_george) and one
used an unspecified ElevenLabs take. Three films, three narrators, none of them
the one the rest of the site uses. Being re-voiced now.
The rule is in skills/media-use/audio/references/tts.md, next to the provider
table an agent already reads before generating a voiceover, including the reason:
falling back to a local voice because a key was not to hand produces a film that
sounds wrong beside the others. If ElevenLabs cannot be reached, say so and stop
rather than substituting.
Also on this branch: the superseded Huly film is gone from the product-launch
page, and three pages that ended up with two hero videos stacked now lead with
one. Where the older clip still showed something different — a finished motion
graphic, the same edits done in Studio — it moved below under its own heading
instead of being deleted.
A probe that could not run is no evidence about the GPU, so pointing the
operator at GPU passthrough hid broken Chrome installs behind a phantom
problem. Carry a cause off the probe and emit the matching remediation.
Also un-exports buildUnverifiedHardwareGpuWarning (Fallow: engine test
files are not audit entry points, so a test-only import would not have
counted as a consumer) and covers the non-linux branch via the spy.
Thirteen manifests declare `preview: { video }` with no `poster` key, and that
omission is deliberate — no .png was ever produced for them. main reads it in one
line: `if (manifest.preview) return manifest.preview.poster`.
I deleted that line as "config nobody varies", having checked whether any poster
DIFFERED from the generated URL (none did, 100 of 100) and never having checked
whether any was ABSENT (thirteen were). Then I replaced it with a filesystem
check against docs/images, which is gitignored, so it was false everywhere and
would have stripped all 168. Then I removed the poster entirely. Three wrong
answers to a question the repo had already answered.
Restored, and the page's video tag now asks the same function rather than a
second source of truth. 155 of 168 carry a poster, matching main exactly: 154
video pages plus texture-mask-text, which renders mask images instead of a video.
The 13 remaining dead URLs are in catalog-index.json only, which no code in
docs/ or packages/ reads, and no rendered page requests them.
The complexity cuts to the encode pass stand: hasAudio deleted (ffmpeg ignores
-c:a with no audio stream), and the spawn+Promise wrapper is execFileSync.
**The poster guard I added twice was unworkable and I never ran it.** It called
existsSync on docs/images/catalog/<name>.png. That directory is gitignored —
previews are generated locally, uploaded to the CDN and never committed — so the
check is false on every clean checkout and in CI. It would have stripped the
poster from all 168 pages, not the 13 with a missing file. It also referenced
REPO_ROOT, which does not exist in that file, so the script crashed on the first
item. I described this guard in two commit messages without once executing the
generator.
The poster is now gone entirely, which is the smaller and more honest fix. These
previews are autoPlay muted loop, so the poster is visible for a few hundred
milliseconds; 13 of the 168 files do not exist and the browser fetches the poster
before the video. Removing the attribute kills 13 x 403 and 168 needless image
requests, and there is nothing to keep in sync.
Also applied a complexity pass to the delivery encode:
- hasAudio() deleted. 17 lines and a spawnSync per item to choose between
"-c:a aac" and "-an". ffmpeg ignores -c:a when the input has no audio stream;
checked, exit 0, output carries no audio track.
- The 40-line spawn + Promise wrapper is execFileSync. Everything around it in
that script is already synchronous.
- The duplicated poster lookup is gone with the poster itself.
Net 76 lines lighter. Generator runs clean, emits 168 pages, carry-forward intact.
Left unformatted by the catalog-generator commit, which is the one file
Preflight was failing on. oxfmt --check is clean on it now.
Note for anyone reading the earlier claim: I said main fails oxfmt on 30 files.
That was my local run picking up no repo config and using its own defaults. CI
found exactly one file, and it was mine.
An earlier commit here said generate-catalog-previews.ts "has a web pass now".
It did not. I made that edit in a second checkout while investigating and never
brought it onto the branch, so the claim shipped in a commit message while the
code stayed as it was.
The fix itself is unchanged from what was described: the render output is a
master, and publishing it directly is what put 25 Mbps files on the docs CDN.
generateVideo now renders to <name>.master.mp4, runs one delivery pass at 1280
wide, CRF 28, faststart, keeping audio only when the source has it, then deletes
the master and logs the delivered size.
Formatted and linted clean. The Format check failing on this PR is pre-existing:
origin/main fails oxfmt --check on the same 30 files, none of which this branch
touches.
Removing the eight-second teaser left this page with nothing at all, and the
replacement had been rendered and published hours earlier without ever being
wired in.
Twenty-seven seconds of a live deck: fragments arriving one key-press at a time,
a hotspot jumping to a branch slide, the back control returning to the exact
fragment it left, and presenter mode with real notes and a running timer. It is a
capture of the deck running, not a video of slides.
Guides with nothing to look at: 5.
Product launch, colour grading, faceless explainer, captions and recuts, motion
graphics, and voice and audio.
Five of these led with a six-to-eight second teaser. A six-second clip on the
page that teaches you to make explainers does not teach anything — it is a
placeholder that looks like content. Colour grading, the most visual feature in
the product, had no video at all.
The new films are 34 to 41 seconds and each shows a RANGE rather than one look:
four ways to tell the same idea with no footage; five caption treatments on one
untouched clip; correction, presets, curves, selective colour, all four scopes
and a LUT; narration with music ducking under it and effects on the beat. The
launch film is built by running the workflow on huly.io for real, so the page
that sells the workflow is showing its actual output.
They share one visual system, measured from HyperFrames own light-theme launch
films rather than invented: bone field, near-black type, coral accent, and the
product always a dark panel inset on the light field. Every one carries audio at
about -20 dB and is 1-2 MB.
The old teasers are removed from the three pages where they became a duplicate.
They stay in the workflow picker, which is where a six-second tile belongs.
Guides with nothing to look at: 15 at the start of this work, 5 now.
Measured across all 136 hand-written pages, the twelve hardest to read were all
in prompting/. These ten were untouched — 29% to 40% of their sentences ran over
28 words, against a site median under 15%.
media-and-audio 39.7% -> 6.2% 15 semicolons -> 6
visual-specs 37.1% -> 19.0% 11 -> 9
weekly-updates 39.1% -> 0.0%
runtimes-and-3d 34.1% -> 17.5%
design-systems 34.0% -> 4.3%
recreating-references 33.3% -> 7.3% 16 -> 1
storyboards 31.9% -> 5.4%
variables-and-templating 30.3% -> 7.3%
generated-artwork 29.4% -> 9.1%
explainers 29.1% -> 3.3% 12 -> 1
Word counts barely move — 1752 to 1748, 1633 to 1617 — which is the point. The
facts and worked examples all survived; the sentences carrying them got shorter.
weekly-updates is an archive, so no date, version or fact changed there at all.
One anchor came back: capstone#the-full-prompt-verbatim, which these pages link
to and which an earlier commit had already moved to #the-prompt-word-for-word.
Repointed. Branch total: 0 broken pages, 0 broken anchors.
The previous commit claimed all 597 media URLs returned 200. They did not — 13
still 403'd, and I pushed that claim without re-running the check after the fix.
The message was wrong; this is the correction.
Two files carry a poster, not one. Guarding the page generator left
catalog-index.json still pointing at 13 .png files that were never produced,
which is a broken thumbnail in the catalog grid rather than a missing one.
catalogPreviewFor now returns undefined when the file is absent, the same rule
the page preview uses, and the 13 stale entries are dropped from the written
index.
Verified after the change, not before: 584 media URLs on this branch, 0 dead.
My catalog rebuild added `poster="<name>.png"` to every preview. Thirteen items
have an .mp4 but no .png, so the page asked for an image that does not exist —
and a browser fetches the poster before the video, so those were thirteen 403s
on load. The merged version did not have this; I introduced it.
The generator checks the file now, the same way it already checks before telling
a reader to read a comment header that may not be there. Thirteen pages lose the
attribute; the videos are unaffected.
Also uploads the three weekly changelog re-encodes to the path they are actually
served from — hyperframes/changelog-videos/, not the docs image tree — which I
had got wrong, leaving three dead links in the previous commit.
All 597 media URLs on this branch now return 200.
Thirty-two published videos were the raw render output. One 20-second catalog
preview was 60 MB at 25 Mbps — Blu-ray bitrate for a screen capture in a docs
page. Nothing autoplays, so no page shipped that silently, but a reader who
pressed play paid for it.
Re-encoded at 1280 wide, CRF 28, faststart, keeping audio where it existed.
Measured PSNR against the originals at ~38.5 dB, which is visually identical —
the first check I ran returned nothing and I mistook that for a pass, so this
number comes from a filter that actually worked.
Worth correcting one thing I said earlier: the catalog is not systemically
over-encoded. 190 videos, 0.36 GB, about 1.9 MB each. Six were over 10 MB. I
found two monsters and generalised from them.
Pages carrying more than 8 MB of video: 26 before, and the heaviest went from
140 MB to under 15 MB.
The durable fix is in generate-catalog-previews.ts, which had no delivery encode
at all — it published the render master directly. It has a web pass now, so the
next preview is born small instead of being cleaned up later.
Chrome's hardware GL args are advisory: with no usable GPU it silently
falls back to software WebGL and the capture runs at CPU speed. Run the
existing WebGL probe for explicit hardware mode too and warn loudly with
the platform's remediation. The requested mode is still honoured.
Fixes#2967