Selecting a rotated (cropped) element appeared to straighten it: the crop
dim and dashed window were drawn on the axis-aligned bounding box, so the
bright window was a straight rectangle and the element's rotated corners
were masked to near-black — while the DOM transform was untouched.
clip-path applies in the element's LOCAL frame, before its transform, so
the crop visualization now renders inside a container rotated with the
element: readElementCropFrame decomposes the computed 2D matrix into
angle + per-axis scale (element scale finally factored into the px
mapping too) and 3D/unparseable transforms keep the axis-aligned
presentation. Pointer deltas rotate into the element frame before the
inset resolvers, so edge/pan drags track the rotated handles correctly.
Review follow-ups (both reviewers, all findings):
- resize captures scope to the resize group: convert-to-keyframes
resolvedFromValues and the whole-offset backfill pass the group filter,
so an opacity-touching intro tween can't ride into a converted scale
tween (the rotation fix's contract, now uniform across intercepts)
- commitStaticSet resolves every group's target set BEFORE committing and
coalesces groups landing on the same legacy mixed set into one commit —
the second commit can no longer chase a stale group-derived id
- installAuthoredOpacityCapture also stamps an element the moment it GAINS
data-color-grading at runtime (attributeFilter), not just at insertion
- both writer twins now share the same emitted-set dedupe shape
- applySoftReload's positional tail becomes a SoftReloadOptions object
- readAllAnimatedProperties builds the group-filtered key set immutably
instead of deleting from the set mid-iteration
- applyAuthoredInlineOpacity documents the priority-lossy round-trip
- the marquee hit-test reads activeCompositionPathRef like its neighbors
New tests: resize intercept (scale route + group filter + non-uniform
longhands), after-write-HTML / stamp / empty-stamp opacity restore, the
no-op-commit-with-missed-instant-patch soft-reload contract, and the
runtime-gained-grading stamp.
The deselect restore read a ref recomputed from RENDER state — on a direct
A→B selection switch, state re-syncs to B before A's effect cleanup runs,
so after a committed crop gesture A was restored with B's crop string (or
lost its crop when B had none). The committed value is now written at
gesture-commit time (tri-state: none committed / crop removal / the exact
committed string), so cleanup never touches render state. Adds component
tests for lift/restore ordering, including the direct A→B switch and the
uneditable-clip stand-down.
gsap_from_opacity_noop matched 'opacity: 0' as a prefix — an authored
'opacity: 0.98' triggered the rule. One boundary-anchored predicate now
owns the exactly-zero test for both block and inline declarations,
including a block's last declaration without a trailing semicolon.
domEditingDom now imports the grading contract attribute from core
instead of re-declaring the literal.
A parsed timeline set carries immediateRender in extras; the recast
statement builder ALSO pushed the flag unconditionally, so every
split/re-add of a set wrote 'immediateRender: true, immediateRender:
true' into the file, doubling on each pass. Both writer twins (recast +
acorn) now emit each vars key exactly once, properties winning over
extras, and reconcileEditableProps skips newProps keys it already
preserved.
commitStaticSet merged every property into the FIRST set found for the
selector: a panel W edit on an element whose only set was positional
produced tl.set("#el",{x,y,width}) — a mixed-group set the split
machinery exists to prevent — labeled "Set 3D transform" in undo.
Commits now batch per property group into a set that owns that group
(exact group match, then a mixed set already carrying the group, then a
fresh off-timeline gsap.set), with undo labels derived from the group
(Move layer / Resize layer / Rotate layer / Set 3D transform).
The always-on crop lifted EVERY selected element's clip-path and restored
only what it could parse as a px inset — selecting an element with a
circle/polygon/percentage clip visually un-clipped it, and deselecting
deleted the authored clip outright.
readElementCropInsets is now tri-state (zeros = no clip, null = a clip
the tool can't represent): uneditable clips get no lift and no handles,
and the lift restores the pre-lift inline value verbatim unless a crop
gesture actually committed.
- resize on a scale-driven element commits per-axis scale (scaleX/scaleY
for non-uniform drags) with keyframe normalization to the longhands, and
clears the width/height draft so size can't double-apply; the intercept
moves to gsapResizeIntercept.ts
- the drop frame applies the corrected position synchronously in the same
microtask chain as the soft reload (no network-window jump), and the
draft pins the anchor through accumulated moves on scaled elements
- gesture size/position math divides by the element's own content scale
- convert-to-keyframes resolves current values through the property-group
filter for ALL capture passes (opacity/rotationX from unrelated tweens
no longer leak into a rotation commit), and a grading-hidden source's
opacity is read from its canvas, not the inline hide
- canvas pointer-down confirms the hover target with a synchronous
hit-test before starting a marquee (stale-hover race lost selections)
Editing commits made elements vanish or dim permanently: invalidating the
whole timeline (or re-running the composition script on soft reload) made
GSAP re-capture tween bounds while runtime transients were live — the
grading hide's opacity 0, or a mid-flight tween value — so from()/to()
bounds got poisoned and the element rendered invisible from then on.
- patch only the edited tween in place, never timeline.invalidate()
- soft reload restores every animated element's authored inline opacity
(after-write HTML first, parse-time stamp as fallback) before the script
re-runs and re-captures
- a paired x/y commit whose second half is a no-op (changed=false) still
applies its instant patch, so panel edits reflect without deselecting
The color-grading engine hides its source element with inline
'opacity: 0 !important', so any code that later reads or re-captures the
element's opacity sees the hide instead of the authored value. Stamp the
authored inline opacity on every [data-color-grading] element at document
parse time (MutationObserver installed at runtime-bundle eval, before any
composition script runs) and prefer the stamp when hiding/restoring.
Also re-sync the grading canvas when the source's inline geometry mutates
(rAF-throttled style observer): a studio drag moves the source via its
transform, which fires no media event, so the visible canvas froze in
place until the next seek.
* fix(product-launch): keep media out of frame subcompositions
* fix(product-launch): hoist approved frame videos at assembly
* fix(product-launch): format and refresh media contract
* test(product-launch): harden approved video hoist
* chore: refresh product-launch skill manifest
* fix(product-launch): validate and sanitize hoisted video attrs
* fix(product-launch): allowlist hoisted video attributes
* fix(hooks): scope pre-commit build check to the actual target repo
The PreToolUse hook matched any Bash command containing "git commit" and
always ran this repo's bun build/lint/typecheck from its own process cwd,
even when the command targeted a different repo (e.g. a sibling worktree
reached via a leading `cd`). Resolve the real target directory from the
command text first, and skip silently for any repo that isn't this one.
* fix(lefthook): force-add already-tracked files under gitignored paths
The format hook's auto-restage (`git add {staged_files}`) exits non-zero
for any staged file that lives under a gitignored directory (e.g.
.claude/settings.json, tracked despite .claude/ being ignored for worktree
noise), silently aborting the whole commit even though the file was
already correctly staged.
* fix(producer): repoint stale puppeteer symlinks to the pinned 25.x install
packages/producer's tracked node_modules symlinks still pointed at
puppeteer@24.43.1, which no longer exists after a fresh install resolves
package.json's ^25.2.1 range to 25.3.0 — breaking the producer TypeScript
build with a missing puppeteer-core module error.
* fix(producer): stop tracking node_modules symlinks
packages/producer/node_modules was accidentally swept into a prior commit
despite the repo-wide node_modules/ gitignore rule, and its ~30 tracked
symlinks silently drift from whatever bun install actually resolves —
the exact cause of the stale puppeteer symlinks fixed earlier in this
branch. CI always runs bun install --frozen-lockfile before building, so
nothing depends on these being pre-committed.
* fix(telemetry): expose stalled render stages
* fix(telemetry): preserve capture data on terminal stage events
* fix(telemetry): fix calibration TDZ crash, tag encode/assemble, extend heartbeat cadence
capture_calibration referenced captureStageObservationData before its
declaration (later in the same scope), which would throw a ReferenceError
for any render hitting the calibration path. Hoist the closure and split
workerCount's declaration from its resolution so calibration can safely
read it as undefined before capture strategy resolves worker count.
Also address the two non-blocking review items: wire encode/assemble
stages through captureStageObservationData for consistent tagging, and
extend the heartbeat schedule to repeat every 120s after the initial
30/60/120s ramp instead of going dark on stalls beyond two minutes.
`hyperframes skills update` failed hard or looped forever once a skill was
retired/renamed upstream while still installed locally (hyperframes-media folded
into media-use; hyperframes-captions/compose/tts consolidated earlier). Two
paths dead-ended:
- Install: target selection could trust a stale local skills-manifest.json
(findRepoManifest) while `skills add` always installs from the canonical repo.
isCoreSkill matches the `hyperframes-` prefix, so a retired skill was forced
into the target set, `skills add` silently declined it (exit 0), and strict
verifyInstalled threw "Skill(s) still missing after install".
- Prune: upstream `skills remove` scans on-disk directories, so a lock entry
retired before it ever shipped a bundle has nothing to match — a silent
exit-0 no-op that never clears the lock, so detectRemoved re-flags it on
every run.
The stale-skills nudge compounded it: it fired even from `skills update` itself
(pointing users back at the failing command) and its count ignored the removed
bucket.
Resolve update targets against the canonical manifest (checkSkills({ canonical:
true })) so a retired skill is never targeted. Add pruneOrphanedLockEntries to
clear the orphaned lock entries the upstream remover can't (idempotent, so a
second run is a clean no-op). Exclude `skills` from the update-nudge gate and
thread the removed count through the nudge total.
The pipeline already carried FrameCheckOptions; only the flag was
boolean, which meant a pipeline caller tuning severity or seek points
would have them silently dropped — the two sides only agreed because
today's caller happens to match the defaults. Bare --frame-check keeps
the defaults; the value form mirrors --caption-zone's grammar, freezing
the contract before a release pins it.
Port validate's per-media-element clip audit into check's session
(clip_media_fit findings): an intrinsic duration meaningfully shorter
than the data-duration slot silently shortens the slot at render time,
and neither lint nor the runtime listeners can see it. A linter crash
now reports as check_lint_failure instead of masquerading as a runtime
failure. Finding-crop capture failures stay non-gating but emit a
stderr note and a telemetry error event so rollouts can measure the
second-session failure rate.
* fix(core): escape NUL delimiters in HFMASK mask token and restore regex
Raw 0x00 bytes in the maskInertRegions token and restore regex made
timingCompiler.ts binary to git and shipped raw NULs into dist/cli.js.
Bun's transpiler (<= 1.3.11) corrupts raw NULs in regex literals into
literal backslash-uFFFD text, so restore never matched: every masked
<style>/<script> region was dropped, the player never initialized, and
bunx renders produced blank white frames showing HFMASK tokens.
Use \u0000 escapes instead, which survive any transpile layer, and add
a byte-level regression test (behavior is identical under Node, so only
a byte check catches this).
Fixes the first half of #2139.
* fix(cli): use NTFS junctions for studio project links on Windows
linkProjectIntoStudioData called symlinkSync(dir, path, "dir"), which
needs Developer Mode or elevation on Windows, so preview and dev in
local-studio mode died with EPERM for default-configured users.
Junctions need no privilege, work for directories, and keep the live
write-back the studio depends on (a copy fallback would decouple the
studio from the real project). Covers both preview and dev, which share
the helper.
Fixes the second half of #2139.
Running the CLI from source (tsx dev script, as CI's smoke job does)
transpiles with keepNames, which rewrites named inner functions in
serialized page closures into __name(...) calls — a helper that exists
in the Node bundle but not in the browser realm. The unified seek
closure was the first validate-path page function with named inner
functions, so 'hyperframes validate' threw '__name is not defined' in
CI while the dist build worked. Every session opener now installs a
no-op __name shim via evaluateOnNewDocument before any page script
runs, immunizing all serialized closures regardless of build mode.
Three filters keep the escalated contrast gate honest, each surfaced by
running check across the registry examples:
- data-layout-ignore (the layout audit's existing decorative opt-out)
now also excludes set-dressing text from the contrast audit — one
vocabulary for 'not copy a viewer must read'.
- Text that has (nearly) left the canvas is skipped: sampling a clamped
off-canvas box reads border pixels and produced the classic false
white-on-white (a cursor exiting the frame).
- Contrast failures follow the same persistence rule as layout findings:
observed at a single sample of a multi-sample sweep demotes to
warning; held failures gate.
Example fixes the sweep exposed: motion-blur's 21 deliberately-dim rail
labels are marked decorative (its vivid labels pass on their own);
nyt-graph's subtitle and source-note adopt the gate's suggested
compliant gray; decision-tree's declared duration drops 15s to 10s —
its content ends at ~9.5s and every render shipped a blank white tail.
Every duplicated decision gets one owner: rectToBbox lives in checkTypes
(was verbatim in pipeline and browser layers); the audit seek tuning is
one exported AUDIT_SEEK_OPTIONS consumed by check and the deprecated
inspect path; zoom padding/scale defaults export from the capture module
instead of re-literalized in three files; the optional run_id property
is built by one helper across all three telemetry events; check's
--max-transition-samples parsing reuses its own positiveInteger helper;
validate drops a leftover re-export and redundant explicit-default args
go away.
Tests: the contrast candidate round-trip gains a real integration
anchor (the actual browser script eval'd in-page, a wrapper asserting
finish receives the page-script bbox shape) replacing regex-over-source
as the primary guard; the redundant geometry source-golden and a
duplicated deprecation-envelope assertion are dropped.
Scaffolded projects' npm run check now invokes the single check command
instead of chaining lint, validate, and inspect (three Chrome boots
become one). The CLI skill, its correctness reference, the entry skill's
capability map, README/docs catalog rows, the Mintlify CLI page (new
check section, deprecation banner on inspect), template CLAUDE/AGENTS
(byte-identical), root CLAUDE/AGENTS, and every creation-workflow skill
that taught the old sequence all point at check. snapshot keeps its
standalone sections; validate/inspect stay documented as deprecated
aliases with their check equivalents.
The persistence-tier promotion surfaced two held content_overlap errors
on the hero heading; zooming in shows the collaborative-cursor demo
(cursor badge + typed comment bubble) hovering by design. Opt out with
data-layout-allow-overlap per the finding's own fixHint. The example's 4
pre-existing gsap_css_transform_conflict lint errors stay for a separate
example-maintenance pass.
Layout findings now distinguish held defects from entrance/exit
transients: a dynamic issue seen at a single grid sample demotes to
info, while content_overlap held across two-plus samples (or 500ms+)
promotes to error, resolving the long-standing re-promotion TODO. Static
compositions keep their severity. check gains a sweep_static error when
a 3s+ composition shows zero geometry change across every sample (a
frozen timeline makes every green verdict unreliable); skipped when the
motion sidecar already reported motion_frozen. text_occluded findings
carry a coveredFraction; atomic labels (short, no whitespace) flag on
any cover while prose needs 15%, since partial cover changes what a
short label reads as.
Deprecation-test scaffolding consolidates into deprecationTestHarness;
tier logic and logger tests restructured under the complexity gate
without suppression markers.
Detection mechanics adapted from Adam Rosler's open-sourced
visual-linter design (github.com/Adam-Rosler/hyperframes-visual-linter-design);
the elementFromPoint paint model, opt-out attributes, and single-audit
architecture are unchanged.
snapshot --zoom <selector|x,y,w,h> + --zoom-scale (default 3) crops via
Puppeteer clip at raised deviceScaleFactor — density changes, layout
never does. Selector resolves per frame with 24px padding; no match is
a loud error, and a frame whose clamped region is a sliver (element
collapsed or animated off-canvas) is skipped with a stderr note rather
than written as a useless few-pixel image.
check --snapshots additionally writes finding-NN-<code>.png crops for
error findings with bboxes (cap 12, deterministic re-seek in a second
session) and draws labeled annotation boxes on overview frames via a
transient overlay injected only after audits complete. Skill reference
gains the zoom workflow: check reports a finding, zoom into it, fix,
re-check.
One stderr notice per invocation and _meta.deprecated: true in JSON mode
(shared helper next to withMeta; layout owns both inspect and layout via
createInspectCommand). Help descriptions gain the pointer. No behavior
change; removal ships separately once migration telemetry says usage
has decayed.
fix(producer): route info/debug logs to stderr — the compiler's
'Localized remote media' line was landing on stdout ahead of validate's
--json payload, breaking every piped consumer. Diagnostics now share
stderr with warn/error; render progress uses its own channel.
HYPERFRAMES_RUN_ID (trimmed, 128-char cap) attaches as run_id to the
generic cli_command / cli_command_result events, absent when unset, so
an orchestrator setting it per design element can group a verify loop's
invocations in analytics. check additionally emits one check_report
event per invocation (including lint-short-circuited and failing runs):
gate booleans, per-class error/warning counts, launch/seek/contrast
phase timings, sample counts, ok and exit code. Timings stay internal;
no command output changes.
Ports the EF bridge's captionZone and frameCheck semantics as opt-in
flags so the bespoke bridge can be retired: --caption-zone takes
fractional band geometry (x0;y0;x1;y1) with optional severity routing
and seek points, defaults matching the bridge (caption seek [1], frame
seek [0.5], 2px tolerance, 0.05 opacity floor, 4px minimum size, 0.95
full-frame exclusion, center-in-band comparison, tag|text dedup).
--frame-check adds media bounds detection (img/svg/video/canvas) the
always-on text canvas_overflow never covered, reusing overflowFor.
Breach floor: max(120px, 6% of min canvas dimension). Band math derives
from the composition's own canvas, portrait included. Both gates off by
default; plain check output unchanged.
One command, one Chrome boot: in-process lint gate (browser skipped on
lint errors), passive runtime capture wired before navigation, layout +
motion + contrast audits over one seek grid, optional --snapshots
persisting the contrast-pass screenshots. Aggregated --json envelope
{ok, lint, runtime, layout, motion, contrast, snapshots}; findings carry
selector/data-*/source-file/bbox/time anchors, contrast findings include
fg/bg, measured vs required ratio, and a compliant color suggestion.
Contrast AA failures gate the exit code (they were warning-only in
validate); --strict gates warnings.
Contrast candidates round-trip verbatim between __contrastAuditPrepare
and __contrastAuditFinish: the page script owns their shape (bbox w/h),
and normalizing them Node-side made every sample rect NaN — the audit
reported zero checked elements as green. Regression-pinned in
check.test.ts; E2E on a low-contrast fixture now exits 1 with 8 findings.
Measured on kinetic-type: check 5.6s vs 23.0s for sequential
validate + inspect + snapshot.
seekCompositionTimeline becomes the single seek implementation with
per-caller settle options (rAF mode, font wait, settle sleep), replacing
the divergent local seekTo copies in validate and layout. All three
launch paths now build args via the engine's buildChromeArgs; screenshot
paths keep the engine's software-GPU default for deterministic output.
inspect gains one transient content_overlap warning on product-promo
(t=12.22s): the gsap.ticker.tick flush samples timeline state the old
layout seek missed.
* feat(media-use): usage visibility — shared telemetry identity, miss log, resolve --stats
- U6: join the CLI/studio telemetry identity — read the shared install id from
~/.hyperframes/config.json (seed if absent) instead of a media-use-only
~/.media/anon-id, and $identify to the HeyGen account (email/username) once
per run on sign-in. One PostHog person across surfaces; pseudonymous before
sign-in, account-linked after. Event properties stay coarse (no intent/paths).
- U1: one-time first-run disclosure to stderr + Privacy section in SKILL.md;
honors DO_NOT_TRACK / HYPERFRAMES_NO_TELEMETRY.
- U2: persist resolve misses to ~/.media/misses.jsonl (local → intent kept;
the media_use_resolve_miss telemetry event stays intent-free).
- U3: `resolve --stats` (+ --days) — local usage report over .media/ + ~/.media
(volume by type, source/provider/via split, hit-rate, top missed intents,
global-cache size/reuse); human + --json.
- U4: reproducible PostHog dashboard definition (references/telemetry-dashboard.md).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* fix(media-use): address #2113 review — shared notice state, legacy id migration, stats robustness
- Notice-shown state now lives in the shared ~/.hyperframes/config.json
(config.telemetryNoticeShown, the CLI's own field) instead of a media-use-only
~/.media marker — so shared-identity users see the first-run notice once per
person, not once per tool.
- Migrate a pre-existing ~/.media/anon-id into the shared config on upgrade, so
media-use-only users keep their PostHog persona instead of resetting.
- buildStats: --days only windows on a positive finite value (negative/NaN → all
time, not an empty report); dropped the top-level catch that masked a real
error as an all-zero "no usage" report (sub-reads are individually guarded).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(media-use): fast heygen CLI onboarding — actionable diagnostics, --doctor, free-usage framing
media-use resolves bgm/sfx/image/icon (catalog), voice (TTS), and avatar video
through the heygen CLI — the free-usage path. Agents hit a dead end when it's
missing/unauthed. This guides them to install it fast, at the point of need.
- Centralized actionable diagnostics (lib/heygen-cli.mjs): every heygen-backed
resolve, on failure, prints the exact fix on stderr — not-installed (curl
install one-liner), not-authenticated (heygen auth login), outdated (heygen
update). Routed through heygen-search + voice-provider. stdout stays clean JSON.
- resolve --doctor preflight (human + --json): checks heygen present/version/
auth, ffmpeg, ffprobe, node, a fix per gap. Exit 0 unless ffmpeg missing.
- SKILL reframe: install-first callout; heygen as the free-usage gateway for
bgm/image/voice/avatar-video; removed the false "degrades gracefully" claim.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* fix(media-use): address #2065 review — classifier blocker, doctor contract, telemetry
- Blocker: classifyHeygenError no longer treats a bare "not found" as CLI-missing
(a stale voiceId → "voice not found" was sending users to reinstall a working
CLI); keep only ENOENT + "command not found". Regression test added.
- 401 now matches \b401\b, not any "401" substring (request IDs no longer misread).
- --doctor: top-level ok requires ffmpeg AND ffprobe (matches SKILL.md); emits
media_use_doctor_run telemetry; auth status queried with --json + JSON-only
parse; auth timeout softened (network issue, not a false "unauthenticated");
node version gated on >= 18; version-without-semver labeled, not silently green.
- Nits: install cmd uses && ; dropped the runResolveStatus alias.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* fix(media-use): require OAuth-capable heygen CLI (v0.3.0), fix auth-status probe
E2E against the live free-usage backend surfaced three issues:
- HEYGEN_MIN_VERSION was 0.1.6, but that CLI can't use OAuth ("heygen-cli can't
use OAuth yet") — free usage needs >= v0.3.0. Bumped the floor; --doctor now
also nudges `heygen update` when a newer stable exists (always-latest).
- Onboarding pointed at `heygen auth login --key` (API credits / billing); the
free path is `--oauth` (subscription/free credits). Fixed install + auth
guidance and SKILL.md accordingly.
- `heygen auth status --json` is an unknown flag on v0.3.0 (JSON is the default
output) — the added --json broke auth detection. Dropped it; verified
--doctor reports authenticated on a real free (OAuth) account.
Tests assert against the exported message constants instead of brittle literals.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* fix(media-use): address #2065 review nits — one root cause on old CLI, floor policy
- --doctor skips the auth check when the version check fails (below v0.3.0): an
old CLI's auth probe fails for the same root cause, so users no longer see two
errors ("outdated" + "not authenticated") — one root cause, one fix.
- Comment links the auth-status probe's JSON-default assumption to
HEYGEN_MIN_VERSION >= 0.3.0 so the floor isn't silently lowered later.
- SKILL.md states the uniform v0.3.0 requirement (nudged even for API-key use).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* fix(media-use): doctor prints one heygen row per fact
The 'heygen on PATH' and 'heygen version' checks both rendered their
detail as `heygen v0.3.0`, so --doctor printed two byte-identical green
lines. Make the PATH row report presence ("heygen found on PATH") and let
the version row own the version string — one row per fact, no duplicate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(media-use): use CLI free HeyGen usage
* fix(media-use): address #2027 R1 nits — gate cli-source header to OAuth, export origin constant
- X-HeyGen-Source is now sent only on OAuth (Bearer) requests, not API-key ones —
the backend ignores it for API-key traffic (normal billing), so it was dead
metadata there. buildAuthHeaders + heygenAuthHeaders + tests updated.
- Export HEYGEN_CLI_ORIGIN_HEADER ("X-HeyGen-Client-Origin") for future cli:<origin>
consumers.
- Document the deliberate paid/X4 confirm-before-call decision on heygen.tts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* refactor(cli): drop unused origin-header export, dedup auth-client tests
Fallow flagged 5 findings on this PR:
- major: HEYGEN_CLI_ORIGIN_HEADER was exported but never emitted or
imported — speculative dead code ("future consumers"). Remove it; a
real consumer can add the constant when one exists.
- 4x minor duplication in client.test.ts: fold the repeated
`.rejects.toSatisfy(auth-code)` assertion into expectAuthCode(), and the
repeated try/catch scrubbed-message assertion into expectRejectionMessage().
No behavior change; auth/client tests still 17/17.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## What
Fixes five reported false-positive/false-negative patterns in the WCAG contrast audit (`hyperframes validate --contrast`):
1. **SVG fill vs. text color** — foreground read from CSS `color` instead of SVG `fill`.
2. **Cross-component color bleed** — background estimate bleeds into a neighboring panel/layer.
3. **Backdrop-filter glass text** — background estimate misses the blur/tint and reads the raw backdrop.
4. **Partially-overlapping translucent decoration** — a decorative shape inside or partly touching the text's bbox goes undetected.
5. **Solid-fill pill/button** — investigated, did **not** reproduce; already handled correctly by the existing own-background ancestor walk. Not touched.
## Why
The audit estimated an element's background two ways:
- foreground: always `getComputedStyle(el).color` — wrong for SVG `<text>`/`<tspan>`, which is painted via `fill`, an independent CSS property.
- background: a 4px pixel ring sampled just **outside** the text's bounding box, with a fallback to an ancestor's opaque `background-color` for solid pills/buttons.
The ring is a proximity heuristic. It's wrong whenever what's immediately outside the text differs from what's actually behind it:
- text near the edge of its own panel, with a differently-colored sibling panel/layer just past the bbox — the ring samples the neighbor.
- a `backdrop-filter: blur()` glass panel sized only a couple pixels larger than the text — the ring exits the panel into the raw, unblurred, untinted backdrop.
- a translucent decoration that only partially overlaps the ring, or sits entirely **inside** the bbox — invisible to the ring regardless of size.
## How
**SVG fill (#1):** elements inside an `<svg>` (`el.ownerSVGElement`) now prefer the computed `fill` when it resolves to a solid `rgb()`/`rgba()` color, falling back to `color` for paint values that aren't a plain color (`none`, `context-fill`, gradient/pattern refs).
**Cross-comp bleed / glass blur / partial decoration (#2–#4):** replaced the ring-sampling + own-background-ancestor-walk heuristic with a two-phase capture:
1. `__contrastAuditPrepare()` walks the DOM, computes each candidate's foreground (unchanged logic from #1), and **hides that element's own text paint** (`color`/`fill` → `transparent`, layout-neutral — no reflow).
2. The caller takes **one** screenshot with the glyphs invisible (same number of screenshots as before — just moved after the hide instead of before it).
3. `__contrastAuditFinish(imgBase64, time, candidates)` restores the original paint immediately, then samples the **real composited pixels directly inside each element's own bbox** — no proximity heuristic needed, since these are the exact pixels that were behind the glyphs.
This is a real architectural change to `contrast-audit.browser.js`'s calling contract (single `__contrastAudit` → `__contrastAuditPrepare`/`__contrastAuditFinish`), with `validate.ts`'s `runContrastAudit` updated to match, including a try/finally restore-safety-net so a mid-loop screenshot/decode failure can't leave a later sample auditing a page with stale hidden text.
Mirrored the identical change in `skills/hyperframes-creative/scripts/contrast-report.mjs`, which duplicates the same DOM-walk/sampling logic (not just the WCAG math). There, the **visible** frame for the human-facing overlay image still comes from the producer's normal `captureFrameToBuffer` path (unchanged); only the **background-sampling** capture is a plain `session.page.screenshot()` taken after hiding text — deliberately bypassing `captureFrameToBuffer`, whose static-frame dedup cache knows nothing about the DOM mutation and would hand back a stale pre-mutation buffer.
**Solid-fill pill (#5):** reproduced a rounded pill/button with a busy page background outside it. The existing own-background ancestor walk already resolves the pill's declared `background-color` correctly regardless of the rounded corners — confirmed via repro, both before and after this change report the identical (correct) result. No fix needed; left untouched, and this case is covered by the new architecture too (would give the same right answer even without the ancestor-walk fallback).
Added `packages/cli/src/commands/contrast-sample.ts` (mirroring the existing `contrast-bg.ts`/`contrast-fg.ts` pattern) hosting the pure sample-rect/grid-point computation, unit tested — the browser-injected scripts can't import it directly, so it's kept in sync by hand, same convention as the rest of this file.
## Test plan
- [x] Unit tests: `contrast-fg.test.ts` (SVG fill resolution), `contrast-sample.test.ts` (sample-rect clamping/degenerate cases), plus the full `packages/cli` suite (1424 tests) passes, including an updated `layout-audit.browser.test.ts` case that called the old single-function `__contrastAudit` API directly.
- [x] Manual verification — standalone `puppeteer-core` harness against real `chrome-headless-shell`, one minimal HTML fixture per pattern, comparing the audit's reported ratio/verdict against a hand-constructed ground truth:
- **SVG fill**: `fill:white` / no `color` on black bg → before: `fg=rgb(0,0,0)` ratio `1:1` (false FAIL); after: `fg=rgb(255,255,255)` ratio `21:1` (correct PASS).
- **Cross-comp bleed**: text on a black sibling highlight box 2px larger than the text, white page bg outside it → before: `bg=rgb(255,255,255)` ratio `1.23:1` (false FAIL); after: `bg=rgb(0,0,0)` ratio `17.14:1` (correct PASS).
- **Glass blur**: black text on an 18%-white-tinted `backdrop-filter: blur(14px)` panel over a yellow/blue gradient, panel only ~2px larger than the text → before: `bg=rgb(0,64,255)` (raw gradient color, blur/tint completely missed) ratio `3.18:1` (false FAIL); after: `bg=rgb(159,160,165)` (correct blurred/tinted blend) ratio `8.05:1` (correct PASS).
- **Partial decoration**: text 92%-covered by a translucent white badge on a dark bg → before: `bg=rgb(16,16,16)` (ring never touches the badge, which sits entirely inside the bbox) ratio `17.45:1` (false PASS); after: `bg=rgb(171,171,171)` (correctly detects the badge) ratio `2.11:1` (correct FAIL).
- **Solid pill sanity**: unaffected — `bg=rgb(10,10,10)` ratio `19.8:1` before and after.
- [x] End-to-end: ran the actual `hyperframes validate --contrast` CLI command (via `tsx src/cli.ts`) against a real scaffolded project containing all 4 patterns simultaneously — only the genuinely-failing case (the 92%-covered decoration) is reported (`1.09:1`, need `3:1`); the cross-comp-bleed, glass-blur, and solid-pill cases are correctly silent. A second vanilla scaffold with plain white-on-dark text produces zero false positives.
- [x] `oxlint`, `oxfmt --check`, and `tsc --noEmit` all pass on the changed files.
handleTimelineElementSelect tags each call with a monotonic token and ignores its result
if a newer selection started while it was resolving, so a rapid A-to-B clip click can no
longer let A's slower async lookup land after B and restore the wrong selection.
A timeline move/resize recorded the timing patch, then a server GSAP rewrite mutated the
same file afterward, leaving the recorded after stale so an undo hit a hash conflict. The
GSAP mutation now snapshots the touched files and records a follow-up edit under the same
coalesceKey, with a per-entry coalesceMs override large enough to survive the GSAP round
trip, so undo restores the original in one step. Applies to single-clip and group edits.
setSelectedElementId now always collapses to one element (genuine user intent); a new
setSelectionAnchor moves the anchor within a multi-selection without collapsing it, used
only by the DOM-to-store sync echoes so a group survives a gesture.
applyDomSelection mirrors the whole DOM group into the store via setSelection instead of
writing only the anchor, so the store stays authoritative and a preview click collapses
while a preserved-group echo keeps every member.
The store-to-preview sync no longer applies a partial selection: if a resolvable member's
DOM node is not ready yet it bails and retries on the next effect run, so the write-back can
never shrink the store's selection by dropping an unresolved member.
Marquee row hit-testing reuses shouldShowTimelineLayerGroupHeader instead of re-deriving
the group-header placement rule, keeping one owner for that predicate.
The DOM-selection to timeline sync routes through the canonical resolveTimelineIdForSelection
(source-file, ancestor, active-comp fallback) instead of a narrow domId/id match that
mismatched sub-composition clips.
The preview-sync equality check compares selection as sets both ways and includes the
anchor, so duplicate resolutions no longer mask an unsynced member.
Group move/resize rejects the gesture when any selected member forbids the op (e.g. a
locked clip), so a group never edits a clip that individually cannot move, and a
persist failure now propagates so the optimistic preview rolls back.
Snapping excludes every moving member, not just the grabbed clip. The marquee hit-test
uses the real pixels-per-second (was floored at 1, wrong below 1x zoom), and a
sub-threshold marquee click scrubs the playhead like a plain lane click.
Extract resolveTimelineIdForSelection so DOM-to-timeline id mapping lives in one place
with a single sourceFile / activeCompPath / index.html fallback, fixing a sub-composition
selection that previously diverged between callers.
Extract shared start-trim delta helpers used by both single-clip and group resize, and
remove the never-called refreshDomEditGroupSelectionsFromPreview.
setSelectedElementId no longer resets the selection set when re-selecting an element
that is already a member: DOM-to-selection sync echoes fire on every pointer move during
a group drag and were collapsing the set to the grabbed clip.
Also drops the duplicate clearSelectedElementIds action in favor of clearSelection,
which the marquee now uses to clear on an empty drag.
Apply handle deltas through a group resize session that snapshots selected members.
Clamp one shared delta against zero start and min duration before preview and commit.
Playback-start changes are calculated per member with the existing trim formula.
Fan selected body drags through a group move session that snapshots all selected members.
The grabbed clip snap result is applied first, then one shared delta is clamped.
Live preview patches every moved member and release persists once through batch timing.
Use selectedElementIds as the live selection set with selectedElementId as anchor.
Keep single-click selection as a one-element set and clear both fields together.
handleDomZIndexReorderCommit no longer swallows per-entry save failures: it settles every
patch, and on any rejection rolls back the eager DOM z-index/position and the optimistic
store zIndex before rejecting, so a failed save cannot leave the UI showing a stacking order
that never persisted or let an ordered-after timing write proceed.
Also removes the dead targetTrack parameter threaded through the timeline edit helpers;
vertical placement is owned by the z-index intent.
* fix(cli): map zh to espeak-ng's cmn for Kokoro TTS synthesis
espeak-ng 1.52.0 recognizes Mandarin Chinese as the ISO 639-3 code "cmn",
not Kokoro's own voice-prefix convention "zh". `hyperframes tts --lang zh`
was forwarding "zh" straight through to kokoro_onnx.Kokoro.create(), which
failed with "language zh is not supported by espeak backend". Translate
only at the Python/espeak boundary; the public --lang value stays "zh"
since that matches Kokoro's own docs and voice-ID prefixes.
* test(cli): cover Kokoro zh language override
A drag that both moved a clip in time and restacked it fired two writers on
the same file via separate queues (a targeted z-index patch and a full-file
timing overwrite), so the overwrite could clobber the just-persisted z-index
and the restack silently vanished after reload. The move now awaits the
z-index commit before persisting timing, giving the file one ordered writer
per gesture. Adds a regression test that gates the commit and asserts the
timing write waits.
The ruler now generates ticks across the visible width instead of stopping
at the composition end, so a zoomed-out ruler stays labelled to the right
edge. The tick interval stays pps-driven, so spacing is unchanged. Logic
lives in a new generateVisibleTicks helper to keep Timeline.tsx under the
600-line cap.
When the composition is narrower than the panel (zoomed out) the track
lanes, ruler bar, and context header now fill the whole timeline instead
of stopping at the content width and leaving a flat dark void. Content
stays in composition coordinates, so ticks, clips, and the playhead are
unaffected.
The structural refresh now force-syncs data-active on freshly queried clip
nodes instead of diffing. A clip that changes lanes on a reorder remounts as
a new DOM node; if it stayed under the playhead the diff skipped it and it
rendered as inactive.
Clips share a lane only when they carry the same z-index and don't overlap
in time. Previously a non-overlapping clip packed into the first
time-compatible row regardless of z, so restacking changed the z-index but
the clip's row never followed.
Lane/overlap resolution now uses the clip's authored time span instead
of the live dragged start. A diagonal drag that drifts the clip out of
overlap no longer flips the placement from restack to lane-join, so the
two axes never fight.
CI file-size check and fallow audit caught two issues the --no-verify
commits skipped: Timeline.tsx sat at 602 (max 600) and
postRootDurationToPreview was an unused export (only called in-file).
Trim a comment to hit 600; drop the export.
Extending a clip past the video end used to force the server-fallback
path that fully remounts the preview iframe (the SDK fast path can't
express the root composition's data-duration, and the runtime bakes+drops
data-duration at load so it can't be patched live). On a large comp that
remount is a visible hitch.
Add a runtime control-bridge action set-root-duration -> clock.setDuration,
so the studio can grow the transport length in place. On an extend the
studio now posts it (and patches the clip's own timing live) instead of
reloading; it only reloads when a GSAP source rewrite actually happened
(the gsap-mutation endpoints now report a mutated flag). Non-animated
extends — the common case — commit as fast as a normal edit.
Verified: bridge dispatch + studio no-reload/post-message paths unit-
tested; core/studio/studio-server typecheck + suites green; the built
runtime artifact carries the handler; E2E confirms the extend no longer
remounts the preview and still persists.
Dragging a clip past the video end fed back on itself: the growing preview
length shrank the fit-to-width zoom, which remapped the pointer, moved the
clip, and grew the length again (visible jumping). Split the committed
basis duration (drives zoom) from the displayed effective duration (adds
the live preview for ruler + width). Zoom holds fixed through the gesture
and the extra length scrolls; it re-fits once on drop. Duration math
extracted to pure tested helpers in timelineLayout.
Timeline moves and resizes previously snapped only to the beat grid; they
now also snap the dragged edge to other clips' start/end, the playhead,
and the composition bounds, whichever is nearest within the existing
threshold. Beats fold in as one target kind, so beat snapping is
unchanged. Snap logic lives in a new pure timelineSnapTargets module with
unit tests; a snapped edge shows a guide line (beat highlight, else a thin
accent line) for both drag and resize.
A vertical drag onto a lane whose clips overlap the dragged clip in time
used to resolve to the "nearest valid lane", which included the clip's own
lane — so a small drag snapped straight back and felt like the editor
refused the move. Overlapping clips can't share a row (a row is 1-D in
time), but the drag should still restack, never reject.
Now a conflicting onto-drop converts to an edge insertion adjacent to the
target by drag direction (up -> above, down -> below): the clip lands on
its own lane just in front of / behind the target, with its time
unchanged. Non-overlapping drops still join the lane. Deletes the
nearest-valid-placement search (~90 lines) this replaces.
Dragging a timeline clip (or its right resize edge) past the end of the
video now extends the composition duration on drop, instead of clamping
the clip at the current end. Extend-only and undoable.
- Relax the move/resize horizontal clamps that pinned a clip's end at the
current duration; effectiveDuration now folds in the active drag/resize
preview so the ruler and track width grow live as you drag past the end.
- On drop, extend the root composition data-duration (and the store) when
the clip's new end exceeds it, via a shared extendRootDurationInSource
helper extracted from the block installer (now the single owner of that
logic). An extending edit routes through the server persist path since
the SDK setTiming op can't express the root composition's own duration.
Rework the timeline row model from one-row-per-stacking-layer to NLE
"lanes": a row holds a time-sequence of non-overlapping clips, ordered
top = front. Stacking only matters between clips that overlap in time.
- Lane packing: per stacking context, sort clips by effective z-index
desc (DOM order tiebreak) and greedily pack each onto the first lane
whose members don't overlap it in time; else open a new lane. Lane's
representative z is the max member z. Audio stays one clip per lane.
- Lane-aware drag: dropping onto a lane joins it (clip takes the lane's
z) when there's no time-conflict; a conflicting drop is rejected and
the preview snaps to the nearest valid lane or a new-lane insertion.
Between/above/below still create a new lane at that stacking level.
- Overlap checks use the drag-preview start/duration, so conflict is
judged by where the clip lands, not where it started.
Only explicit drags write z-index; authored z is preserved and
data-track-index is never rewritten.
L4 polish for the stacking-layer timeline:
- Sub-composition layers group under a slim "Inside: <comp>" header with a green
accent, so it reads that restacking there is scoped to that context.
- Audio clips render in a distinct bottom lane: separator border, muted row bg,
music glyph in the gutter, beat strip — clearly not part of the z-order stack.
- During a vertical drag, a drop affordance shows join-vs-insert: an "onto"
target highlights the row (join that layer / same z); "between"/"above"/
"below" shows an insertion line (new layer here).
New pure helper timelineDropIndicator.ts (+ test) maps placement -> indicator;
row-group rendering extracted into TimelineLayerGroupHeader / TimelineLayerGutter
/ TimelineDropInsertionLine / TimelineDragGhost to keep files under the 600 cap.
applyTimelineStackingReorder resolved each z-index change by looking the clip up
in the top-level timelineElements list, but sub-composition children live only
in the expanded list, so the lookup missed them and the reorder silently bailed.
Carry the element's locator (domId/selector/sourceFile) on each z-index change
so the commit resolves the live element directly from the preview DOM. Verified
E2E: dragging a clip inside a sub-composition restacks it and patches the
sub-comp source, while the parent composition is untouched.
Adds timelineEditingHelpers.test.ts (locator-based commit + audio no-op).
applyTimelineStackingReorder resolves the live clip from the preview IFRAME,
then gated it with `element instanceof HTMLElement` against the MAIN window's
constructor. Cross-realm instanceof is always false, so every timeline z-index
commit silently bailed ("element not live in iframe") — the drag resolved the
right z but never wrote it. Use the element's own-realm HTMLElement constructor
(matching timelineDOM.ts). Verified end-to-end: dragging a card down now lowers
its z-index and reorders the row, leaving sibling z-indexes untouched.
Unit tests missed this because the happy-dom test iframe shares a realm; caught
via a real-browser Puppeteer E2E drag.
Re-architect the timeline row model from data-track-index rows to stacking
layers. Rows represent stacking layers per context: explicit-z clips merge onto
one track when they share a z and don't overlap in time; auto-z clips stay one
row each (DOM order); audio is pulled into its own bottom lanes. Rows keyed by a
stable layer id, not data-track-index.
Vertical drag always writes z-index, never track: drop onto a layer joins it
(same z), between layers interpolates a new z, past the ends creates a new
front/back layer. data-track-index is never rewritten; #958 holds. Adds
hasExplicitZIndex capture (computed z != auto).
L1: hasExplicitZIndex on the element model
L2: buildStackingTimelineLayers (layer-based rows)
L3: layer-aware vertical drag (join / interpolate / new-extreme)
createTimelineElementFromManifestClip used `clip.zIndex ?? computed`, but the
runtime reports inline-only z-index (0 for CSS-rule authored z-index), and
`0 ?? x` keeps the 0 — so every CSS-styled clip collapsed to a z=0 tie. The
timeline then ordered rows by DOM position instead of true stacking, and the
first vertical drag renumbered the whole tie group, clobbering the author's
z-index. Prefer the effective computed read from the live element (the same
read the reorder commit uses); fall back to the runtime value only when the
element isn't live.
The element stacking key (element.key ?? id) was recomputed in four places
(reorder-intent generation, row ordering, the commit-time sibling lookup via a
threaded keyOf param, and resolveTimelineMove). Any drift would silently break
the sibling lookup and no-op the reorder. Route all of them through the existing
getTimelineElementIdentity owner, share one toStackingOrderItem mapper between
row ordering and reorder intent, and drop the keyOf parameter.
Also enforce the audio side-effect invariant in the single mutation owner
(applyTimelineStackingReorder): dragging an audio clip has no visual layer to
restack, so it never writes z-index. Covered by a new hook test.
Timeline rows now order by scoped stacking (z-index per stacking context)
instead of data-track-index, and dragging a clip up/down commits a targeted
z-index change through the same shared path the layers panel uses. Both panels
stay consistent and moving a clip actually changes front/back. data-track-index
is demoted to time-overlap layout only; no bulk z-index injection (#958 intact).
Also restores beat-snapping on keyframe retiming (re-wires snapKeyframePctToBeat,
orphaned when keyframe dragging was removed) which surfaced while unifying the
model. Extracts pure track-ordering logic to timelineTrackOrder.ts, the stacking
reorder commit + deleteSelectedKeyframes to timelineEditingHelpers.ts, to keep
StudioApp / the timeline hook / Timeline under the studio 600-LOC cap.
U3: scoped stacking row order in the timeline
U4: vertical drag commits z-index via the shared reorder commit
U8: restore keyframe beat-snap on retime
The storyboard view's empty state was a single "no storyboard yet" line. Add a
copy-to-clipboard prompt box (a ready-to-paste handoff prompt carrying the
canonical STORYBOARD.md frontmatter + per-frame format) so users can hand it to
their coding agent, plus a faded skeleton of the contact-sheet grid so landing
on an empty board previews what a filled one looks like instead of a dead end.
Crop is now part of the element selection instead of a separate mode. Selecting
a croppable element shows edge handles just outside each side and, once cropped,
the full content with the cropped-away area dimmed plus a center reposition
handle to pan the crop window. Dragging the body moves the element, edge handles
crop, the center handle pans; corners stay free for the resize handle. Removes
the crop-mode toggle (toolbar + property-panel buttons), the cropMode/
cropAvailable player-store state, and the double-click-to-crop gesture. The
clip-path inset model is unchanged.
Sub-composition <head> styles targeting html/body/:root (width/height/
overflow/background) were injected into the parent document unscoped by both
the Studio runtime mount (compositionLoader) and the render-time inliner
(inlineSubCompositions/htmlBundler). scopeCssToComposition deliberately passed
html/body/:root through unchanged, so a sub-composition smaller than the root
clobbered the host <body> dimensions and its overflow:hidden clipped the
composite to the last sub-comp's size. Only the top-left element painted;
everything else (and framework-owned video positioned outside that box) was
clipped away.
Add a scopeRootSelectors option to scopeCssToComposition that remaps
html/body/:root to the composition's own box, and enable it everywhere
sub-composition styles are scoped. The universal selector stays untouched.
Top-level composition scoping is unchanged (it legitimately owns the document).
Covered by new compositionScoping tests.
* feat(media-use): color grading — grade/lut resolve, smart-grade, grade-compare CLI
Add color grading to media-use as first-class resolve types plus a faithful
comparison command. All local, offline, deterministic — no model, no GPU.
- resolve -t grade / -t lut: produce a data-color-grading block (or a frozen
.cube). Look cascade: core preset (no file) -> bundled .cube library ->
parametric buildCube. Emitted .cube is Rec.709 and validated against core's
colorLuts constraints (LUT_3D_SIZE <= 64) before it is frozen.
- smart grade (grade --for <media>): ffmpeg signalstats -> adjust suggestion
(exposure / contrast / white balance), surfaced with the measured evidence on
stderr as a starting point; never auto-applied.
- hyperframes grade-compare: renders N candidate grades onto a reference frame
through the real runtime shader into one labeled comparison PNG, so an agent
picks a look without opening Studio. Prepends an "original" baseline cell by
default (--no-baseline to omit). Shares the headless-capture pipeline with
snapshot via capture/captureCompositionFrame.
- media-use SKILL: proactive "media opportunity pass" guidance (grounded
signal -> offer, ask once, surface don't mutate).
Verified: media-use 116/116, grade-compare 7/7, snapshot 9/9, lint + format
clean, full build green, comparison renders end to end.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* test(cli): narrow grade-compare baseline assertion off unknown-typed grading
Assert the whole cell via toEqual instead of reaching into .grading.preset /
.grading.lut on the unknown-typed field, keeping the test typecheck-clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* feat(media-use): agent-authored LUTs via --params + validate --from cube; never-read-.cube guardrail
- resolve -t lut / -t grade --params '<json>': build a parametric .cube from
explicit params (bypassing the intent cascade), validate, and freeze in one
step. --intent becomes the optional description. Lets an agent commit a look
it computed itself.
- --from <file.cube> now validates the ingested LUT for lut/grade types and
rejects an invalid/oversized cube (no partial write) — the escape hatch for a
LUT the agent generated with its own code.
- SKILL.md: hard rule to never read a .cube body into context (~size^3 lines,
zero legible signal) — inspect via grade-compare (see it) or cube-validate
(ok/size), read the manifest description for meaning; plus both authoring
paths and the parametric-vs-film-stock ceiling note.
Verified: media-use 116/116, lint + format clean; smokes — --params builds a
valid frozen cube, grade --params returns a lut block, bad JSON and an oversized
--from cube are both rejected with no stray file.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* fix(cli): grade-compare validates referenced LUTs, warns on no-op cells, caps candidates
Bug-bash follow-ups — grade-compare silently accepted bad input:
- Validate LUT *content*, not just existence: each referenced .cube is parsed
with core's parseCubeLut (now exported from @hyperframes/core) and rejected
with a per-cell error ("LUT for \"<label>\" is not a valid .cube: ..."). A
file that exists but isn't a valid cube no longer renders a silent no-op cell.
- Warn on inactive cells: a grading that normalizes to inactive (e.g. a
malformed {lut:12345}) emits a stderr warning naming the cell; the
auto-prepended "original" baseline is intentionally inactive and stays silent.
stdout remains valid JSON.
- Cap candidates at 16 (excluding baseline): over-cap input renders the first N
and reports {truncated:true, total:M} on stdout + a stderr note — no silent
drop, no unbounded giant sheet.
Verified: grade-compare 10/10; non-cube LUT → clear error; {lut:12345} → warning
+ ok; 20 cells → cells=17 truncated total=20; valid runs unchanged. Lint/format
clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* feat(cli): general `hyperframes compare` visual-variant primitive
Generalize grade-compare's "render N variants → one labeled sheet → the agent
looks and picks" loop into a standalone command that works on ANY variation
(font, layout, motion, grade, whole compositions) — the tool never needs to
know what differs.
- `hyperframes compare <path...> [--at <sec>] [--labels a,b,c] [--out] [--cols]
[--json]`: renders each agent-authored composition variant through the real
runtime (captureCompositionFrame) and stitches one labeled comparison sheet +
JSON ({ok, sheet, rendered, variants, truncated?/total?}). 2+ paths required;
caps at 16 with loud truncation. It presents, it does not judge — choosing is
the caller's job.
- Factored the shared "render a labeled set → contact sheet" path so compare,
grade-compare, and snapshot all sit on it (no duplication). grade-compare is
now the first color-specific specialization of this primitive.
- New pathArgs util + contactSheet test; hyperframes-cli SKILL documents compare
as the agent's "see your own renders and choose" primitive.
Verified: 26/26 across compare + grade-compare + snapshot + contactSheet (no
regressions); compare renders 3 variants into one visibly-distinct labeled
sheet; 2+-path error path clean; lint/format clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* fix(ci): green the skills CI — skip ffmpeg tests when absent, oxfmt markdown
The "Test: skills" CI job runs bare `node --test` with no ffmpeg on PATH (by
design — skills tests are meant to be node-builtin-only). The grade-analyzer +
smart-grade tests shell to ffmpeg and were failing there with ENOENT. Guard
them to skip when ffmpeg isn't on PATH; they still run locally / where it is.
Also oxfmt README.md + hyperframes/media-use SKILL.md (the whole-repo
`oxfmt --check .` Format job caught markdown left unformatted by the rebase
conflict resolution).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* fix(ci): skip core-conformance test when tsx is unavailable
The "Test: skills" CI job installs no deps, so the normalizeHfColorGrading
conformance test (which imports core's TS via `node --import tsx`) failed there.
Guard it to skip when tsx can't resolve; runs locally / in the deps-installed
Test job. Completes the skills-CI greening (the ffmpeg guards handled the rest).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* fix(cli): escape grade-compare src double-quotes (CodeQL XSS) + Windows-safe compare test
- grade-compare built `<img src="...">` (double-quoted) with the single-quote
escaper, leaving `"` unescaped — a `"` in the frame path could break out
(CodeQL: incomplete HTML attribute sanitization). Use escapeXml for src.
- compare label test hard-coded POSIX paths that can't match on Windows; assert
the derived labels (the subject); path resolution is covered elsewhere.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* refactor(media-use): generate LUT library from params (drop committed .cube files)
The 3 bundled .cube files were 733 lines each (2,199 total) and were themselves
buildCube output — pure repo bloat. Replace with compact per-look params in
luts/index.json, generated on resolve; add an optional `url` for future scanned
LUTs to be CDN-hosted + downloaded on demand (freezeUrl) instead of committed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* feat(media-use): serve library LUTs from CDN on-demand (static.heygen.ai/luts), params fallback
Looks now carry a CDN `url` (hosted at s3://heygen-public/luts → static.heygen.ai/luts/<id>.cube);
resolve downloads + validates + freezes on demand, like bgm/image. `params` stays
as the deterministic offline fallback (--local-only, or if the download fails), so
resolution is never blocked on the network. Provider prefers url, falls back to params.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
* fix(media-use): address #2041 review — atomic LUT writes, compare telemetry, follow-ups
- Atomic .cube writes: library provider (url + params) and the parametric
generator now write to a .tmp path, validate, then rename, so a crash can
never orphan an invalid .cube at the final path (was validate-after-write).
- track("media_use_resolve") now emits provenance.via (url/params-fallback/params).
- grade-compare + compare: --timeout flag (was hardcoded 5000) and a
media_use_compare event (cells, truncated, total, render_ready_timed_out);
openSettledCompositionPage now surfaces the render-ready timeout.
- compare staging skips node_modules/.git; --for gets an upfront existence check.
- Rec.709 luma comment; HYPERFRAMES_ANALYZE_TIMEOUT_MS override; measured note
uses basename; LUT s3 hosting moved from index.json into luts/README.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`hyperframes transcribe --model large-v3` aborted with "unknown DTW preset
'large-v3'". whisper.cpp's --dtw flag wants a dotted alignment-heads preset
(large.v3), but we passed the hyphenated ggml file stem (large-v3). They
coincide for tiny/base/small/medium(+.en) — why it slipped through — but
diverge for the large-v* family. Map stem -> preset (- to .) so large-v1/v2/v3
(and large-v3-turbo) work; no-op for the others. Also fixes media-use, which
shells to `hyperframes transcribe`.
Claude-Session: https://claude.ai/code/session_01H5k87mPZ4d6yiFwcWSb8Vv
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cli): report unknown-flag errors + cover nested subcommands (HF#2033)
Two flag-hygiene gaps behind the assertKnownFlags arc:
1. Telemetry loss: assertKnownFlags ran BEFORE the try/catch in the command
wrapper, so an unknown-flag throw skipped reportCommandFailure entirely —
zero signal on how often users hit bad flags. Moved the assertion inside
the try so it reports like any other failure.
2. Nested-subcommand scope: cli.ts wraps only the top-level command loaders,
so command groups' leaves (cloud/*, auth/*, figma/*, lambda/*, capture/*,
skills) were never wrapped — citty dispatches to the leaf, whose run had no
assertion and no failure reporting. So `hyperframes cloud render --badflag`
silently ignored the flag. trackCommandFailures now recurses through
cmd.subCommands (normalizing citty's Resolvable entries to loaders) and
wraps every leaf. Identity is preserved for bare no-run/no-subcommand defs.
Verified: `auth status --badflag` now errors "Unknown flag: --badflag"
(previously silent); `auth --help` still dispatches; top-level `lint
--badflag` still rejected. Tests: unknown-flag rejection is reported, and a
nested subcommand's failure reaches onFailure.
* test(cli): guard indexed subCommands access for noUncheckedIndexedAccess
CI Typecheck (tsc, unlike the local tsup build) flagged the nested-subcommand
test: indexing `subCommands["render"]` yields `T | undefined` under
noUncheckedIndexedAccess, so invoking it tripped TS2722/TS18048. Guard the
loader before calling it.
The WCAG contrast audit estimated each text element's background by sampling a
4px pixel ring just OUTSIDE its bounding box. For an element that paints its
OWN opaque background (a caption pill, a CTA button, a solid card), the text is
composited over that solid color, not over whatever surrounds the box. Sampling
the ring there measured the text against the scene behind the element (often a
dark photo), producing false ~1:1 ratios and flagging perfectly readable CTAs
and captions. Users reported the warning persisting no matter how they changed
the background color, because the audit was never reading it.
Resolve the nearest fully-opaque background-color by walking the element up its
ancestor chain, and use it when present; keep sampling the ring only when the
text sits over image pixels (a background-image is hit first) or no opaque
background exists. The pure decision lives in a new commands/contrast-bg.ts with
unit tests; contrast-audit.browser.js (injected as a raw string, so it cannot
import) inlines the same logic, mirroring the existing duplicated-WCAG-math
note.