* fix(cli): pre-flight FFmpeg check and propagate failed_stage on render errors
Add an early FFmpeg availability check in renderLocal() so users get a
clear error message before the render starts instead of a cryptic ENOENT
mid-render. Also thread job.failedStage through handleRenderError into
the render_error telemetry event so we can attribute failures to a
specific pipeline stage.
* fix(cli): consolidate FFmpeg pre-flight into renderLocal()
Remove the duplicate findFFmpeg() check from run() — renderLocal()
already validates FFmpeg availability before starting. Single source of
truth.
When the user runs `hyperframes cloud render` without `--aspect-ratio` and
the project source is a local directory, parse the entry HTML's root
`<div data-composition-id ...>` for `data-width` / `data-height` and pick
the supported aspect ratio that matches within ±0.05 tolerance:
- 16:9 (≈1.778) ← landscape 1920×1080, 4K 3840×2160, etc.
- 9:16 (≈0.563) ← portrait 1080×1920
- 1:1 (=1.0) ← square 1080×1080
If the composition's ratio matches one of these, the CLI sets
`aspect_ratio` in the submit body and prints a one-line note
(`Detected aspect ratio: 9:16 (from index.html dims 1080×1920)`).
If the composition has no root div, no dims, or a ratio outside all three
tolerance bands (e.g. 4:5, 5:4, 21:9), the CLI logs a one-line warning
explaining the fallback and leaves `aspect_ratio` out of the submit body
— the server defaults to 16:9, and the user can pass `--aspect-ratio`
explicitly to override.
Explicit `--aspect-ratio` always wins. Detection is skipped for
`--asset-id` / `--url` project sources since the composition isn't on
disk; user gets a brief note in that case too.
New helper: `packages/cli/src/cloud/detectAspectRatio.ts` (pure regex
parse, no DOM library dep). 23 tests cover canonical matches, in-band
tolerance, all three non-match patterns (no root div, no dims, ratio out
of bands), and authoring edge cases (unquoted attrs, attribute order,
self-closing tags, multi-composition files).
Closes the `auto` carve-out flagged in ef#38182's deferred-scope note —
the CLI gets auto-detect without requiring a server-side zip-parse
capability (no API change).
Aligns the `hyperframes cloud render` CLI with the v3 API's decomposed
shape (ef#38182). Replaces the flat 6-value `--resolution` flag with two
independent flags:
- `--resolution`: tier ∈ {1080p, 4k}; default 1080p; 4k bills at 1.5x
- `--aspect-ratio`: ratio ∈ {16:9, 9:16, 1:1}; default 16:9
Regenerates `packages/cli/src/cloud/_gen/{types,client}.ts` from the
updated `experiment-framework/openapi/external-api.json`. Threads
`aspectRatio` through `SubmitOptions` and `buildRenderBody` so it lands
in the request body as `aspect_ratio`.
Old flag values (`landscape`, `portrait-4k`, etc.) now reject at the CLI
layer via `parseEnumFlag`, matching the API surface's rejection. The
six legacy combinations map to the same effective output in the new
shape — see the migration table in ef#38182's PR body.
Deferred (will follow in a separate PR): 720p, 4:5, 5:4, and `auto`.
These need producer-side capability + controller-side composition-dim
inference; out of scope for an API/CLI shape refactor.
reportApiError centralizes the HyperframesApiError -> Error -> String
reporting cascade for the cloud subverbs, including the curated
ERROR_CODE_HINTS table and its priority order (code-specific hint >
caller suggestion > bare code label > no third line). That priority
logic was previously untested; the module comment notes a past
regression where hyperframes_render_not_found was unreachable from
get/delete.
Add errors.test.ts covering: 404 + notFound short-circuit, known-code
hint, hint-wins-over-suggestion priority, suggestion fallback, bare
code label, no-third-line, extraHints merge and override, plain Error,
and non-Error stringification. Mocks errorBox and process.exit
following the sibling cloud/parsing.test.ts pattern.
Co-authored-by: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com>
* feat(studio): GSAP tween editing in Design panel
Add a GSAP animation editor to the studio Design panel: select an element,
view and edit its tweens (properties, easing, timing), add/delete animations,
and drag custom bezier speed curves — all persisted back to the composition
HTML. Gated behind VITE_STUDIO_ENABLE_GSAP_PANEL.
Parsing of existing GSAP source now uses a recast + Babel AST parser instead of
regex, giving scope resolution, stable tween IDs, and round-trip preservation of
extras and unresolved raw values.
recast compiles to CommonJS that calls require("fs"), which breaks browser and
Vite SSR bundles. To contain it, @hyperframes/core is split into an isomorphic
layer and a Node-only AST layer:
- gsapSerialize.ts holds the recast-free helpers (serialization, keyframe
conversion, validation, shared types). htmlParser.ts is now fully isomorphic.
- parseGsapScript and the script-mutation helpers live in gsapParser.ts,
reachable only via the @hyperframes/core/gsap-parser subpath, loaded
server-side by the studio-api mutation routes and the linter via dynamic
import (recast stays external under SSR).
- The barrel and the gsap-constants subpath are recast-free, so studio browser
bundles never trace recast.
Adds AST parser unit + stress coverage and e2e helpers for the panel.
* fix(lint): await async lintHyperframeHtml in all callers
lintHyperframeHtml became async (gsap rules use dynamic import)
but lintProject and check-hyperframe-static weren't awaiting it,
causing typecheck failures and runtime crashes in CI.
Also wire LintRule type in gsap rules to fix fallow unused-type
finding, and suppress render.ts exported-for-tests symbols.
Both flags were silently broken via the same root cause: citty parses
`--no-FOO` as a negation of the base flag `FOO`, so a flag literally
named "no-confirm" gets routed as `args.confirm=false` (not
`args["no-confirm"]=true`), and same for "no-wait".
Surfaced during the end-to-end smoke test on the just-merged stack:
- `cloud delete <id> --no-confirm` was hitting "Confirmation required"
and exiting 1 without calling the API.
- `cloud render --no-wait` was running the full poll + download flow
instead of submitting and exiting with the render_id.
Renamed the arg keys to `confirm` (default true) and `wait` (default
true) so citty's built-in negation handles the user-facing flags
correctly. Flag names stay the same; only the runtime arg keys change.
Live-tested both: delete now removes the render and a subsequent get
404s; --no-wait now returns just {render_id, status: "queued"} and
exits.
Note: a third instance of the same pattern exists in commands/add.ts
(`--no-clipboard`) and is also latently broken. Out of scope for this
fix; should be addressed alongside any audit of the CLI's interactive-
vs-noninteractive defaults.
* feat(cli): vendor initial hyperframes cloud client codegen
Generated by experiment-framework/scripts/generate_hyperframes_cli_client.py
(see heygen-com/experiment-framework#37896). Sets up the baseline for the
sync workflow to diff against on future spec changes.
The follow-up PR adds the orchestration layer (zip + upload + poll +
download) and the user-facing 'hyperframes cloud render/list/get/delete'
commands on top of this generated client.
The fallow ignore pattern is necessary because the generated request()
method is intentionally a single switch that handles all 5 endpoints
in one place; refactoring it here would just be re-introduced on the
next codegen run.
* chore(cli): regenerate cloud client with mimeType parameter on multipart uploads
Adds optional mimeType arg to uploadAsset (and any future multipart
endpoints). Without it, FormData sends application/octet-stream which
is correct for the documented media surface (png/jpeg/mp4/etc.) but
ambiguous for the private-beta zip uploads the cloud render flow uses.
Callers that pass `mimeType: "application/zip"` tag the multipart
part with the right Content-Type so downstream proxies, WAFs, and any
future server-side change that keys off the part MIME (instead of the
current magic-byte detection) all see the intended type.
Addresses review feedback on heygen-com/experiment-framework#37896.
Generated by scripts/generate_hyperframes_cli_client.py with the
matching update to the multipart emit path.
* feat(cli): add hyperframes cloud render/list/get/delete commands
Hand-rolled orchestration layer on top of the auto-generated cloud
client (vendored in the previous PR):
- cloud render <dir>: zip via createPublishArchive → upload to
/v3/assets → submit /v3/hyperframes/renders → poll
/v3/hyperframes/renders/{id} every 10s (max 60min) → stream the
signed video_url to disk.
- cloud render --no-wait: submit and exit with the render_id.
- cloud render --asset-id / --url: skip zip+upload and use a
pre-uploaded asset or public HTTPS zip.
- cloud render --variables / --variables-file: same UX as the local
render command; variables are validated against
data-composition-variables only when there's a local project.
- cloud list / cloud get / cloud delete: thin wrappers around the
matching client methods, with cursor-pagination support on list.
Auth comes from the existing cli/src/auth/ chain via cloud/auth.ts —
no new credential store, no new env var. The cloud client receives a
getAuthHeaders() callback that re-resolves credentials on every
request, so OAuth refreshes mid-poll are picked up automatically.
Also extracts a parent-scoped path lookup in help.ts so 'cloud render
--help' surfaces the right examples instead of falling through to the
top-level 'render' command's examples.
* fix(cli): address 15 code-review findings on cloud commands
Correctness fixes
- delete: require --no-confirm when stdin isn't a TTY OR --json is
passed; previously both silently auto-bypassed the irreversible-
delete prompt. Explicit decline now exits 2 (distinct from API/system
errors which still exit 1).
- render: mutex check now counts the positional dir alongside
--asset-id / --url; `cloud render ./foo --asset-id X` now errors
instead of silently dropping the dir.
- render: docstring updated — only --no-wait short-circuits the poll
loop; --callback-url is independent (webhook fires either way).
- render: removed dead try/catch around resolveProject (it calls
process.exit, never throws). resolveVariablesAndValidateIfLocal also
takes the resolved project source instead of re-parsing args.
- render: createPublishArchive errors now surface via errorBox instead
of bubbling a raw stack trace past citty.
- help: loadExamples now only catches ERR_MODULE_NOT_FOUND; real load
errors (syntax error, broken import) propagate so a broken
cloud/render.ts no longer silently shows the local render command's
examples. Also skips the parent-scoped lookup when parentName is the
root command ("hyperframes").
- list: fetchAll gained a 50-page safety cap + duplicate-cursor
detection so a buggy backend serving the same next_token on a loop
can't OOM the CLI.
- download: drain await now listens for error / close / abort so a
failing write stream (ENOSPC, AbortSignal) rejects promptly instead
of hanging forever. Partial files are unlinked on any error so the
caller never observes a truncated MP4. content-length is verified
against the actual byte count.
- poll: default sleep is abort-aware so Ctrl+C feels immediate instead
of waiting out the full interval.
- pollWithProgress: ANSI carriage-return redraws now gated on
process.stdout.isTTY — non-TTY runs (CI, file redirects) emit one
line per status transition instead of polluting the log with
literal escape codes.
Cloud client: 401-retry-with-refresh
- createCloudClient now wraps the generated client with a Proxy that
catches HyperframesApiError(status=401), force-refreshes the OAuth
token via forceRefreshCredentials, and retries the call exactly
once. Mirrors AuthClient's onUnauthenticatedRefresh so server-side
revocations and clock-skew rejections recover automatically.
- auth.ts gained forceRefreshCredentials() and now updates expires_at
on the refreshed credential it returns (fixed stale-expiry race).
Shared helpers
- cloud/errors.ts: reportApiError(stage, err, opts) is the single
error-funnel. ERROR_CODE_HINTS now applies to every subverb — fixes
hyperframes_render_not_found being unreachable from get/delete and
cuts ~70 LOC of duplicated try/catch/instanceof from render/list/
get/delete.
- cloud/parsing.ts: parseIntFlag / parseNumericFlag / parseEnumFlag
strict-mode parsers reject trailing garbage that Number.parseInt
silently accepts.
- cloud/ansi.ts: stripAnsi / visibleLength / padEndVisible — covers
ESC + 24-bit truecolor (c.accent palette) instead of the previous
regex which undercounted overhead and missed truecolor.
JSON-output consistency + _meta envelope
- Every cloud subverb's --json output now goes through withMeta(...)
so it carries the standard _meta envelope documented in cli.mdx.
- Single-render outputs use {render: detail} across get, delete,
render-no-wait, render-failed, and render-success. list uses
{renders: [...], has_more, next_token?}. delete adds deleted: true.
Tests
- 25 new tests across ansi.test.ts, parsing.test.ts, plus truncation
+ abort-cleanup tests for download.test.ts.
- 589 / 589 total CLI tests pass.
* fix(cli): address Vai's review on cloud commands
- render: pass mimeType: "application/zip" to uploadAsset so the
multipart Content-Type is correct (was application/octet-stream).
Server currently magic-byte-detects from file bytes so this is
belt-and-suspenders today, but any downstream proxy / WAF / future
server change that keys off the part MIME now sees the intended
type instead of relying on detection.
- render: poll error path now surfaces "Resume with: hyperframes
cloud get <renderId>" via reportApiError's new `suggestion`
option, matching the PollTimeoutError handler. The server-side
render keeps running through a transient 5xx; the user just
needs the right command to pick it back up.
- list: fetchAll now errorBox-exits on the malformed
{has_more: true, next_token: null} shape instead of silently
returning a truncated list (matching the duplicate-cursor guard).
- download: closeFile now listens for 'error' on the write stream
in addition to the end() callback, so a late ENOSPC during flush
doesn't leak an unhandled error onto the stream and resolves the
finally promptly.
- errors: reportApiError accepts an optional `suggestion` that's
used as the errorBox third line when no code-specific hint
matches — gives callers a place to surface always-actionable
recovery context.
- docs(cli): document --idempotency-key as the safe-retry mechanism
for the upload step. The 401-retry Proxy replays POST requests
on a stale token; without an idempotency key, the upload may
land twice. A UUID per logical render is the recommended pattern.
* feat(cli): vendor initial hyperframes cloud client codegen
Generated by experiment-framework/scripts/generate_hyperframes_cli_client.py
(see heygen-com/experiment-framework#37896). Sets up the baseline for the
sync workflow to diff against on future spec changes.
The follow-up PR adds the orchestration layer (zip + upload + poll +
download) and the user-facing 'hyperframes cloud render/list/get/delete'
commands on top of this generated client.
The fallow ignore pattern is necessary because the generated request()
method is intentionally a single switch that handles all 5 endpoints
in one place; refactoring it here would just be re-introduced on the
next codegen run.
* chore(cli): regenerate cloud client with mimeType parameter on multipart uploads
Adds optional mimeType arg to uploadAsset (and any future multipart
endpoints). Without it, FormData sends application/octet-stream which
is correct for the documented media surface (png/jpeg/mp4/etc.) but
ambiguous for the private-beta zip uploads the cloud render flow uses.
Callers that pass `mimeType: "application/zip"` tag the multipart
part with the right Content-Type so downstream proxies, WAFs, and any
future server-side change that keys off the part MIME (instead of the
current magic-byte detection) all see the intended type.
Addresses review feedback on heygen-com/experiment-framework#37896.
Generated by scripts/generate_hyperframes_cli_client.py with the
matching update to the multipart emit path.
* feat(cli): prompt for render satisfaction after successful renders
* feat: add text feedback, doctor context, and Studio render feedback UI
* feat(studio): replace render feedback with session-based Studio experience bar
Move the feedback prompt out of RenderQueueItem (where it triggered every
5th render) into a standalone StudioFeedbackBar mounted at the bottom of
the preview area. The new bar is session-gated (shows after the 5th studio
session), auto-dismisses after 20s, and respects a 30-day cooldown once
dismissed or submitted. Renames telemetry to trackStudioFeedback with a
"studio_experience" survey ID to reflect the broader scope.
* feat(studio): attach browser doctor summary to feedback events
* fix(studio): use recurring interval for feedback instead of one-time cooldown
* fix(cli): skip feedback prompt when an agent runtime is detected
* feat(cli): add hyperframes feedback command and agent render hint
- New `hyperframes feedback --rating <1-5> --comment "..."` command
for submitting anonymous render satisfaction feedback via telemetry.
- When an AI agent runtime is detected after a render, print a dimmed
hint to stdout so the agent can optionally call the command instead
of silently skipping the readline prompt.
- Export getDoctorSummary from telemetry/feedback.ts to share the
system-info collector between the interactive prompt and the CLI command.
- Register the command in cli.ts and help.ts under Settings.
* fix(studio): align feedback interval to every 15 sessions
* fix: show CLI feedback on first render, Studio every 10 sessions
* feat: add env flags to disable feedback prompts
* feat: env flags to configure feedback prompt frequency
* fix: address review — agent hint reachability, cadence gate, session debounce, deprecated API
## What
Adds OAuth 2.0 + PKCE login as the default for `hyperframes auth login`,
plus refresh-token + 401 auto-retry + `auth refresh`. Stacks on top of
PR #1081 (the API-key + shared store work).
- `hyperframes auth login` (no flags) — opens the user's browser to
`/v1/oauth/authorize`, captures the code on an ephemeral
`127.0.0.1:<port>/oauth/callback`, exchanges it for tokens with
PKCE S256, and persists. `--api-key` opts back into the legacy
long-lived-key path from PR #1081.
- `hyperframes auth refresh` — force-refresh the OAuth access token
using the stored refresh_token. Mostly useful for testing the path.
- `hyperframes auth logout` — best-effort revokes via
`POST /v1/oauth/revoke` (RFC 7009) before wiping local state.
- `AuthClient` now refreshes-and-retries once on a 401 when the
caller wires `onUnauthenticatedRefresh`. `auth status` wires it.
Internals added in `packages/cli/src/auth/`:
- `pkce.ts` — RFC 7636 code_verifier + S256 code_challenge.
- `loopback.ts` — ephemeral 127.0.0.1 HTTP server; state validation,
120s timeout, styled success/error page.
- `browser.ts` — wraps `open` with a `BROWSER=none` /
`HF_NO_BROWSER=1` fallback that prints the URL.
- `oauth.ts` — `startAuthorizationCodeFlow`, `refreshTokens`,
`revokeTokens`, `requireOAuthConfigured`, `parseTokenResponse`.
## Why
This is the foundation OAuth flow that lets free-tier users authenticate
without managing a long-lived key. Refresh + auto-retry means CLI
commands keep working past the access_token lifetime without bugging
the user.
The OAuth client_id (`q2A2QRSke2LrFTPJhoDbHtXh`) is the one James
created in the `oauth2_client` table. Baked in as a build-time default;
override via `HYPERFRAMES_OAUTH_CLIENT_ID` for dev/test.
## How
- Public client: PKCE only, no `client_secret`. Backend already
requires PKCE (`movio/logic/oauth2.py:638`).
- Loopback port is ephemeral (`server.listen(0)`) — the backend
wildcards localhost ports for public clients
(`movio/model/oauth2.py:check_redirect_uri`), so the registered
redirect URI's port is a placeholder.
- State parameter is generated per-flow + validated on callback to
prevent CSRF.
- Token-response parsing is permissive on `expires_in` type (some
servers return it as a string) but strict on `access_token` presence.
- 401 retry happens at the `AuthClient.fetchUser` layer, not the
command layer — so future endpoints inherit it for free.
- `persistOAuth` merges into the existing store (preserves co-located
`api_key`). `auth login` (API-key path) does the symmetric thing.
## Test plan
- [x] 80 unit tests, all green. `vitest run src/auth/`.
- [x] PKCE: verifier within 43-128 chars, challenge = SHA-256, S256
method, distinct outputs each call.
- [x] Loopback: state mismatch / IdP error / missing-code / timeout /
404 non-callback paths all rejected; success path captures `code`.
- [x] OAuth: `refreshTokens` posts correct body, persists, throws
`REFRESH_FAILED` on 400/401 and `API_ERROR` on 5xx. Existing
api_key preserved on refresh.
- [x] AuthClient: 401 retries with refreshed bearer on OAuth, does
NOT retry for api_key, returns 401 if refresh hook fails.
- [x] `bunx oxlint` / `bunx oxfmt --check` / `bunx tsc` clean.
- [x] `bunx fallow audit --base origin/main --fail-on-issues` — only
inherited `help.ts:showUsage` finding (from main, not this PR).
- [ ] Smoke test against dev API:
`HEYGEN_API_URL=https://api.dev.heygen.com hyperframes auth login`
then `hyperframes auth status` then `hyperframes auth refresh`.
## Out of scope
- Cloud render commands — separate plan.
- PR 4 (heygen-cli read-side JSON support) — independent, ships after.
## What
Introduces the `hyperframes auth` command group + a shared credential
store library that hyperframes-CLI and heygen-cli will both read from.
- `hyperframes auth login --api-key` saves a HeyGen API key to
`~/.heygen/credentials.json` (stdin pipe or hidden-input prompt).
- `hyperframes auth status` resolves the active credential (env vars
→ file) and verifies it against `GET /v3/users/me`, printing
identity + billing.
- `hyperframes auth logout` removes the credential (`--keep-api-key`
drops only the OAuth block).
Internals (`packages/cli/src/auth/`):
- `paths.ts` — `~/.heygen` layout, `HEYGEN_CONFIG_DIR` override.
- `store.ts` — read/write `credentials.json` (file 0600, dir 0700)
with legacy single-line plaintext fallback so existing heygen-cli
users don't lose their session.
- `resolver.ts` — chain: `HEYGEN_API_KEY` → `HYPERFRAMES_API_KEY` →
file (unexpired OAuth wins over api_key).
- `client.ts` — hand-written typed wrapper for `GET /v3/users/me`
(intentionally not OpenAPI codegen — single endpoint).
- `errors.ts` — typed `AuthError` with discriminating `code`.
## Why
This is the foundation for `hyperframes cloud render`. Splitting it
out keeps the cloud-render PR small and lets users sign in today.
The plan originally called for a library-only PR followed by a
commands PR. The `fallow` dead-code gate flagged the library-only
shape as unused exports, so I bundled them — the library and its
first consumers ship together. PR 3 (OAuth PKCE) and PR 4
(heygen-cli read-side JSON support) follow.
## How
- Credential file format: JSON with optional `api_key` + `oauth`
blocks. Both CLIs read it; the resolver picks the freshest valid
credential.
- Auth header selection happens in the HTTP client: OAuth →
`Authorization: Bearer ...`, API key → `x-api-key: ...`.
- `HEYGEN_API_URL` lets dev testing target `api.dev.heygen.com`
without rebuilding.
- The new `auth` command lazy-loads its subverbs (same pattern as
`lambda`).
## Test plan
- [x] Unit tests added (`vitest`) for paths, store, resolver,
client, and errors — 45 tests, all green.
- [x] `bunx tsc --noEmit -p packages/cli/tsconfig.json` clean.
- [x] `bunx oxlint` + `bunx oxfmt --check` clean.
- [x] `bunx fallow audit --base origin/main --fail-on-issues` —
zero new findings.
- [ ] Smoke test against dev API:
`HEYGEN_API_URL=https://api.dev.heygen.com hyperframes auth login --api-key`
then `hyperframes auth status`.
`process.on("beforeExit", ...)` re-fires every time the event loop
drains, and the handler kicks off a fire-and-forget async telemetry
flush — so on a successful command the user sees the
"Update available: …" notice twice (once after the initial drain, again
after the flush settles). Using `process.once` detaches the listener
after first invocation, fixing the double-print and also preventing a
double-flush of telemetry.
Reported during local testing of `auth login`, but the bug affects every
command (any path where `_flush()` schedules work).
* fix(studio): add FFmpeg pre-flight check before starting render
Studio renders now fail fast with a 422 and an actionable FFmpeg
install hint instead of burning through the entire capture pipeline
before hitting "spawn ffmpeg ENOENT" at encode.
* fix(studio): address review — use 503, memoize FFmpeg lookup
* fix(producer): normalize error messages to prevent [object Object] in telemetry
When a render fails and the caught value is a plain object (not an Error
instance), String(error) produces [object Object], masking the real error
in PostHog telemetry (~24 errors/day).
Add normalizeErrorMessage() that tries Error.message, string passthrough,
.message on plain objects, JSON.stringify, and String() as a last resort.
Apply it on the two telemetry-feeding paths: the main render failure
handler (renderOrchestrator.ts:2099) and buildRenderErrorDetails
(cleanup.ts), plus the error classifier isRecoverableParallelCaptureError
so timeout detection works even when the thrown value is a plain object.
* fix: address review — normalize CLI telemetry path, captureCost fallback
* fix: use local normalizeErrorMessage in CLI to avoid cross-package resolution
The Vite test runner can't resolve runtime imports from @hyperframes/producer
since its exports point to dist/. Copy the utility into the CLI package and
import locally instead.
* feat(core): add probeElementInSource for source-existence checks
* feat(core): add probe-element endpoint for source-existence checks
* feat(studio): gate editing capabilities on source existence
* fix(studio): enrich save_failure telemetry with target details
* feat(studio): async selection resolution with source probe
Make `resolveDomEditSelection` async and wire a `probeSourceElement` call
into the selection path so elements generated by scripts (not present in the
source HTML) are detected early and have all edit capabilities disabled with
a clear reason message ("This element is generated by a script and cannot be
edited visually.").
Part A – core probe logic:
- `domEditingLayers.ts`: `resolveDomEditSelection` is now async; calls
`probeSourceElement` (POST /api/projects/:id/file-mutations/probe-element/:file)
when `projectId` is supplied and the element has a stable id/selector.
`existsInSource: false` flows into `resolveDomEditCapabilities`, which
disables all write capabilities with the appropriate reason.
- `domEditingLayers.ts`: `refreshDomEditSelection` promoted to async.
- `files.ts`: new `probe-element` route; extracted `resolveProjectPath`,
`resolveFileMutationContext`, `writeIfChanged`, and `parseMutationBody`
helpers to eliminate repeated boilerplate across remove/patch/probe handlers.
Part B – caller propagation (all eight consumer sites):
- `useDomSelection.ts`: `buildDomSelectionFromTarget`,
`resolveDomSelectionFromPreviewPoint`,
`buildDomSelectionForTimelineElement`, `handleTimelineElementSelect`,
`refreshDomEditSelectionFromPreview`, and
`refreshDomEditGroupSelectionsFromPreview` all made async; `projectId`
forwarded into `resolveDomEditSelection`.
- `useDomEditCommits.ts`, `useDomEditTextCommits.ts`: updated
`buildDomSelectionFromTarget` parameter type; added `await` at call sites.
- `useDomEditSession.ts`: inner `syncSelectionFromDocument` made async; fire
with `void` to satisfy the surrounding effect.
- `usePreviewInteraction.ts`: `handlePreviewCanvasMouseDown` and
`handlePreviewCanvasPointerMove` made async (React ignores handler return
values, so this is safe).
- `useStudioUrlState.ts`: deferred `buildDomSelectionFromTarget` call
converted to `.then()` chain with `void` prefix so the effect stays sync.
- `LayersPanel.tsx`: `seekToLayer`, `handleSelectLayer`, and
`handleLayerHover` made async.
- `DomEditOverlay.tsx` / `useDomEditOverlayGestures.ts`: `onCanvasPointerMove`
return type widened to `Promise<DomEditSelection | null>`; pointer-down
handler falls back to `hoverSelectionRef.current` (always populated by a
prior hover) instead of awaiting the async move callback inline.
Part C – test and tooling fixes:
- `lefthook.yml`: filesize hook shell loop explicitly skips `*.test.ts/tsx`
files as a guard against a lefthook v2.1.6 bug where `exclude` patterns are
not applied to `{staged_files}` in shell scripts.
- `domEditing.test.ts`: all `it()` blocks calling `resolveDomEditSelection`
made async with `await`.
- `DomEditOverlay.test.ts`: mock updated to return `Promise.resolve(selection)`
and `hoverSelection` pre-seeded so pointer-down test works with the new
hover-first path.
- `studioUrlState.test.ts`: `buildDomSelectionFromTarget` mocks wrapped in
`Promise.resolve()`; seek/selection hydration test made async with
`await act(async () => { await Promise.resolve(); })` to flush microtasks.
* feat(cli): add global error handlers for crash telemetry
Register process-level uncaughtException and unhandledRejection handlers
that fire trackCliError so unhandled crashes are captured in telemetry.
Add the trackCliError function to events.ts and re-export it from the
telemetry barrel.
* feat(cli): track per-command success/failure and duration
* test(core): add integration test for JS-created element probe scenario
* fix: address PR review feedback
- uncaughtException handler now calls process.exit(1) after flushing
- cli_command_result uses real exit code from process "exit" event
- drop stack_trace from cli_error (contains filesystem paths)
- skip source probe during hover — only probe on click/selection
- format .fallowrc.jsonc
* fix(cli): restore stack_trace in cli_error telemetry
* fix(cli): use captured module refs in exit handlers instead of dead import()
`injectVideoFramesBatch` now returns `Promise<string[]>` so the caller can
filter cache entries to videos the page actually painted. The cli-side
snapshot command does not use the return value, but its local `InjectFn`
declared `Promise<void>` which made the `as { injectVideoFramesBatch:
InjectFn }` cast on the dynamic engine import fail typecheck under TS's
"sufficiently overlapping types" rule. Match the engine's actual export
shape.
- play.ts: move --remote-debugging-port parse+deps validation before any
server setup so an invalid value exits cleanly instead of leaking a
listening socket (the original bug — server printed 'Player running'
and 'Press Ctrl+C to stop' before failing).
- Extract validateRemoteDebuggingPortDeps() in openBrowser.ts to keep
preview.ts and play.ts in sync instead of copy-pasting the dep
checks.
- Narrow parseRemoteDebuggingPort param to string | undefined; drop the
dead null branch and the redundant String() / Number.isInteger() now
that the regex already constrains the input.
- buildBrowserArgs: omit --remote-debugging-port when userDataDir is
missing so a CDP endpoint cannot leak into the user's main profile
even if a caller bypasses the CLI validation layer.
- Replace the duplicated buildBrowserArgs case with one that proves
this defense-in-depth behaviour; add unit tests for
validateRemoteDebuggingPortDeps.
- Drop the heavy JSDoc on parseRemoteDebuggingPort to match the file's
surrounding style.
- Both commands: align --remote-debugging-port description (it now
matches the actual 'requires --browser-path and --user-data-dir'
contract) and add a CDP example to the --help output.
Adds a Chromium remote debugging port flag for preview and play.
The flag is only passed when launching an explicit browser/profile.
HyperFrames still does not own CDP automation.
- Guard __renderReady with `if (state.capturedTimeline)` in all three
paths (setTimeout(0) and .finally() were setting it unconditionally
even when bindRootTimelineIfAvailable returned false)
- Remove redundant fps=30 pre-quantization in snapshot — renderSeek
already calls quantizeTimeToFrame internally with the runtime's
canonicalFps, so pre-quantizing was double-quantizing at a
potentially wrong grid
- Add regression tests: __renderReady is set when timeline exists,
stays undefined when no timeline is available
- Add comment explaining hardcoded fps=30 (runtime's canonicalFps
default, not exposed on PlayerAPI)
- Add cross-reference comments between init.ts and fileServer.ts
explaining their different __renderReady timing semantics
- Fix broken duration getter: use getDuration() (PlayerAPI method)
instead of .duration (property doesn't exist, always fell through
to the DOM attribute fallback)
- Remove redundant sub-composition wait: __renderReady already
guarantees all timelines are bound
- Warn on readiness timeout instead of silently capturing garbage
- Warn when shader transitions don't finish pre-rendering
- Warn when no player API is available (seeks will be no-ops)
- Remove redundant node:fs re-import (already imported at top)
- Remove stale step numbering comments
- Trim verbose comments that restate the code
The runtime set __renderReady at the same time as __playerReady,
before the root timeline was bound. Consumers waiting for
__renderReady (the render-safe signal) could observe a player with
no captured timeline, making renderSeek a no-op.
Root cause: init.ts set both flags together, but timeline binding
happens later — synchronously via bindRootTimelineIfAvailable(),
via a deferred setTimeout(0) for bundled compositions, or
asynchronously via loadExternalCompositions().
Fix in init.ts:
- Remove __renderReady from the __playerReady assignment
- Set it after bindRootTimelineIfAvailable() when timeline is found
- Set it in the setTimeout(0) deferred path
- Set it in the external compositions .finally() path
Fix in snapshot.ts:
- Wait for __renderReady (truthful signal) not __timelines
- Use renderSeek() with frame quantization, not seek()
- Tick the GSAP ticker after seeking
- Await document.fonts.ready before capturing
Closes#1047
Remaining review follow-ups:
- killProcessTree now escalates to SIGKILL after 500ms if SIGTERM
doesn't kill the process (same pattern as killTrackedProcesses).
Covers orphan cleanup and dev/local mode tree kill.
- Added unit tests for both new modules:
- processTracker.test.ts (6 tests): track/remove on exit/error,
kill running processes, SIGKILL escalation for SIGTERM-resistant
processes, idempotency.
- orphanCleanup.test.ts (5 tests): tree kill with children,
SIGKILL escalation, non-existent PID handling, orphan detection
returns 0 when clean.
- Blocker: arm 3s force-exit timer BEFORE awaiting cleanup, not
inside .finally(). Prevents hang if drainBrowserPool() blocks on
dead Chrome.
- Reorder cleanup: killTrackedProcesses() (sync, fast) runs first,
then async browser drain. Ffmpeg dies immediately instead of
surviving if the hard timer fires early.
- SIGKILL escalation: processTracker now SIGTERMs all tracked
processes, then SIGKILLs survivors after 500ms grace period.
- Scope pgrep to current user (pgrep -u $(id -u)) so orphan
detection doesn't touch other users' Chrome on shared machines.
- Add process.on('exit') handler for crash paths (unhandled
exceptions/rejections that bypass signal handlers).
- Document Windows no-op behavior on killProcessTree handlers.
The preview command's shutdown handler only closed the HTTP server,
leaving Chrome (browser pool) and ffmpeg processes alive. This caused
silent resource leaks — orphaned processes consuming CPU and RAM with
no parent.
Root cause: preview.ts never called drainBrowserPool() or killed
tracked ffmpeg processes. The thumbnail browser in studioServer.ts
registered its own competing signal handlers that raced with
preview's shutdown.
Fix:
- Add a central process tracker (processTracker.ts) that registers
every spawned ffmpeg across engine and producer packages
- Centralize thumbnail browser cleanup via exported
closeThumbnailBrowser() instead of scattered signal handlers
- Wire preview shutdown to call closeThumbnailBrowser(),
drainBrowserPool(), and killTrackedProcesses() before closing the
HTTP server (embedded mode)
- Add killProcessTree() for dev/local modes where Chrome runs in a
child process tree
- Add startup orphan detection that finds and kills orphaned
chrome-headless-shell/Puppeteer Chrome processes (PPID=1) from
previously crashed sessions
Closes#1038
Audit of every detection rule in the registry against actual vendor
source code. Rules that lacked a public-source citation were guesses
and have been removed; surviving rules now all cite the file + line
that emits the marker.
Codex — replace per @magi's investigation:
- Drop CODEX_HOME (config override read at startup, NOT propagated to
child processes — would miss most Codex invocations).
- Drop CODEX_SANDBOX (macOS Seatbelt only; covered by the others).
- Add CODEX_THREAD_ID (set unconditionally on every spawned shell
command — codex-rs/protocol/src/shell_environment.rs:6 +
codex-rs/core/src/unified_exec/process_manager.rs:1010).
- Add CODEX_CI (hardcoded in UNIFIED_EXEC_ENV — process_manager.rs:70).
- Keep CODEX_SANDBOX_NETWORK_DISABLED (default-on sandbox marker —
codex-rs/core/src/sandboxing/mod.rs:135-138).
Cursor — drop unverified CURSOR_TRACE_ID and CURSOR_AGENT guesses.
Keep TERM_PROGRAM=cursor (set by Cursor's integrated terminal).
Pi — new rule. https://github.com/earendil-works/pi
packages/coding-agent/src/cli.ts:13 unconditionally executes
process.env.PI_CODING_AGENT = "true";
at module entry, so every subprocess Pi spawns sees this marker.
Same propagation pattern as Hermes.
Removed (no source-cited marker found in this audit):
- aider — verified Aider sets no AIDER_* env vars; only OR_SITE_URL and
OR_APP_NAME (OpenRouter integration). No reliable marker.
- gemini_cli — GEMINI_SANDBOX/GEMINI_CLI_TRUST_WORKSPACE are conditional
on CLI flags; no unconditional marker found.
- jules, devin — closed source, no public marker documentation.
These vendors can be re-added later with a source citation; absence
in the registry will silently false-negative (events land in the null
bucket), but won't false-positive on other vendors.
Per @james-russo's review: do source-level research before shipping
detection rules. Memory updated to enforce this for future work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three follow-ups from @miguel-heygen's review:
1. HERMES_QUIET — switch to existence check.
`env["HERMES_QUIET"] === "1"` was brittle vs. future Hermes changes
(e.g. if cli.py ever sets it to "true"). The var name itself is
specific enough that existence is the right signal.
2. CI_PROVIDERS — convert to a discriminated union.
`mode: "truthy" | "presence"` is stricter than the previous pair of
optional boolean flags (which allowed entries with neither set).
3. Sandbox detection tests — add coverage.
- Docker positive: /.dockerenv present → docker.
- Negative case: plain Linux laptop with no markers → null.
Together with the gVisor 4.4.0 fix in the previous commit, that addresses
all three actionable callouts (the discriminated-union nit was non-blocking
but worth doing while in the file).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses PR feedback from @magi: kernel string `4.4.0` is also the
Ubuntu 16.04 LTS / older-real-kernel version, so accepting it alone
false-positives. Now `4.4.0` only counts as gVisor when /proc/version
also contains "gVisor". `*-gvisor` kernel strings remain standalone-
sufficient since no real production kernel reports them.
Adds a regression test that an Ubuntu 16.04 box reporting
`Linux version 4.4.0-1128-aws (buildd@lcy01)` is NOT classified as
a gVisor sandbox.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add two new properties to every CLI telemetry event so we can tell
managed-sandbox traffic (Codex Cloud, Claude Code Web, etc.) apart from
real developer laptops without geolocation guesswork:
- sandbox_runtime: 'gvisor' | 'firecracker' | 'docker' | 'kvm' | 'wsl' | null
gVisor detected via kernel string ('4.19.0-gvisor' or legacy Sentry
'4.4.0') + /proc/version. Firecracker via /dev/vsock + DMI sys_vendor.
Docker reuses the existing /.dockerenv + cgroup probe.
- agent_runtime: claude_code | codex | cursor | copilot_agent | jules
| replit | devin | aider | gemini_cli | hermes | openclaw | null
Detected by the EXISTENCE of well-known vendor env vars only — values
are never read. Hermes rule keys on HERMES_QUIET=1 (set unconditionally
at hermes-agent/cli.py:50). openclaw rule keys on OPENCLAW_STATE_DIR
or OPENCLAW_CONFIG_PATH (set explicitly in the spawned child env at
openclaw/extensions/qa-matrix/src/runners/contract/scenario-runtime-cli.ts).
Drive-by cleanups required by fallow because system.ts and client.ts
fall into the audit scope of this PR:
- Extract detectWSL into platform.ts to break the system.ts ↔ agent_runtime.ts cycle.
- Refactor detectCI / getCIName into a single CI_PROVIDERS table.
- Dedupe flush / flushSync via a shared drainQueueToPayload helper.
Privacy posture unchanged: HYPERFRAMES_NO_TELEMETRY=1 still opts out;
disclosure in docs/packages/cli.mdx updated to enumerate the new fields.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`--width 3840 --height 2160` against a composition with
`data-width="1920"` silently produces a 1080p output because the
runtime lays out the page at the composition's authored dimensions —
real footgun we hit during a cost-analysis sweep. Warn early and point
at `--output-resolution` (the supersampling escape hatch) so the user
doesn't burn a 30-minute render learning the override rule.
Skipped when `--output-resolution` is set (the supported supersampling
path — the user is opting in), when `--json` is set (machine consumers),
or when `index.html` isn't on disk (typical with `--site-id`).
Helper lives in a shared module so render + render-batch agree on the
parse + message. Tests cover both attribute orders, single/double
quotes, the silent paths, and the warning path. Best-effort regex over
the canonical attr shape — malformed HTML falls through to no warning
rather than blocking the render.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Allows authored-at-1080p compositions to render at 4K/2K via Chrome
deviceScaleFactor supersampling without re-laying-out the composition.
Plain --width 3840 silently lays out at 1920×1080 because data-width/
data-height attrs override Config.width — this flag is the supported
way to ask the renderer to supersample.
Accepts canonical CanvasResolution names (landscape, landscape-4k,
portrait, portrait-4k, square, square-4k) and aliases (1080p, 4k, uhd,
hd, 1080p-portrait, 4k-portrait, 1080p-square, 4k-square). Wired
through render + render-batch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Registry blocks are authored at 1920x1080 but projects may use
different dimensions (e.g. 1280x720). After installing a block, the
server now reads the host project's data-width/data-height from
index.html and rewrites the block's viewport meta and CSS dimensions
to match, preventing overflow.
VITE_STUDIO_* env vars set in the user's shell had no effect when
running `hyperframes preview` because the pre-built studio bundle had
them baked at Vite build time.
The embedded Hono server now collects VITE_STUDIO_* vars from
process.env and injects them as a `window.__HF_STUDIO_ENV__` script
tag into index.html. The client merges this runtime object on top of
the baked `import.meta.env`, so flags like
VITE_STUDIO_ENABLE_BLOCKS_PANEL=1 work as expected at runtime.
Capture pipeline work that came out of the 11-round website-to-video
eval branch. The wins that actually moved quality were the artifacts
agents read (contact sheets, design-styles) and the snapshot tool
visual-verification fixes; the rest are smaller follow-ons.
**Contact sheets (`contactSheet.ts`, new)**
- Replaces the embedded one-image-per-asset listing with paginated
labeled grids (3-col screenshots / 4-col raster / 5-col SVG). Each
page contains 9–15 cells with filename labels baked in via SVG
text overlay (`escapeXml` covers `&<>"'`).
- `fit: "contain"` keeps every asset visible at its real aspect
ratio; the old `fit: "cover"` cropped to the first image's box.
- Returns `string[]` (page paths) — single-page captures get one
file, multi-page produce `contact-sheet-1.jpg`, `contact-sheet-2.jpg`,
etc.
- `createSvgContactSheet` scans both `assets/svgs/` (inline-extracted
SVGs) and `assets/` root (external SVGs from `<img src="*.svg">`)
and de-dupes by filename. Sites with all-external SVGs (huly.io)
now get coverage they previously didn't.
**Design styles extractor (`designStyleExtractor.ts`, new)**
- Walks the live DOM and reads computed styles to produce
`extracted/design-styles.json`: typography hierarchy (every text
role with exact font-size / weight / line-height / letter-spacing),
button variants (background / padding / radius / shadow), card /
container / nav styles, spacing scale with base unit, border-radius
scale, box-shadow values with usage counts.
- Primary data source for DESIGN.md authoring at Step 1. Replaces
the prior "guess from screenshots" workflow.
**Snapshot tool (`snapshot.ts`)**
- HyperShader pre-rendering used to swallow the entire snapshot
capture window (every frame after the first showed the loading
overlay or final-opacity-zero exit fades). Wait signal is now
`window.__hf.shaderTransitions[].ready` (set after both warm and
cold cache paths complete); local-time seek for sub-comps means
exit fades read at their own t=0..duration, not global time.
- Gemini vision per-frame analysis runs by default (`descriptions.md`
next to the contact sheet). `--describe "custom Q"` overrides the
prompt; `--describe false` opts out.
- 3-column contact sheet generation for snapshot frames so reviewers
see all beats at a glance.
**Screenshot capture (`screenshotCapture.ts`)**
- Replaces `querySelectorAll('*') + getComputedStyle` overlay scan
with a TreeWalker that early-exits on cheap rect checks before
reaching the expensive style read. Caps at 5000 elements per page.
- Cookie/consent dismissal selectors are scoped under cookie /
consent / gdpr ancestors so we don't click "Accept invitation" or
similar unrelated buttons.
**Agent prompt (`agentPromptGenerator.ts`)**
- Auto-discovers contact-sheet page count (matches base name plus
paginated `-NNN` variants only, with regex escaping on the base
name and numeric sort for 10+ pages).
- `inferColorRole`: classifies extracted hex colors as bg-dark /
bg-light / accent / surface / neutral via luminance + saturation,
so the agent prompt shows `#533AFD (accent)` instead of bare hex.
- `design-styles.json` row is gated on `existsSync` — the upstream
write is wrapped in try/catch and may skip on failure, so the
prompt only points to files actually on disk.
**Other CLI ergonomics**
- `cli.ts`: auto-load `.env` from CWD on startup so subcommands like
`snapshot` don't need explicit `export GEMINI_API_KEY=…`. Handles
`export FOO=bar`, quoted values, inline `# comments`.
- `commands/transcribe.ts`: default output dir is the input file's
directory, not CWD. Stops the "wrote transcript.json somewhere
unexpected" footgun.
- `assetDownloader.ts`: improved asset naming uses catalog context;
de-duplicates inline SVG filenames.
- `contentExtractor.ts`: captions SVGs via Gemini (code-as-text) and
integrates them into asset descriptions.
- `tokenExtractor.ts` + `types.ts`: SVG bounding box dimensions and
new DesignStyles schema added.
Visual redesign of the timeline:
- Flat solid clip backgrounds, no gradients or multi-layer shadows
- Unified neutral color palette — all clips use the same base color
- Clean 6px border-radius instead of organic asymmetric radii
- Single-line labels, no redundant tag badge or time range
- 3px teal accent stripe on the left edge of every clip
- Simplified trim handles (2px accent bars)
Thumbnail fixes:
- Fixed broken thumbnail URLs — compositionSrc was an absolute URL
that got nested inside the preview/comp path. Now normalized to
relative path before constructing the thumbnail URL
- Thumbnail background changed from #000 to #1c2028 so transparent
overlay compositions render visible content against a matching
dark background
- mix-blend-mode: lighten on thumbnail layer — dark backgrounds blend
away, bright content shows through
- Removed double-label in CompositionThumbnail (was showing both a
badge at top and text at bottom)
Five fixes from Copilot's inline review + Miguel's note on PR #987:
1. inferWeightFromSubfamily — only matched concatenated forms
("extralight", "semibold"). Spaced ("Extra Light") and
hyphenated ("Extra-Light") variants fell through to the 400
default, misreporting 200-weight fonts as 400. Now normalizes
`[\s-]+` out of the subfamily before matching.
2. meta.tool — was hardcoded to "fontkit@2.0.4" but
`packages/cli/package.json` allows ^2.0.4, so the manifest
string would drift on every dep bump. Now records just
"fontkit"; the version moves with the dep and can be discovered
from package.json at debug-time if needed.
3. FontFileMetadata.rawFamily — docstring said "nameID 16 preferred,
then nameID 1" but the code also derives from PostScript via
deriveFamilyFromPostscript when both name-table fields are
missing. Doc now reflects the actual three-step precedence.
4. FontFileMetadata.weight — docstring said "100-900" but the code
emits 0 (when identified: false) and 950 (when
canonicalizeFamily picks ExtraBlack/UltraBlack). Doc now
documents both edge values explicitly.
5. sharp ^0.34.5 — bumped from ^0.34.0 on this PR but font
extraction doesn't use sharp; the bump is needed by the contact
sheet code in PR #988. Reverted on #987; will re-bump on #988
where it's actually consumed.
Also adds vitest coverage:
- 34 tests in fontMetadataExtractor.test.ts
- Covers inferWeightFromSubfamily for concatenated, spaced, and
hyphenated forms (including composite styles like "Bold Italic"
and case-insensitivity)
- Covers canonicalizeFamily for unchanged families, stripped
weight tokens, preserved width modifiers, and the 950 emit
- Integration tests for extractFontMetadata (non-existent dir,
empty dir) verifying the meta.tool / generatedAt shape
Exported `inferWeightFromSubfamily` and `canonicalizeFamily` for
testing. Pure functions, internal helpers, but exporting is the
clean way to pin their behavior against regressions.
Modern frameworks (Next.js, Webpack) hash font filenames like
`f9b8e1e8d4c3f0a7-s.woff2`, so the capture pipeline can't tell which
file belongs to which family by reading the filename. Sub-agents
authoring DESIGN.md were guessing or falling back to system fonts.
This adds `fontMetadataExtractor.ts`: reads the binary OpenType `name`
table via `fontkit`, identifies each downloaded font by its real
family name, and writes `capture/extracted/fonts-manifest.json` with
per-file metadata + per-family aggregates (weights, variable-font
axes, file counts).
- Canonicalizes static-weight family-name packaging: "Inter Medium"
resolves to family "Inter" with weight 500, "Semi Bold" normalizes
to "SemiBold", etc. Width modifiers ("Tight", "Condensed") are NOT
stripped — they denote separate typographic families.
- Reads variable-font axes from `fvar` so a single .woff2 carrying a
full weight range is identified as variable (e.g. "Inter (100-900
variable)").
- Uses `@types/fontkit` properly (no `unknown` cast), with a
Font/FontCollection type guard. fontkit API drift surfaces as a
compile error rather than silent undefined.
- Wired into `capture/index.ts` after `downloadAndRewriteFonts` so it
runs after fonts are already on disk. Non-fatal try/catch — capture
succeeds even if extraction fails.
Tested against 9 captures: 132/132 fonts identified by real family
name, including hashed Next.js builds.
Addresses review comments on #982:
- studio shouldTrack(): adds VITE_HYPERFRAMES_NO_TELEMETRY (mirrors CLI's
HYPERFRAMES_NO_TELEMETRY) and import.meta.env.DEV gates so dev / CI
studio builds don't pollute production telemetry. shouldTrack() is now
exported for testability.
- App.tsx session dedupe: moves the once-per-session check from a useRef
(which resets on HMR / remount) to sessionStorage via new
hasFiredSessionStart / markSessionStartFired helpers in config.ts.
- studioRenderTelemetry.ts: documents why `workers` is intentionally
omitted from emitStudioRenderError (studio renders don't accept a
user-supplied worker count, so early failures genuinely don't know one).
- client.ts flush(): documents fire-and-forget no-retry design so future
hands don't accidentally add retry logic that double-counts.
Tests:
- studioRenderTelemetry.test.ts (8 tests): perfPayload mapping for every
RenderPerfSummary field, undefined-perf path, missing-extract path,
zero-elapsed edge case, error event shape.
- studio/telemetry/events.test.ts (4 tests): pin event names
(studio_session_start, studio_render_start) and payload shape.
- studio/telemetry/client.test.ts (9 tests): shouldTrack() returns false
for non-phc_ key, opt-out, doNotTrack, build-time env, vite dev mode;
memoization.
Net diff is now +3 lines: import line and the two emit calls. Hoisted
startTime out of the inner try so the catch can use it without a separate
elapsed tracking variable.
Pre-existing complexity findings in studioServer.ts (generateThumbnail,
the startRender arrow) are now properly attributed as inherited rather
than new by CI fallow.