* feat(cli): emit sign-in lifecycle telemetry
The CLI tracks command and render lifecycles but emits nothing for
`auth login`, so sign-in outcomes are invisible on the observability
dashboards — a completed sign-in, an abandoned browser flow, and a
rejected key all look identical (absent). This leaves a blind spot in
the same funnel the render events already cover.
Add three events mirroring the existing `trackX` pattern:
- auth_login_started (method: oauth | api_key)
- auth_login_completed (method)
- auth_login_failed (method, reason)
`reason` is a fixed low-cardinality enum (flow_error / no_credential /
rejected / invalid_input). No token, key, identity, email, or free text
is ever attached — consistent with the existing anonymous telemetry and
the `telemetry disable` opt-out. Wired into both the OAuth and
--api-key paths in `auth login`, with unit coverage for the new events.
* fix(cli): close sign-in telemetry funnel dropout gaps
Follow-up so `started` reconciles to `completed + failed` on the common
abandonment paths, which the first cut missed:
- Interactive prompt cancel (Ctrl-C) now surfaces as a throw that the
single catch in the api-key path records as `aborted`, instead of a
bare exit with no event.
- A stdin read that times out in non-TTY `--api-key` mode now records
`aborted` before the error propagates, rather than exiting silently.
- OAuth split: a timed-out browser callback (user closed the tab) is
tagged `flow_timeout`, separated from real `flow_error` (IdP/network),
since the walk-away timeout is the dominant non-error dropout.
Also pre-plumb an optional `distinctId` on the three trackers, mirroring
trackRenderComplete/trackRenderError. Unused today; it lets a later
identity-level attribution be a one-line callsite change rather than a
signature sweep. Coverage added for the new reasons and forwarding.
Closes the observability gaps on the figma integration:
- withFigmaErrors takes a command label (figma:asset|tokens|component) and
reports the failure inline before its process.exit — the top-level
trackCommandFailures wrapper never sees self-exiting commands, so typed
codes (NO_TOKEN, BAD_TOKEN, FORBIDDEN, RATE_LIMITED) were invisible.
FigmaClientError codes surface as the error name for dashboarding the
first-run funnel (NO_TOKEN -> later success = onboarding conversion).
- new figma_import event per import: phase, duration, reused (dedup
effectiveness), tokens variables-vs-styles mode + entry count
(Enterprise gating rate), unresolved-binding + rasterized-node counts
(fidelity degradation). No fileKeys, node ids, names, or descriptions.
- /figma skill fires the events beacon (figma-motion / figma-shaders /
figma-storyboard) for the MCP phases that never touch the CLI.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(cli/telemetry): surface unrecognized agents in the agent_runtime=null bucket
agent_runtime is a closed allowlist: an agent we have no rule for collapses
to null with no trace of what it was, so ~18% of CLI users are unattributable
and new agents stay invisible until reverse-engineered by hand.
Add detectAgentHints(), a self-populating residual signal computed only for
the null bucket (gated off classified events):
- agent_hint: value of AGENT / AI_AGENT (the emerging self-identification
convention; Crush and Goose set AGENT=<name>) — names agents the allowlist
misses.
- term_program: raw TERM_PROGRAM (editor name) — catches the IDE-terminal
class the same way the cursor/windsurf rules do.
- agent_env_hints: sorted, comma-joined "agent-ish" env-var KEY names present
but matched by no vendor rule — a fingerprint that clusters by agent.
Privacy stays consistent with the existing "never read secret-shaped values"
stance: agent_env_hints emits key names only; the three value-reads are vars
whose sole purpose is non-secret identification, each passed through a strict
short-slug allowlist so anything long/spaced/secret-shaped is dropped.
Breaking down agent_hint / agent_env_hints filtered to agent_runtime IS NULL
AND is_tty=false gives a ranked leaderboard of new agents to promote into
VENDOR_RULES.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cli/telemetry): guard agent_hint/term_program against short credential-shaped values
Review feedback (Magi, #1978): the short-slug allowlist in sanitizeHint() still
accepted short credential-shaped values (AGENT=sk-ant-api03,
AGENT=AKIAIOSFODNN7EXAMPLE, AGENT=github_pat_abc), so the "never emit a secret"
claim wasn't actually enforced — only overlong values were dropped.
Add a credential-shape guard on top of the slug allowlist:
- known token/credential prefixes (sk-, ghp_, github_pat_, akia, ya29, ...)
- any unbroken alphanumeric run >= 16 chars (key bodies, hex, base64-ish),
while agent names segment on _/-/. and keep each run short.
Replace the single overlong-value test with the short credential shapes from the
review (parametrized) plus a positive case (gemini_managed_agent) proving real
multi-segment names still pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cli): validate seeks the runtime player directly, not raw timelines
validate's seekTo() only checked for window.__hf.seek (a bridge object
the producer's render-pipeline file server injects) before falling
back to grabbing window.__timelines and calling .seek() on each raw
GSAP timeline directly. validate serves compositions through a plain
static file server that never injects that bridge, so this fallback
ran on every single validate invocation.
Seeking a raw timeline moves the animation state but skips the
runtime's own [data-start]/[data-duration] visibility sync
(syncMediaForCurrentState in packages/core/src/runtime/init.ts), which
is what sets an off-window clip's inline visibility/display styles.
Skipping it left elements outside their timeline window looking fully
visible to any check that reads computed style afterward at that seek
time.
This surfaced as validate's WCAG contrast audit
(contrast-audit.browser.js) flagging text in off-window clips against
whatever background happened to be behind them, since its own
visibility filtering trusts the runtime to have already hidden them.
Fix: prefer window.__player.renderSeek, which the composition runtime
exposes directly on every page load (no bridge required) and which
does run the visibility sync, before falling back to the __hf/raw
timeline paths. No changes needed to contrast-audit.browser.js itself
since its existing visibility check now sees correct computed style.
No new test added: seekTo's branch selection runs entirely inside a
page.evaluate() callback, which Puppeteer serializes via .toString()
for the browser context, so it can't import and call a project-local
window.__player stub from a jsdom/vitest test without testing a copy
of the logic rather than the shipped code. Verified instead by reading
the runtime chain end-to-end: window.__player.renderSeek is always set
by packages/core/src/runtime/init.ts's createPlayerApiCompat, calls
through to player.renderSeek, which calls syncMediaForCurrentState().
* fix(cli): wait for runtime seek target in validate
`validate` navigated the page with a hardcoded 10s timeout that ignored
the --timeout option. A composition that loads GSAP (or any library)
from a CDN <script> in <head> blocks `domcontentloaded` until that
script finishes downloading; on a slow network that exceeds 10s and
validate fails with an opaque "Navigation timeout of 10000ms exceeded"
— even though the full render (much larger budget) rides it out fine,
and even though --timeout (the documented "wait longer for slow loads"
knob) had no effect on navigation. The only recourse was to change the
composition (vendor the script locally).
Reported precisely, with the exact error and the observation that
render's 60s budget masks it while validate's 10s trips.
Fix:
- resolveNavigationTimeoutMs(optTimeout) = max(10s floor, --timeout), so
--timeout now also extends the navigation budget. Default behavior is
unchanged: the default --timeout (3000) stays clamped to the 10s floor.
- navigationTimeoutHint() replaces Puppeteer's opaque timeout error with
an actionable message naming the likely cause (a blocking CDN <script>)
and the two fixes (vendor locally / raise --timeout). Any non-timeout
error is rethrown unchanged.
- --timeout help text updated to note it also governs navigation.
Both helpers are pure and exported; validateInBrowser wires them around
the single page.goto. No behavior change for compositions that navigate
within 10s.
Test: resolveNavigationTimeoutMs (floor kept for unset/small/zero,
raised past the floor) and navigationTimeoutHint (rewrites a nav-timeout
error with CDN + --timeout guidance; returns null for other errors so
the caller rethrows as-is). validate suite 14 tests pass.
* fix(cli): purge stale/partial browser installs instead of wedging retries
Two independent reports of the same failure: a `chrome-headless-shell`
zip extraction gets interrupted (Windows AV lock, sleep/wake, ctrl-C)
and leaves only the alphabetically-early files (ABOUT/LICENSE) in the
target directory, no executable. Every subsequent `browser ensure` (or
implicit re-download from `findBrowser`/`ensureBrowser`) sees the
directory already exists and hands it straight to @puppeteer/browsers'
install(), which throws "folder exists but the executable is missing"
without re-extracting -- permanently wedging the machine until someone
manually deletes the directory. `--force` didn't help because it was a
phantom flag: `browser.ts` never declared it, so it silently did
nothing (mentioned only in an error-message string).
Root cause: `findFromCache()` already detects this exact case (dir
exists, exe missing) and returns it as `staleHyperframesCachePath`, but
`findBrowser()`/`ensureBrowser()` fed that straight into a re-download
without ever deleting the stale directory first, so install() hit the
same "exists" branch every time.
Fix:
- `findFromCache()` also returns `staleInstallPath` (InstalledBrowser's
`.path` -- the actual install-folder root, not the missing
executablePath) for the stale case.
- Both `findBrowser()` and `ensureBrowser()` now purge that directory
(`rmSync`, inside the existing `withInstallLock` mutex from #1866 so
a purge can't race a concurrent installer) before retrying, so
install() actually re-extracts instead of erroring.
- Wired up a real `--force` flag on `hyperframes browser ensure`: it
purges the whole HF-managed cache (reusing the already-tested
`clearBrowser()`) and skips every cache/system shortcut, so it always
gets a fresh download regardless of what's currently on disk --
matching what the existing (previously false) help text already
claimed it did.
Not fixed here (separate root cause, flagged for later): neither
report's machine had a usable auto-detected system Chrome fallback on
Windows -- `SYSTEM_CHROME_PATHS` only lists macOS/Linux paths, so
`findFromSystem()` can never succeed on win32. Both reporters worked
around this manually via HYPERFRAMES_BROWSER_PATH, which still works
fine; adding real Windows system-Chrome detection is a distinct,
larger change.
Test: extended manager.test.ts's existing stale-cache-redownload test
to include a populated stale install directory and assert it's gone
before the mocked install() is called (was previously only asserting
the redownload happened, not that the fix's purge step ran). Added a
new test for `ensureBrowser({force: true})` purging the cache and
bypassing a healthy cache/system-Chrome shortcut. Also fixed the shared
fs mock's `rmSync` to actually simulate recursive deletion (drop
nested tracked paths too), which the new tests need and the old ones
never exercised. Full CLI suite (1222 tests) passes.
* fix(cli): serialize force browser cache purge
Post-release review of media-use ↔ figma coupling (spec §13.1):
- figma asset imports now regenerate .media/index.md, the agent-readable
inventory media-use maintains — format locked byte-identical via a
cross-runner parity test against media-use's own index-gen.mjs
- figma asset --description/--entity land in the manifest record, the
index table, and <img alt>; component rasterize auto-describes with
the node name. Named brand marks become visible to media-use's
resolve --entity lookups.
- spec §13.1 records the review verdict (loose coupling correct) and
the follow-up queue (shared media-ledger module, global cache for
figma assets, media-use version-keyed idempotency)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): reroute /figma by capability - REST/CLI for phases 1-3, MCP for 4-5 (M4)
Rewrites the skill from MCP-first to the spec 2 split: asset/tokens/
component route through the hyperframes figma CLI (FIGMA_TOKEN), motion/
shaders stay agent-driven over MCP (no REST equivalent). Adds two-
credential guidance, Starter rate-limit tactics (recursive:true, raw-
response cache, opt-in screenshots), the 7.1 binding flow (tokens before
components, one ask per unknown library, never value matching), and the
shader manual-export default. Catalog blurbs updated in lockstep.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(cli): register figma component subcommand
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): add storyboard-to-animatic guidance to /figma
Field-tested against a real 26-scene storyboard section: the parsing
grammar (frame-sized nodes incl. loose rectangles = scenes, x-order =
time order, TEXT below the strip = director notes paired by x-overlap),
batched still export (chunk ~4 ids per render call - big frames timeout
past ~12), a note-verb -> transition vocabulary (EXPLOSION/SLIDE/MORPH/
CYCLE), and the stills-vs-component routing rule for within-scene motion
notes. Catalog blurbs updated in lockstep.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(skills): storyboard frames are keyframes, not slides
Field-tested against a second real storyboard section: frames sharing an
element (matched by name, else geometry similarity) define that element's
states through time - tween the element between states, crossfade only
when pixels genuinely differ, enter/exit unmatched children, tween frame
backgrounds as a color track. Stills demoted to fallback for frames that
don't decompose. Validated live: a 4-frame logo-rise reconstructed as one
element with four keyframes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(figma): self-explanatory first-run experience + mintlify guide
- NO_TOKEN/BAD_TOKEN errors now carry the full one-time setup (mint URL,
read-only scope checklist, persist hint) instead of a bare pointer
- figma subcommands print clean guidance on typed client errors, not a
stack trace (shared withFigmaErrors boundary)
- CLI help gains component subcommand, FIRST-TIME SETUP and WHAT TO
EXPECT blocks
- /figma skill: preflight the token before the first CLI call and walk
the user through setup up front; narrate landed-artifact + next action
at every step
- new docs/guides/figma.mdx (setup, per-phase walkthroughs, provenance,
troubleshooting table) wired into docs.json nav
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(figma): review fixes — missing withFigmaErrors imports, 401/403 semantics, docs accuracy
- tokens.ts/component.ts called withFigmaErrors without importing it
(tsup doesn't typecheck, so every invocation shipped as an immediate
ReferenceError); imports added, tsc --noEmit now clean
- error boundary widened to all Errors so bad-ref/bad-format input
errors print their message instead of a stack trace
- 401 no longer claims 'missing scopes' (figma signals that as 403);
new FORBIDDEN code maps non-variables 403 to scope/access guidance
- docs: asset/component refs require a node id (bare fileKey is
tokens-only), example snippet matches real output, FORBIDDEN row
- skill: preflight counts a project-.env token as configured (CLI
auto-loads it); BAD_TOKEN/FORBIDDEN guidance split
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(cli): present figma errors via standard errorBox
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
resolveBindings: scan the full tree (boundVariables + style ids, alias
chains, children) and partition exact-ID-only against the binding index
before any CSS is emitted per spec 7.1 - never value matching.
nodeToHtml: absolute geometry at figma bounds inside a fixed-size root,
solid/linear-gradient fills, corner radius, opacity, drop shadow, blur,
text styles; resolved bindings emit var(--slug, literal), unresolved
bake literals with data-figma-unresolved; visible:false respected;
vectors/boolean ops route to a rasterize list.
hyperframes figma component: tree -> bindings -> html, rasterize
fallback via Phase-1 asset export with src backfill, registry-item
packaging, unresolved-binding guidance in output.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
tokensToVariables: variables -> composition brand-variable entries
(COLOR->hex/rgba, FLOAT/STRING/BOOLEAN), alias chains walked cycle-safe
to the leaf value while the binding keeps the semantic id. Sidecar
figma-tokens.json + .media/figma-bindings.jsonl records per spec 7.1.
hyperframes figma tokens: variables path, REQUIRES_ENTERPRISE degrades
to published-styles metadata (values resolve at component time).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
M0: renderNode/imageFills/variables/styles/nodeTree/fileVersion over
api.figma.com with injectable fetch and typed capability errors
(NO_TOKEN/BAD_TOKEN/REQUIRES_ENTERPRISE/RATE_LIMITED/RENDER_FAILED/
NODE_NOT_FOUND/HTTP_ERROR) per design spec 4.4.
M1: svg sanitizer (scripts/foreignObject/handlers/external hrefs) +
hyperframes figma asset: render -> sanitize -> freeze under .media/ ->
manifest provenance -> snippet. Idempotent on
fileKey:nodeId:format:scale:version; re-imports when the version moves.
Plus the 7.1 binding index store (.media/figma-bindings.jsonl): exact-ID
lookup incl. alias chains, per-project library-file answers, shared
jsonl reader with the asset manifest.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(core): add figma motion easing mapping
* feat(core): translate figma motion doc to gsap timeline spec
* feat(core): emit paused GSAP timeline script from figma motion spec
* fix(core): restore type exports dropped from figma barrel in Task 8
* feat(skills): add /figma import skill + catalog wiring
Add the agent-facing /figma skill (asset + Figma Motion import via the
Figma MCP connector, built on @hyperframes/core/figma) and wire it into
the skill catalog across CLAUDE.md, README.md, docs/guides/skills.mdx,
and the hyperframes router's capability map. Bumps the skill count from
19 to 20 in CLAUDE.md and README.md.
* fix(core): use replaceAll for figma node-id dash-to-colon conversion
* style: format skills catalog tables
oxfmt-align the README and router SKILL.md tables after the /figma +
/hyperframes-keyframes merge left uneven column padding.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(cli): add missing cache fields to telemetry test fixture
ExtractionPhaseBreakdown gained cachePublishFailures/cacheGcEvictions/
cacheGcBytesFreed/cacheAgedPartialsCleared; the studioRenderTelemetry
test fixture was never updated, breaking Typecheck on main and every PR
based on it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The design-style extractor now captures a site's signature color grounds
and materials that a flat background-color misses:
- Capture gradient background-image + backdrop-filter on buttons/cards/nav.
- backgrounds[]: dominant gradient / mesh washes ranked by on-screen area
(includes ::before/::after glow orbs), chroma-weighted so a small vivid
brand wash outranks a large neutral scrim.
- glass[]: frosted-glass panels (backdrop-filter blur) with their raw
translucent fill, border, radius, shadow — ranked by area.
- nav CTA capture: keep filled buttons inside <nav> (a page's primary
"Sign up" / "Start for free" CTA that the old nav-drop lost), including
gradient-filled CTAs whose background-COLOR is transparent.
- Dedup keys for buttons/cards now include gradient + glass so a
gradient/frosted variant is not collapsed into its flat sibling.
- Fix: a fully-transparent fill rgba(...,0) now reports "transparent"
instead of #000000 — the old bug turned every transparent wrapper into a
phantom black button/card.
types: ComponentStyle gains backgroundImage/backdropFilter; DesignStyles
gains backgrounds[] and glass[].
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(cli): lock chrome-headless-shell install against concurrent extraction races
A detailed post-release feedback report of `render` producing a fully
black 15s MP4 despite lint/validate/inspect/snapshot all passing and
Studio preview playing correctly. Root cause traced by the reporter:
chrome-headless-shell had been manually re-extracted after `browser
ensure`'s own download got stuck mid-extraction when two concurrent
invocations raced on the same cache dir. The manual extraction lost a
macOS Gatekeeper/quarantine or GPU/Metal entitlement bit that a clean
install sets, so headless GPU frame capture silently returned all-black
frames — invisible to every existing health check, since they only
confirm the binary *exists*, not that it captures real pixels.
`--no-browser-gpu` fixed it completely, confirming the GPU-capture path
specifically. A related, vaguer report of the same race the prior loop
run ("'browser ensure' hung mid-extraction after a race from two
concurrent invocations") was deferred pending a clearer repro; this
report supplied one.
@puppeteer/browsers' install() has no concurrency guard of its own —
confirmed by reading its source: two concurrent installs for the same
browser/buildId both proceed straight to download+unpack with no
existing-install check, no lock. Two ensureBrowser()/findBrowser() calls
that both miss the cache at the same time (the common case on a fresh
machine, or right after `browser clear`) race on the same extract target.
Fix: mkdirSync as an atomic cross-process mutex around the download —
recursive:false makes it throw EEXIST when another process already holds
it (that's load-bearing: recursive:true would silently no-op instead).
Zero new dependencies. A concurrent caller polls until the lock releases,
then re-checks the cache before deciding whether to download at all — the
common case (loser waits, then reuses the winner's completed install)
never re-downloads. A lock held past a generous timeout is reclaimed
rather than left to wedge every future render if the holder crashed
mid-extraction. Applied to both call sites that reach the racy
downloadBrowser() (ensureBrowser's two paths, and findBrowser's stale-
cache re-download — the file already carries a code-duplication
suppression between these two near-identical functions).
Not doing (out of scope for this fix): the reporter's second suggestion,
a deeper `doctor` check that actually captures a test frame rather than
checking binary existence. That's a real gap but a separate, larger
feature — this fix prevents the corruption that caused it, which matters
more than detecting it after the fact.
Tests: two new cases (lock releases after a successful download; a lock
held past its timeout is reclaimed rather than hanging — exercised via
withInstallLock's injectable timeoutMs/pollMs with tiny real waits,
avoiding fake-timer mocking through the full async ensureBrowser call
graph). All 13 tests in manager.test.ts, 22 across packages/cli/src/browser,
and the full CLI suite (1115 tests) pass.
* test(cli): isolate browser install lock test from system chrome
* fix(cli): guard stale browser lock reclaim
Two independent post-release feedback reports of validate warning about
audio duration despite an explicit, correct data-duration slot, one of
them naming a timeout explicitly.
Root cause: auditClipDurations reads each <video>/<audio> element's
intrinsic .duration via a single page.evaluate() snapshot taken after a
flat, unconditional page-settle sleep (opts.timeout ?? 3000ms, shared with
other audits). Per the HTML spec, HTMLMediaElement.duration is NaN until
metadata loads. A slow-loading audio file (large narration WAV, remote
source) can still be mid-fetch when that sleep elapses — el.duration is
NaN at that exact instant, which the audit permanently records as
"could not read the duration" even though the render pipeline (which
properly awaits media readiness) handles the same file fine.
Fix: race each not-yet-ready element's loadedmetadata/error event against
a deadline instead of taking one fixed-time snapshot. Elements already
ready resolve immediately (no added latency in the common case); only
genuinely slow elements get a real second chance before the warning fires.
The race/cleanup wiring lives twice by necessity — once inline inside the
page.evaluate() closure (Puppeteer serializes and re-runs that closure in
an isolated browser realm with no access to this module), and once as the
exported, duck-typed raceMediaReady for a real, deterministic unit test
via Node's built-in EventTarget (no browser or DOM library needed). The
comment on raceMediaReady flags that both copies must move together.
* chore(cli): regenerate cloud client for createAssetUpload + completeAssetUpload
Regenerated from experiment-framework `master` at commit `e74815f7af` (the
merge of EF#41085, which added `/v3/assets/direct-uploads` +
`/v3/assets/{asset_id}/complete` to the `TARGET_ENDPOINTS` allowlist in
`scripts/generate_hyperframes_cli_client.py`).
The `sync-hyperframes-codegen.yml` workflow that normally auto-opens this
PR failed with a `gh: Not Found (HTTP 404)` on the PR-creation step (run
28556975483); regenerated manually with:
cd experiment-framework
PYTHONPATH=. python3 scripts/generate_hyperframes_cli_client.py \\
--out /path/to/hyperframes-oss
This commit is codegen-only — no hand edits. The direct-upload wire-up
that consumes the new `createAssetUpload` + `completeAssetUpload` methods
lands in the follow-up commit.
— Jerrai
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(cli): migrate cloud-render upload to /v3/assets/direct-uploads (200MB)
Replaces the legacy `client.uploadAsset(...)` multipart POST to
`/v3/assets` (32 MB in-memory proxy path) with the three-step direct-to-
S3 flow that lifts the practical per-project ceiling to 200 MB:
1. `POST /v3/assets/direct-uploads` — declares filename, content-type,
size, and SHA256 checksum; returns `asset_id`, presigned
`upload_url`, and required `upload_headers`.
2. Raw `PUT` to `upload_url` with the zip bytes + `upload_headers`
verbatim. No CLI auth attached — the presigned URL signature carries
authorization, and any extra headers would break the signature.
3. `POST /v3/assets/{asset_id}/complete` — finalizes into a reusable
asset. Retried up to 5x on 409 ("Uploaded object not found yet"), a
documented race between S3 write consistency and the finalize check.
The returned `asset_id` is the same namespace the legacy path produced
(both write into `movio_asset`), so the downstream render submission at
`createRender({project: {type: "asset_id", asset_id}})` is unchanged.
Server-side context (EF#41085): the direct-upload endpoint now accepts
`application/zip` via a scoped `_ZIP_MIME_TO_EXT` map — the shared media/
PDF allowlist stays zip-free. The exact-MIME cross-check at the sniff
step guards against zip<->PDF confusion under the shared 'document'
category. Canonical S3 key layout matches the legacy proxy path
(`document/{asset_id}/original.zip`), so the render-side head_object
gate is transparent to which upload path produced the asset.
The prior codegen commit added the generated createAssetUpload +
completeAssetUpload methods this commit consumes.
— Jerrai
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Fix slideshow presenter mode for screen-share workflows by opening the audience view as a regular noopener tab, preserving audience query construction across fragments, and keeping iframe keyboard forwarding diagnosable.
The P1-3 aspect/alpha/HDR pre-flight (#1843) aborts an incompatible render before any browser/ffmpeg work, but that "save" was invisible on dashboard 1783183 — indistinguishable from a deep failure or a user giving up.
checkRenderResolutionPreflight now returns { message, kind } (kind = the existing low-cardinality OutputResolutionIssueKind), and the render command emits render_preflight_rejected { kind } before exiting. No parsers change — the helper already carried kind. trackRenderPreflightRejected is typed to the union so the metric can't carry free text.
Tests: preflight tests assert kind for all five kinds; an events test locks the emit.
Further follow-up (still log-only): encoder-frame-0-exit counter and a P1-4 doctor cli_env_check event.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The CLI Test job (bun run --filter '!@hyperframes/producer' test) intermittently failed unrelated PRs (#1843, #1850) with `Test timed out in 5000ms` / `Hook timed out in 10000ms`. Root cause: multiple CLI tests cold-import a heavy command module graph via dynamic import() (render.js, auth/status.js, telemetry/system.js), which under the full parallel monorepo run contends for CPU and blows vitest's 5s/10s defaults on constrained runners. Not a product bug.
Fix at the right altitude: set testTimeout 20s + hookTimeout 30s once in packages/cli/vitest.config.ts instead of per-test/per-hook bandaids, and remove the now-redundant explicit 30s beforeAll timeouts added in #1843.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the render-reliability batch (#1841/#1842/#1843). Threads two capture-reliability counters through the existing observability → CLI-telemetry pipeline (no new PostHog wiring) so #1842's hardening is measurable on dashboard 1783183:
- transient-retry burn (CaptureAttemptSummary.reason gains "transient-retry"; counted into RenderCaptureObservability.transientRetries on BOTH the recovered and the still-failed paths via a shared helper).
- OOM classification (memoryExhaustionDetected set when describeMemoryExhaustion classifies the failure).
Surfaced as capture_transient_retries + capture_memory_exhaustion_detected render-event props. Tests cover the attempt tagging and the payload mapping.
Further follow-up (different subsystems): encoder-frame-0-exit signal, and P1-3 pre-flight-rejection / P1-4 cli_env_check counters.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Renames the motion-surfacing tool from `hyperframes keyframes` to `hyperframes motion`,
renames the implementation from keyframes*.ts to motion*.ts (keeping the keyframe data
model name where still accurate), and renames the shipped skill from
hyperframes-keyframes to hyperframes-motion. Expands the skill from a command
reference into a full motion-design workflow: reading motion, 3D angle verification,
layered GSAP motion, one-shot reference reproduction, diagnostic checks, and
eval-derived craft guidance.
Users pick an --resolution preset whose orientation/aspect ratio (or alpha/HDR mode) conflicts with the composition; the render fails deep in the compiler with a cryptic message. ~8K err / ~1K users.
- New shared pure helper checkOutputResolutionCompatibility in @hyperframes/parsers — single source of truth for aspect/alpha/HDR/downsample/non-integer-scale constraints; suggests the matching-orientation, tier-preserving preset.
- CLI render pre-flight aborts early (before browser/ffmpeg) with an actionable, fix-suggesting message; resolveDeviceScaleFactor delegates to the same helper for identical defense-in-depth messages.
- Suggest (not auto-select); defers when dims can't be determined rather than guessing.
- suggestMatchingPreset keys tier off the -4k suffix so square-family swaps (square + landscape-4k -> square-4k) aren't downgraded to HD.
- render.js DOM polyfill made a lazy import; render.test cold-import beforeAll hooks given a 30s timeout to absorb CI contention.
Render-reliability workstream P1-3. Success measured on PostHog dashboard 1783183.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WSL first-render success (34.7%) is dominated by a downloaded chrome-headless-shell that launches into `libnss3.so: cannot open shared object file` — doctor/preflight only checked the binary exists, never that it can load its libraries.
- New linuxDeps.ts: /etc/os-release distro detection (Debian/Fedora/Arch/Alpine) + WSL detection, per-distro Chrome dep set, ldd-based shared-lib probe.
- preflight.checkChrome downgrades a found-but-unlaunchable Chrome to a render-blocking error with the exact per-distro install command.
- Distro-aware ffmpeg hints; launch failures converted to actionable guidance pointing at `hyperframes doctor` (skipped on ARM64).
- Detect + print remediation (no auto-install).
Render-reliability workstream P1-4. Success measured on PostHog dashboard 1783183.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- fs.watch's async 'error' event had no listener, crashing the preview
server on EMFILE (exhausted OS watch handles)
- moveKeyframeInScript/resizeKeyframedTweenInScript/removeAllKeyframesFromScript
required object-form keyframes: {"0%": {...}}, silently no-opping on
array-form keyframes: [{...}, {...}]
- a keyframe diamond click's auto-synthesized native click event bubbled
to the ancestor clip's onClick, which toggles selection off when the
clip is already selected (the state every diamond click happens in)
- the clip's trim-resize handles (z-index 4) visually and functionally
covered any keyframe diamond within their 14px edge strip
- synthesizeFlatTweenKeyframes didn't recognize a collapsed
duration:0 + immediateRender static hold (what remove-all-keyframes
produces) as non-animated, so it kept showing a phantom diamond after
Delete All Keyframes
- resolveMediaStartSeconds's fast path for elements with their own
data-start discarded the host composition's inherited start offset,
so a video nested inside a sub-composition played from the root
timeline's time instead of holding until its parent scene began
Fixes#1838
* fix(core,producer,cli): pre-flight validation for empty/malformed sub-compositions
The #1 render failure bucket in production telemetry (PostHog project 356858,
dashboard 1783183 "HyperFrames — Bottom-Line & Activation"; ~65-69K
occurrences / ~27-28K affected users over 30 days, ~80% via AI-agent
authoring flows) is a `data-composition-src` reference pointing at a scene
file that is empty, malformed, or missing.
Root cause, traced end-to-end:
- The literal error "Composition HTML is empty or could not be parsed: <path>"
is real (not a PostHog paraphrase) — thrown by a since-reverted guard in
packages/core/src/compiler/inlineSubCompositions.ts (#1364), then changed to
a silent skip in #1678 to avoid aborting renders on partial content during
authoring. #1629 added per-assembler guards for 3 skill workflows
(product-launch-video, faceless-explainer, pr-to-video), but general-video
and hand-authored flows — where the dominant filename `scene-title.html`
(40K+/68K of the bucket) originates — have no assembler and thus no guard.
#1678 assumed the assembler guards from #1629 covered this pre-render; they
only covered 3 of the many authoring flows.
- On current `main`, an empty/malformed data-composition-src file no longer
crashes or throws during render — it's silently dropped by the tolerant
inliner. Reproduced locally: `hyperframes render` on a project with an
empty scene-title.html "succeeds" after ~93s (two 45s
pollSubCompositionTimelines timeouts) with the scene silently missing from
the output video. `hyperframes validate` also reports "No console errors"
for the same broken project.
- The raw `Cannot destructure property 'firstElementChild' of
'documentElement' as it is null` crash reproduces directly against
linkedom (the DOMParser polyfill packages/cli/src/utils/dom.ts installs in
the real CLI runtime) for empty and non-HTML input — confirmed with a
standalone repro script, not just inferred. jsdom/happy-dom (used in this
repo's own test environment) are spec-compliant and never produce a null
documentElement, which is why this needed a linkedom-specific test file.
Fix:
- New shared helper `checkSubCompositionUsability`
(packages/core/src/compiler/subCompositionValidity.ts) is the single
source of truth for "is this data-composition-src file usable" — mirrors
the inliner's own parse/template/body logic so all callers agree.
- `inlineSubCompositions.ts` (preview/studio bundling) now uses the shared
helper internally but keeps its #1678 tolerant skip-and-continue behavior
unchanged — mid-authoring iteration on a partial project must keep
working. `onMissingComposition` now also receives a human-readable reason.
- New render-only pre-flight (`assertSubCompositionsUsable` in
packages/producer/src/services/htmlCompiler.ts) walks every
data-composition-src reference (including nested ones, root-relative,
matching parseSubCompositions' own resolution) before any compilation
work starts, and throws naming every offending file at once. This is
unconditional — not gated behind --strict — because a render that
silently drops a scene is strictly worse than one that refuses to start.
Confirmed locally: render now fails in ~0.4s with an actionable message
instead of "succeeding" after 93s with a missing scene.
- New `hyperframes lint` rule `missing_or_empty_sub_composition`
(packages/cli/src/utils/lintProject.ts) surfaces the same check as a
file-scoped, actionable lint error (already unconditional — lint exits 1
on any error).
- `hyperframes validate` now also runs this check before launching a
browser, so it no longer reports "No console errors" for a project with a
broken sub-composition.
- `packages/core/src/parsers/htmlParser.ts`: guarded every
`documentElement`-may-be-null access (parseHtml, updateElementInHtml,
addElementToHtml, removeElementFromHtml, extractCompositionMetadata,
validateCompositionHtml) with a new typed `CompositionHtmlParseError` (or,
for validateCompositionHtml's collect-and-report contract, a typed
validation failure) instead of a raw crash.
Tests: empty file, whitespace-only, malformed/non-HTML, missing file, nested
sub-compositions (both happy path and broken-grandchild), and the happy path
— at the shared-helper, lint, and render pre-flight layers.
Not changed: the AI-agent authoring skills (skills/*). general-video and
hand-authored flows have no assemble-index.mjs equivalent to guard, so the
fix is at the CLI/render layer instead — flow-agnostic, covers every
authoring path, and the skills' existing "run lint/validate and stop on
failure" guidance now actually catches this class of mistake once run.
Not run in this environment: the producer package's full regression-harness
test suite (`bun test` in packages/producer) — it performs heavy real
rendering (S3 asset downloads, Google Fonts fetches, full video encodes) and
did not complete in a reasonable time in this sandbox. Verified instead via
the targeted test file for all touched code (76/76 passing), whole-repo
typecheck/build/oxlint, `fallow audit` (complexity/duplication/dead-code
gate, clean), and manual end-to-end CLI runs (render/lint/validate) against
reproduction projects, including a nested sub-composition scenario. CI
should run the full producer suite before merge.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* refactor(parsers,lint): port empty-composition pre-flight to extracted packages
Rebased onto main, which extracted @hyperframes/lint from core (lint depends
only on parsers, not core). Relocate checkSubCompositionUsability from core to
@hyperframes/parsers so both core (inliner) and lint can consume it without a
core<->lint cycle; core keeps a @deprecated re-export shim.
Correctness fixes from code review:
- checkSubCompositionUsability now returns "no-composition-root" when the
<template>/<body> content has no [data-composition-id] element (previously
a marker-free placeholder body passed both guards).
- lint's missing/empty sub-composition rule now only checks files reachable
via data-composition-src from the root (matching render pre-flight), instead
of a raw filesystem walk that false-positived on orphaned files.
- drop `as string` cast in inlineSubCompositions in favor of an explicit
null guard (per CLAUDE.md).
Review-comment items:
- move EmptyCompositionError JSDoc above the class (was above the adapter fn).
- correct stale circular-ref comment to match actual silent-skip behavior.
- rewrite self-contradicting lint message ("silently drop") to describe the
new loud render-pre-flight abort.
- add the __PLACEHOLDER__ (/^__[A-Z_]+__$/) skip to the render pre-flight so
it agrees with lint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* feat(telemetry): unify CLI and Studio PostHog identity (Layer 1)
Seed the CLI's anonymous distinct_id into Studio at launch so a developer's
CLI and their Studio browser session resolve to the same PostHog person.
Also unifies Studio's two previously-independent anonymous ids into one
source of truth. Uses only the existing anonymous machine id (no new PII).
- cli: inject window.__HF_CLI_DISTINCT_ID into the served index.html <head>
(mirrors the existing __HF_STUDIO_ENV__ injection) + add a fallback
GET /api/telemetry-identity endpoint. Only seeds when CLI telemetry is
enabled; empty/no-op otherwise.
- studio: new telemetry/distinctId.ts single source of truth; adopts the
CLI-seeded id when present, else falls back to the existing per-browser
localStorage id. Both Studio clients (studio:* and studio_*/render) now
share this one id.
* fix(telemetry): keep Studio distinct_id resolver fail-silent on getItem
resolveStudioDistinctId read localStorage.getItem() outside a try/catch
while every other external access in the module is guarded. In a
storage-restricted context where the localStorage reference resolves but
getItem throws, the resolver threw — breaking the module's fail-silent
contract (telemetry must never break Studio). Guard the reads and treat a
throw as "no id". Also drop an unnecessary `as` cast in the test per the
repo CLAUDE.md convention (the optional global is already declared).
* refactor(telemetry): address review feedback on identity unification
- dedup safeLocalStorage/safeSessionStorage into utils/safeStorage.ts,
used by both telemetry/config.ts and telemetry/distinctId.ts (Miga #6)
- replace redundant `??=` with `=` in the no-storage branch; cachedId is
guaranteed null there (Miga #2)
- extract buildStudioHeadScripts() so the "identity script before env
script" head-injection ordering is a pure, tested invariant (Miga #5)
- add tests: head-script ordering + telemetry-off passthrough, and a
Studio memoization test proving an adopted CLI id survives a later
window.__HF_CLI_DISTINCT_ID reassignment (Rames)
- clarify the XSS-escaping comment (both < and / escaped so no </script>
sequence can form) (Miga #1)
designStyleExtractor now also extracts chip/pill/badge/tag, stat/metric cells, and
tab components — by class-substring selector plus a shape fallback (small + fully
rounded + short text) so hashed/utility class names (Tailwind, CSS-modules) are
still caught. It also emits a "transparent" sentinel for fully-transparent
(rgba(...,0)) grounds instead of collapsing them to #000000, so a transparent
chip/tab/stat on a light-ground site no longer reads as solid black.
fontMetadataExtractor now flags icon fonts (isIcon) by glyph coverage: a font is an
icon font only when it BOTH lacks a real Latin alphabet (<26 of A-Za-z) AND is
mostly (>50%) Private-Use-Area glyphs. The Latin gate matters — some text fonts pack
thousands of PUA glyphs yet are plainly text (Apple SF Pro is ~81% PUA but ships a
full alphabet; Descript's Booton ~50%); flagging by PUA ratio alone would strip a
brand's real typeface. Measured icon fonts: "hushly" 63% PUA / 7 letters, Font
Awesome 95% / 0 letters. Names alone can't identify icon fonts ("hushly",
"swiper-icons"), hence the glyph-based test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(cli): keep doctor resilient to a corrupt browser cache
A partial or corrupt browser cache (a stub file where a version directory
is expected, a missing executable, or malformed metadata) makes
getInstalledBrowsers throw ENOTDIR. That throw propagated up through
findBrowser -> checkChrome -> runEnvironmentChecks, and since doctor.run
calls runEnvironmentChecks before any try/catch or the --json output, the
command crashed with exit 1.
doctor --json is documented to exit 0 even when checks fail, so it must
report a corrupt cache as "Chrome not found", not crash on it.
- checkChrome now catches any error from findBrowser and converts it to the
existing ok:false "Chrome not found" outcome with the browser ensure hint,
so runEnvironmentChecks never throws for a missing or corrupt browser.
- findFromCache treats a throwing getInstalledBrowsers as "no cached
browser", letting resolution fall through to system/download instead of
crashing every caller (render included), not just doctor.
A healthy browser still reports ok:true. Adds a preflight test asserting an
ok:false Chrome outcome when discovery throws, instead of propagating.
* fix(cli): warn on corrupt browser cache fallback
* fix(cli): exclude clip-path-hidden text from inspect layout and contrast audits
A clip-path can shrink an element's painted region to nothing (a typewriter
span pre-reveal at clip-path: inset(0 100% 0 0), or circle(0px)) while its
layout box, opacity, visibility and display all still read as present. Such
an element paints zero pixels, so the layout audit flagged the visible block
beneath it as a content_overlap, and the contrast auditor measured it as a
meaningless background-on-background ratio (~1:1) and reported a WCAG failure.
Both auditors already filtered opacity:0, visibility:hidden and display:none,
but neither accounted for clip-path. Add a shared check: when a non-none
clip-path is in effect on the element or an ancestor, probe a grid of points
across the element's box with elementFromPoint; if none resolve to the element
or a descendant, it is clipped to nothing and is skipped. The probe runs only
when a clip-path is present, so a genuinely occluded (but unclipped) element is
still measured and still flagged.
Wired at the in-page collection chokepoint so it covers content_overlap,
text_occluded and the contrast auditor consistently. Genuine overlaps between
visible elements remain flagged; data-layout-allow-overlap and
data-layout-ignore are honored unchanged. The two audit scripts and the
layout-audit test are added to the fallow ignore lists: their pre-existing
IIFE-level complexity and per-rule test scaffold re-flag under the line-shift
fingerprint when the small probe helpers are inserted.
* test(cli): cover clip-path audit edge cases
* fix(cli): satisfy clip audit test types