1247 Commits
Author SHA1 Message Date
miga-heygenandMiguel Ángel 484a06fb65 chore: release v0.8.45 (#4027)
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-16 22:56:40 -04:00
miga-heygenandMiguel Ángel 71bb88719e feat(telemetry): render output-shape, adapter, count, and environment properties (#4017)
* feat(telemetry): add output-shape request facts to render events

output_resolution_preset, output_format, hdr_mode, video_frame_format,
and gif_fps_capped join render_complete/render_error, all threaded
from already-resolved RenderOptions/RenderPlan fields rather than
recomputed: createRenderPlan already computes gifFpsCapped (clamping a
requested --fps above 30 to 30 for --format gif) but only used it for
a console warning; this threads that same decision into RenderOptions
and telemetry. All five are known before the pipeline starts, so both
events carry them even when a render fails before a perfSummary
exists.

Also wires output_format/output_resolution_preset into Studio's
separate emitStudioRenderComplete/emitStudioRenderError path, since
Studio's request genuinely varies those two per-request. hdr_mode,
video_frame_format, and gif_fps_capped stay CLI-only: Studio exposes
no HDR-mode, video-frame-format, or GIF output control to users at
all, so there is no per-request value to report for those three.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* feat(telemetry): add adapters_used to render events

adapters_used lists the runtime deterministic adapters
(packages/core/src/runtime/adapters/) a composition exercises.
Investigated the actual codebase rather than the ticket's guessed
7-adapter list and found 12 real registered adapters (gsap, css,
waapi, animejs, lottie, three, typegpu, d3, leaflet, mapbox, maplibre,
google-maps) — the map/data-source adapters share the exact same
window.__hf<Name> registration mechanism and are equally real
"adapters used" facts, so all 12 are included.

Detection unions a live page.evaluate probe (piggybacked on the same
probeSession resolveCompositionElementCount already uses) with a
static regex fallback over the compiled HTML, since this is purely
observational with no gating role (unlike compositionElementCount) —
union strictly increases recall rather than needing one source of
truth. Threaded through the same plumbing chain as the already-merged
composition_element_tags/aroll_video_count/heygen_video_count.

Studio's emitStudioRenderComplete/emitStudioRenderError get this field
automatically, with no Studio-side code change, via the same shared
capture-observability spread heygen_video_count already uses.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* feat(telemetry): add audio/image/sub-composition/color-grading counts

audio_count, image_count, sub_composition_count, audio_group_count,
color_grading_count, and has_lut join composition_element_tags in the
same single scanElementTags pass — audio/image/audio-group counts read
from the scan's uncapped tag map so a 50+-distinct-tag composition
can't push a named count into the "other" bucket. sub_composition_count
counts data-composition-src mounts directly rather than reusing
collectSubCompositionSrcs, which dedupes by src and drops
placeholder/remote mounts (a different, smaller fact than the mount
count this field reports).

has_lut decodes the HTML-entity-escaped form of data-color-grading
before parsing, since linkedom's serializer re-emits it
&quot;-escaped on every compile-pipeline round-trip — without the
decode this silently reported false for every LUT-graded composition.
The LUT-presence check now mirrors normalizeLut's own definition of
"has a LUT" exactly, closing a gap where a blank or empty lut value
would have reported true.

Threaded through the same plumbing chain as the already-merged
per-tag/provenance/adapter fields; Studio's emitter picks these up
automatically via the shared capture-observability spread.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* feat(telemetry): add browser_version_major and ffmpeg_version_major

Both are already-run subprocess calls whose output was discarded:
readToolVersion (checkFFmpeg's preflight) already runs `ffmpeg
-version`, and chromeLaunchOutcome already runs `--version` as a
Chrome launch sanity probe on every render. A shared
extractMajorVersion helper pulls the first X.Y-shaped number from
either banner, threaded onto EnvironmentCheckOutcome/
EnvironmentCheckResult, then RenderOptions right after
runEnvironmentChecks resolves in the local render path only.

Docker renders never populate these two RenderOptions fields (the
containerized producer runs its own preflight the host CLI never
sees), so both properties come back absent there rather than
reporting the host's own toolchain versions for a render that didn't
use them.

Studio does not call runEnvironmentChecks or this ffmpeg/Chrome
preflight at all before creating a render job, so its render_complete/
render_error events will not carry these two properties unless
Studio's own startup path is separately instrumented — a genuine
parity gap, not an oversight in this change.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* feat(telemetry): add authoring_skill_source and authoring_skill_invalid

Both derive from resolution logic createRenderPlan already runs:
authoring_skill_source names whichever step actually won attribution
(an explicit --skill flag, or the skill persisted in hyperframes.json)
using the same flagSkill/projectConfigSkill values already computed
for authoringSkill itself, not a second lookup. authoring_skill_invalid
surfaces the raw --skill value when it fails slug-shape validation
(normalizeSkillSlug rejects malformed input, not unregistered skill
names) — this value already exists as RenderPlan.invalidAuthoringSkill
and already drives a CLI warning in present.ts; this change threads
the same field into telemetry rather than adding a parallel one.

Threaded through both RenderOptions builders in execute.ts (single and
batch render) and all three telemetry call sites in render.ts,
including the Docker path — skill attribution is a request-level fact
independent of which backend executes the render.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* feat(telemetry): add root_body_mismatch and root_body_delta_px_bucket

Measures the composition root's authored data-width/data-height
against the scaffold's own html/body CSS size, straight from the
compiled document string scanElementTags already scans — the
source-level fact, not runtime DOM state. init.ts's applyCompositionSizing
(the #4001 fix) only patches the live DOM after the browser loads this
same HTML, so this measurement stays meaningful whether or not that fix
ran for a given render.

Reuses @hyperframes/parsers' canvasScaffoldPatterns regexes (the same
ones packages/lint's root_dimensions_mismatch rule reads) for the CSS
side. Root-tag detection here is a lighter first-data-composition-id-match
heuristic, not lint's more thorough findRootTag (which also handles a
leading decorative <svg> defs block) — that helper isn't part of
@hyperframes/lint's public API, and expanding it is out of scope here.

The delta is bucketed (0 / 1-10 / 11-50 / 51+ px) rather than reported
raw, and both fields come back absent — not a false/"0" default — when
the root or the CSS block can't be read at all.

Threaded through the same plumbing as every other static-scan field
this week, including the live-capture-observability fallback used on a
crash that never reaches perfSummary.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* feat(telemetry): add hf_env_overrides (names only, capped at 20)

Reports the sorted names of HF_*/HYPERFRAMES_* env vars present when
createRenderPlan resolves — never their values, since several of these
carry filesystem paths. Snapshotting at plan-resolution time (the very
first line of the render command, before any pipeline code runs)
matters: this render's own preflight later injects
HYPERFRAMES_FFMPEG_PATH/HYPERFRAMES_FFPROBE_PATH into its own
process.env, which would otherwise make those two always read back as
"operator overrides" whether or not the operator ever set them.

Reports an empty array, never an absent key, when nothing is set,
matching every other capped/bucketed field added this week. Capped at
20 names, documented alongside the cap constant.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* fix(cli): stop clobbering studio render_complete fields, fix an env leak

trackRenderComplete's explicit audio_count/image_count/root_body_mismatch/
adapters_used/etc. keys, sourced from perfSummary.drawElement and only ever
populated on the CLI's own render.ts path, always won the object spread over
the observability-capture fallback those same keys share with trackRenderError,
even when the direct value was undefined. studioRenderTelemetry.ts's
emitStudioRenderComplete never populates drawElement, only the capture
fallback, so 24 fields silently came back missing from every studio-triggered
render_complete event despite Studio having computed and sent the capture
value. Each field now falls back to its captureXxx counterpart via a new
directOrCapture() helper when the direct field is absent, with regression
tests proving both the fallback and that the direct value still wins when
both are present.

Also excludes HF_SHADER_WORKER_ENTRY from hf_env_overrides: cli.ts sets this
on every invocation to point the bundled shader worker pool at its own file,
sharing the HF_ prefix by coincidence rather than because an operator set it.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* style: remove em and en dashes from added code comments

No behavior change. Rewrites 22 comment lines this PR's own diff introduced,
using a comma or a period where the dash separated two clauses.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* test(producer): add a temporary diagnostic for a CI-only rootBodyMismatch failure

Not a real fix. The rootBodyMismatch test group fails deterministically in
CI (confirmed on two runs of the same head) but will not reproduce locally
by any method tried, including an isolated file run, the exact combined
producer:test:unit command, a worker-count-constrained run, and a fresh
clone with a clean install and build matching CI's own steps.

This dumps the intermediate regex-match state for the simplest failing
fixture to stderr so the next CI run's log says what actually differs.
To be removed once that answer lands.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* test(producer): widen the rootBodyMismatch diagnostic to compare import paths

Still not a real fix. The previous diagnostic (ca4e73983) proved the
imported HTML_BODY_CSS_WIDTH_FIRST_RE has 4 capture groups in the failing
CI run instead of the 2 the source and production code expect, but it used
a dynamic import(), which can resolve through a different codepath than
renderOrchestrator.ts's static import. This adds a static import of the
same binding, a require.resolve() of the resolved file path, and a direct
read of that file's own regex line, so the next run can rule in or out a
static/dynamic resolution split versus a genuinely different file on disk.

Locally both imports agree (2 groups, same object, correct file). To be
removed once the CI answer lands.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* fix(producer): read the merged 4-group scaffold-size regex correctly

main merged #4012 while this branch was in flight, consolidating
HTML_BODY_CSS_WIDTH_FIRST_RE/HEIGHT_FIRST_RE from 2 capture groups (digits
only) to 4 (prefix text, digits, suffix text, digits) so cli's
applyResolutionPreset could reuse the same regex for its in-place replace.
detectRootBodySizeMismatch still read groups 1/2 as the digits, now the
prefix/suffix text, so Number() on them was NaN and every render's
rootBodyMismatch/rootBodyDeltaPxBucket silently came back undefined. Fixed
to read groups 2/4, matching lint's core.ts readHtmlBodyCssSize exactly.

Removes the temporary diagnostic test (ca4e73983, e2e384c8e) now that its
CI log gave the real answer: both a static and dynamic import of the
regex resolved to the same file, and that file's own content had 4 groups
because this branch's merge-base predates #4012, not because of any
resolution or caching difference. A fresh CI run confirmed no CI-only
failure remains after merging main and fixing the indices.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* chore: nudge CI (no workflow run triggered on previous push)

* chore: nudge CI again now that #4017 is out of draft

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-17 02:10:27 +00:00
Aditya Poudyal e4b1832fad feat(cli): add hls output format (#4022) 2026-09-16 21:09:14 -04:00
miga-heygenandMiguel Ángel 9aa60a844f fix(registry): make motion-blur match After Effects shutter-based blur (#4008)
* fix(registry): make motion-blur match After Effects shutter-based blur

The motion-blur catalog snippet estimated speed from the x/y delta
between consecutive timeline updates and drew three one-sided ghost
copies with decaying alpha plus a Gaussian on top. After Effects does
temporal supersampling: each frame integrates the layer over a shutter
window (shutter angle, shutter phase, samples per frame), producing a
symmetric box smear of length speed x shutter time.

The snippet now does the same inside the page. After every timeline
update a deferred pass seeks the GSAP timeline to N sub-frame times
across the shutter window (events suppressed), reads each target's x/y,
restores the frame time, and drives an SVG filter that averages N
feOffset copies with equal weight (feComposite arithmetic, 1/N, sRGB).
Defaults follow AE: shutterAngle 180, shutterPhase -90, samplesPerFrame
16; fps comes from the render config, then the root data-fps, then 30.
Displacements are mapped through the inverse of the element's own
scale/rotation; percent x/y values are resolved to px; SVG targets use
their bbox; an existing inline filter is kept and the blur appended to
it. The pass runs in a microtask rather than inside the tracker tween's
onUpdate, because seeking GSAP from within its own render reads stale
tween state and suppresses the composition's callbacks on forward
seeks. The heuristic options blurScale/blurMax/stretchScale/stretchMax
are removed.

Reference: an AE-algorithm reconstruction of the same composition
rendered without the component at an effective 960 fps and averaged
over the 16 sub-frames of the 180 degree window per output frame.
Against it (1920x1080, 30 fps, solid + text translating linear then
power2.inOut): old mean PSNR 53.2 dB (min 26.7), SSIM 0.9930; new mean
PSNR 72.4 dB (min 52.6), SSIM 0.9998. The 10-90 edge ramp at 66.7
px/frame is 26.8 px in the reference, 27.5 px new, 14.5/6.3 px
(one-sided) old; at the ease peak 79.3 vs 79.5 vs 7.9/17.7 px, with
smear centres matching to 0.1 px.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* fix(registry): drop render-config fps probe from motion-blur and refresh its docs

The snippet resolved fps by reconstructing the renderer's private seek
config key so the compiler's inline-script guard would not strip it. A
public catalog snippet must not depend on that key, so fps is now the
explicit option, then the root data-fps, then 30; renders with an fps
override pass fps explicitly. The catalog page's Usage, How it works and
Options sections still described the old velocity heuristic and are
rewritten for the shutter model. The 1/N weight keeps full precision.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* fix(registry): match the After Effects reference export's shutter window

The shutter model landed on this branch was built against a reconstruction
of After Effects' algorithm, not against After Effects. A real 1920x1080 /
30 fps export of translating text disagrees with it on three points, all
measurable off the export itself.

The window is two frames wide, not half a frame. On the measured frame the
outermost duplicate on the trailing side sits 128 px back while the previous
frame is 125.8 px back, and the outermost leading duplicate sits 189 px ahead
while the next frame is 184.9 px ahead: the window ends land on the
neighbouring frames. Defaults become shutterAngle 720, shutterPhase -360.

Duplicates sit on the sub-interval boundaries, both ends included, not at
sub-interval centres. The staircase across a stroke steps every 15.6 px on a
125.8 px displacement and every 23.0 px on a 184.9 px one, i.e. an eighth of
a frame, with the run closing exactly on the neighbouring frame's position.
samplesPerFrame now means sub-intervals of the window, so there are N+1
copies at k/N rather than N copies at (k+0.5)/N.

The frame-time instance is drawn sharp over the smear. At full ink the core
bounding box is the whole word at every frame and total ink rises to 1.44x
the unblurred frame's, neither of which an average can produce. A final
feComposite over of SourceGraphic reproduces it. Each duplicate stays at
1/N: the staircase plateaus step by 0.063 +- 0.002 of the sharp text
intensity, flat, with no taper toward the window edges, and triangle
weighting scores 1.6 dB worse against the export.

Verified by replaying the export's translating section as a composition
whose sprite is the export's own rest-frame ink and whose eight legs follow
its own measured per-frame positions, rendered at 1920x1080 / 30 fps and
compared frame by frame over the text band. Mean PSNR against the export
rises from 24.19 dB (min 17.07) to 32.51 dB (min 27.57) across 72 frames,
and from 19.83 dB (min 17.07) to 32.03 dB (min 27.57) across the 26 frames
that actually move. Ghost extents match to a few px and the rendered
staircase plateaus match the export's to two decimal places. The export's
later scale, skew, flip and 3D rotation sections are out of reach here:
integrating anything but translation inside the shutter is engine work
tracked in issue #4010.

The filter region now clamps its bounds through zero. It was derived from
the duplicate offsets alone, which was correct while the output was nothing
but duplicates; the sharp instance draws at offset zero, so a window sitting
entirely on one side of the frame time - any shutterPhase above 0 or below
-shutterAngle - produced a region starting inside the element's own box and
cut the instance off. The defaults straddle the frame time and were never
affected, so the measured numbers above are unchanged.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-17 00:51:03 +00:00
miga-heygenandMiguel Ángel 3c9462feea chore: release v0.8.44 (#4016)
- Lint no longer reports root_dimensions_mismatch for sub-compositions, the
  false positive v0.8.43 shipped with; a top-level mismatch is still reported
  and a viewport-meta-only mismatch gets its own wording (#4012)
- The streaming encoder accepts lockGopForChunkConcat and gopSize, emitting the
  same arguments as the disk encoder. Off by default, so an existing render
  produces an unchanged FFmpeg arg list (#3973)

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-16 19:14:51 -04:00
miga-heygenandMiguel Ángel 2229d0d6a6 fix(lint): root_dimensions_mismatch sub-composition false positive + regex SSOT consolidation (#4012)
* fix(lint): guard root_dimensions_mismatch against sub-compositions

Sub-compositions mounted via data-composition-src are fetched with
DOMParser and only their matched <template>/<body> subtree is imported
into the live document. A sub-composition authored as a full standalone
HTML document still carries its own <html>/<head>/<meta viewport>, but
none of that ever reaches the rendering document, so a mismatch there was
always a false positive. Guard the rule with options.isSubComposition,
matching the sibling missing_timeline_registry rule's existing pattern.

Split the warning wording: html/body CSS actually sizes the element that
clips the root, but the CDP viewport is set from the root's own
data-width/data-height (frameCapture.ts), never from <meta viewport>. A
viewport-only mismatch is real drift but not a clipping risk.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* fix(cli,parsers): consolidate scaffold resolution regexes into one definition

applyResolutionPreset kept its own prefix-capturing regex literals for the
html/body CSS size and meta viewport content, duplicating the same three
patterns already defined in @hyperframes/parsers for lint's read-only
root_dimensions_mismatch comparison. Merge to one definition per pattern
that captures both the prefix (for in-place replace) and the digits (for
comparison), so the two callers can no longer drift on where a scaffold's
resolution lives. Existing applyResolutionPreset tests stay green.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-16 21:09:32 +00:00
miga-heygenandMiguel Ángel d6fe66707f chore: release v0.8.43 (#4007)
- Portrait compositions render the full frame when the project was
  scaffolded at a different size: html/body are sized to the composition
  root at runtime; new lint warning root_dimensions_mismatch (#4005,
  fixes #4001)
- render_complete telemetry adds composition_element_tags,
  aroll_video_count, heygen_video_count; render_error adds error_name and
  failed_stage_code (#4004)
- Catalog docs "Source" accordion titles are plain strings, so docs
  search no longer shows [object Object] (#4006)

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-16 14:05:29 -04:00
miga-heygenandMiguel Ángel 3704863d96 feat(telemetry): per-tag, provenance, and failure-taxonomy properties on render events (#4004)
* feat(telemetry): add per-tag element and a-roll counts to render_complete

countElementTags's single regex scan (the ~83% of renders that get no
live probe session) is extended into scanElementTags, which returns a
per-tag breakdown and an a-roll video count alongside the same total,
all from one pass over the same script/style-stripped markup — the
total is the sum of the per-tag map by construction, so the two
properties can't drift apart. countElementTags itself is removed; it
had zero production callers after this change.

New render_complete properties: composition_element_tags ({tag: count},
raw lowercase tag names, capped at the top 50 by count with the rest
folded into an "other" key) and aroll_video_count (<video
data-aroll="true"> elements from the same scan). Both are static-scan-
only, mirroring the existing composition_element_count_source split —
the live probe-DOM path never runs this scan.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* feat(telemetry): count HeyGen-provenance video from the same tag scan

heygenVideoCount joins arollVideoCount/byTag as a third property
derived from scanElementTags's single pass: <video
data-media-source="heygen"> elements. media-use's resolve.md now
documents that the skill stamps this attribute on a mounted video only
when the ledger record's provider is "heygen.video" (the mount itself
is always the agent's own composition edit, never written by resolve
scripts) — the ledger stays the single source of truth for provenance,
the HTML attribute is a projection of it, and no other provider value
is invented beyond this one signal.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* feat(telemetry): add a bucketable failure taxonomy to render_error

error_name (error.name, e.g. one of the ~20 typed producer error
classes) and failed_stage_code (job.currentStage/failedStage
normalized to a stable snake_case code via normalizeStageCode)
join the existing free-text error_message/failed_stage, so a fleet
query can bucket failures without parsing prose. Error-conditional by
nature — render_complete has no equivalent, since a successful render
has no error to name or stage to blame.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-16 12:37:57 -04:00
miga-heygenandMiguel Ángel 71b274bbfd chore: release v0.8.42 (#3988)
* chore: release v0.8.42

- Studio preview works for projects whose path has non-Latin-1 characters (#3982)
- Studio render downloads use RFC 6266 headers for non-Latin-1 filenames (#3984)
- check --json reports HDR auto-promotion; render prints one warning (#3985)
- Forced --sdr renders tone-map HDR sources before extraction (#3981)
- Render/preview workers no longer survive as orphans after Ctrl-C or parent exit;
  cancellation reaches final artifact promotion; HYPERFRAMES_RENDER_DETACHED=1 for
  intentionally detached renders (#3974)
- HDR preflight test coverage for held tails (#3963)

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* chore: release v0.8.42 notes — name the #3982 symptom and scope

- #3982 entry: a Studio preview server crash for compositions whose file path
  has non-Latin-1 characters (the project directory is not involved)

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* chore: release v0.8.42 notes — name the #3982 symptom

- #3982 entry: the uncaught ETag TypeError took the preview server down, so
  call it a Studio preview server crash rather than a preview failure

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-16 09:40:06 -04:00
heygengenesis[bot]andmiguel.sierra 08d48ab753 fix(cli): guard process snapshot capture (#3974)
Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com>
Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com>
2026-09-16 06:33:29 +00:00
heygengenesis[bot]andmiguel.sierra 5d2478c6fa fix: report HDR auto-promotion (#3985)
Report HDR auto-promotion consistently from the render media set, including remote media, and make its regression coverage portable across supported paths.

Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com>
Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com>
2026-09-16 06:13:24 +00:00
terencecho edf6e4b373 chore: release v0.8.41 (#3966) 2026-09-15 17:24:09 -04:00
Miguel Ángel cfe5dcfad3 chore: release v0.8.40 (#3938) 2026-09-14 12:41:47 -04:00
Miguel Ángel d13a89b670 chore: release v0.8.39 (#3936) 2026-09-14 11:13:53 -04:00
Santhi Prakash dc6d78b462 fix(cli): assign w{index} ids when loading JSON transcripts (#3770)
loadTranscript assigns id: w{index} on the srt/vtt branches but never on
the JSON branches: parseWhisperCpp and parseOpenAI drop the field and the
words-json branch defaults it to "". Every engine funnels through
loadTranscript, and transcribeAudio rewrites transcript.json from its
output, so CLI-produced transcripts ship without the stable word ids that
transcribe.md documents for caption overrides — per-word overrides have
nothing to key on.

Assign id: w.id || `w{index}` across the JSON branches, matching the
srt/vtt behavior. || also repairs the empty-string ids older CLIs wrote
to words-json files, which otherwise collapse every word onto one key.

Signed-off-by: Santhi Prakash <b.santhiprakash@gmail.com>
2026-09-14 07:29:04 +00:00
Miguel Ángel 10e8447ac0 chore: release v0.8.38 (#3929) 2026-09-13 22:02:19 -04:00
Miguel Ángel f059a6eb53 feat(cli): default BeginFrame, agent blank, and looks quality (#3927)
* feat(cli): render summary names the capture path, gpu mode and stage timings

* fix(lint): drop gsap_exit_missing_hard_kill, its hazard does not reproduce on cold workers

* fix(cli): render slow-path hint fires only when the gpu was auto-probed

* test(cli): isolate agent hint suite from ambient agent env keys

The suite runs inside agent sessions. Ambient env keys fill the 16-key
hint cap and fail assertions that a plain shell would pass.

* docs(music-to-video): drop leftover hard-kill mandate after lint rule removal

Frame workers still required a tl.set hard-kill that the dropped lint
rule no longer enforces. Align the self-check with montage.md.

* refactor(cli): share hint-key pattern and collapse render summary args

Export HINT_KEY_PATTERN for the test isolation strip. Read duration and
frame count from the perf summary instead of passing them beside it.

* test(cli): lock capture-mode preference and 16-key hint isolation

Session mode, including drawelement, must print over observability.
The hint suite now fails if ambient keys already fill the 16-key cap.

* feat(cli): default BeginFrame, agent blank, and looks quality

Local auto render opts out of the software-GPU screenshot clamp so
BeginFrame can run. init --agent scaffolds a centered Inter blank with
no prompts. --quality looks (now the default) is CRF 16; delivery is high.

* fix(cli): honor screenshot env and teach agent pitfalls on the happy path

Local auto still requests BeginFrame unless PRODUCER_FORCE_SCREENSHOT=true.
The agent blank fills its canvas so --resolution can resize it. Skills now
name the four pitfalls and tell agents to read the render summary line.

* feat(cli): default init to the centered blank

Bare init scaffolds the centered Inter stage. --agent is a hidden alias.

* fix(cli): pack from-file and use it for video init

TTY --video with no --example selects from-file. Pack copies that template.

* fix(cli): wire --video onto from-file even with --example blank

One resolver picks the scaffold. Spawned init --video asserts a-roll src.

* fix(cli): narrow looks CRF on the quality alias

Only looks carries crf. Typecheck failed on the union without a guard.

* style(cli): shorten render-pipeline summary comment
2026-09-13 21:44:19 -04:00
Miguel Ángel 95bea1631f refactor(skills): keep hyperframes-core as the HTML contract (#3928)
* refactor(skills): keep hyperframes-core as the HTML contract

Move brief, storyboard, review, production, dispatch, and
frame-worker docs to hyperframes. Slim remaining core references.

* fix(skills): restore camera recipes and root sizing

Restore Zoom, Ken Burns, crop, and clip-path recipes. #root is 100 percent.

* fix(skills): stamp size on the composition root

Runtime sizes the composition root, not html/body. Overlap pin requires is valid.
2026-09-13 21:20:58 -04:00
Miguel Angel Simon Sierra 7a5d2bf769 chore: release v0.8.37 2026-09-13 12:51:02 -04:00
Miguel Ángel f86aae655a chore: release v0.8.36 (#3911) 2026-09-12 13:03:01 -04:00
Miguel Ángel 4c6fa9a790 fix(studio): the preview stays paused, survives typing, and opens without a re-encode (#3910)
* chore(studio): remove the write-only self-write timestamp ref

`domEditSaveTimestampRef` was assigned at 26 sites across 54 files and read
nowhere. It used to feed a 2 s "suppress the watcher reload after our own
write" window; that mechanism was replaced by content-hash identity in
sdkSelfWriteRegistry, whose header still says why (a clock cannot tell an
SDK self-write echo from an undo landing in the same window). The reader
went with that change, the writers did not, and three comments kept
describing the timestamp as protection that no longer existed.

Pure deletion: the ref, every prop and parameter that threaded it, every
assignment, the five comments citing it, and the orphaned test helpers.
No behaviour change; the studio suite, typecheck, lint and format are
green.

Not removed: the __hfSuppressSceneMutations wrapper in gsapSoftReload.ts.
It looked undefined from inside the studio package, but shader-transitions
installs it on the preview window (hyper-shader.ts) so soft reloads do not
invalidate cached transitions. It is live.

* docs(studio): plan for loading the preview during the shell's first layout

* fix(runtime): a paused preview stays still after any seek

Sub-compositions kept animating while the transport was paused and the
Studio button showed play. A render-seek unpauses every sibling timeline so
GSAP propagates the root's totalTime into them, and nothing paused them
again; parented to the global ticker, they free-ran at 1x while the master
timeline and the clock stayed stopped. The Studio reaches that seek path
through its seek-driven fallback adapter right after a preview reload,
which is why editing text was what set it off. Captured live in the user's
preview: seven child timelines advancing 0.210s per 200ms sample, all
gsapPaused=false, transport not playing.

Three fixes under one invariant: while the clock is paused nothing runs,
and the button never disagrees with the runtime.

- The sibling rearm is a lease, not a resting state: seekTimelineAndAdapters
  now returns every timeline it unpaused to paused in a finally. The second
  rearm after the child re-seek only ever changed the leaked state and is
  gone, with the comment that justified the leak by describing one caller.
- The paused side of the transport tick policed nothing; it now stops any
  timed media element found running. A parked transport runs no ticks, so a
  capture-phase play listener wakes it.
- Studio's canvas click resumed by setting the store flag alone; it now
  requests playback through the player so adapter, rAF loop and flag agree.

Regression tests fail without each of the three changes.

* perf(studio-server): stop re-encoding videos the browser already plays on open

Opening a project kicked off a background transcode for every asset whose
codec some browser might not decode. VP9 is on that list for Safari's sake,
so a Chrome user opening a project with a VP9 avatar paid a ~10s, ~60
CPU-second re-encode across six cores on every open, concurrently with the
browser's first layout, for an output nothing ever requested: across four
recorded sessions the browser asked for the proxy zero times.

One predicate was doing two jobs. "Could some browser fail on this" is a
property of the asset and decides what gets injected into the page;
"will this client request the substitute" decides whether to spend CPU
before being asked. The codec table now carries an explicit prewarm flag:
HEVC and ProRes (no browser decodes them) still warm; VP9 and AV1 are
injected but transcoded lazily by the existing ?hf-proxy= request, which
already reports failure as a 502.

Pre-warms requested and proxies served are now counted on the existing
structured-stderr telemetry channel, because a speculative job with a 0%
hit rate emits no error and had been invisible. A single lookup helper
also closes a prototype-key gap where one path used Object.hasOwn and the
other a bare index.

Regression test: a VP9 and an HEVC asset in one composition yield exactly
one pre-warm, for the HEVC. Fails on the previous gate.

* perf(cli): serve the studio bundle compressed and cache hashed assets

The 4.1MB studio bundle was served uncompressed with Cache-Control:
no-store, so every open of the studio re-downloaded and re-parsed it. Vite
names built assets with an 8-character content hash, so their bytes can
never change: those now ship gzip-compressed (1.25MB on the wire,
byte-identical after inflation) with a one-year immutable cache policy.
Unhashed files under public/ keep no-store, and the HTML shell, which
previously sent no Cache-Control at all, now sends no-store explicitly:
it is the only thing that names the current hashed bundle, and it must
keep revalidating for the immutable policy to be safe.

The hash test only accepts the segment after the last hyphen and requires
a digit, underscore or capital, so an ordinary hyphenated name like
user-Guide-v2.js is not mistaken for a hash and served forever.

* perf(cli): warm the preview route before the browser opens

The browser's first request for a project's preview paid the server's cold
compiler import and first bundle in the request path, after the studio
shell's own first-layout stall, so both costs landed on the user's
time-to-first-frame in series. The CLI now issues one fire-and-forget
request for the preview route from openStudioBrowser, the single funnel
for every launch path, before the --no-open early return so pasted URLs
benefit too. Measured on the demo project: first client request 231ms
before, 101ms after; the ETag 304 path afterwards is under 1ms.

The fetch carries a 10s abort so an unsettled connection cannot keep an
otherwise-finished CLI process alive, matching the package's existing
convention. The shader query params the player appends are not part of
the route's cache key, so the plain route URL warms the same entry.

* perf(studio): request the player chunk before the shell's first layout

The dynamic import of @hyperframes/player ran inside the preview's mount
effect, which React schedules after the shell's first layout. On a cold
open that layout stalls the main thread for seconds, so the chunk request
waited behind it for no reason. The import is now kicked at module scope,
behind a typeof window guard that preserves the documented SSR contract
(the module registers a custom element at load), and the mount effect
awaits the already-in-flight promise. Verified in the built bundle: the
preload call sits at module top level, so the request goes out on bundle
evaluation.

* fix(studio): editing a text property no longer reloads the preview

Every keystroke in the Design panel writes the composition file, the file
watcher announces the change to the studio, and the studio decides whether
the change was its own. Over the CLI's event stream that decision has
never worked: the browser hands the handler a MessageEvent whose data is
a JSON string, and three of the four payload readers (version, write
token, content) only understood an already-parsed object, so they read
every field as absent. An absent token means "someone else edited the
file", and the studio hard-reloaded the preview iframe on its own edit,
blanking it for seconds. The path reader alone knew how to unwrap the
string, which is why the event was recognised well enough to reload and
never well enough to suppress.

The envelope is now decoded once, at the boundary, by one function that
all three transports feed; the readers share one field accessor so they
cannot diverge again. The production event-stream rung is extracted into
an exported channel so a test can drive a real MessageEvent through the
listener it registers, which was impossible before because vitest defines
import.meta.hot and the selection never reached that rung under test.

A second, smaller cause: the server attached the write receipt to the
first subscriber only and deleted it on read, so any other listener saw
an unlabelled change. Reads are now non-destructive with the TTL as the
only eviction, scanning newest-first because identical bytes written
twice inside the TTL (undo, retyping a value) share a version and the
older token was already spent. The file version now ships with every
event, receipt or not, so duplicate deliveries of one change dedupe
instead of reloading once each.

shouldReloadSdkSession had no production callers and a signature that
invited an undecoded delivery straight back into this bug; it is removed.
consumeFileWriteReceipt stays as a deprecated alias for one release.

Regression tests: a Studio write delivered as a real SSE MessageEvent is
suppressed; two subscribers of one watcher event reload once; a genuinely
external write still reloads; a repeat of earlier bytes gets the newest
token; a receipt past the TTL is not recognised. Each fails on the code
before it.

* fix(runtime): paused-time media playback is borrowed, not banned

The paused-side enforcement added in the previous commit had no notion of
provenance, so it stopped two features that legitimately play media while the
transport clock is paused. Both were deterministic, not racy: the capture-phase
`play` listener means the very play() that starts them wakes the transport that
stops them.

- The colour-grading preview (colorGrading.ts startPreviewPlayback) plays a
  video while paused to render grading previews. It went dark on the first
  paused tick.
- The Studio's scrub audition (timelineIframeHelpers.ts applyScrub) plays the
  music track for ~140 ms while paused so a playhead drag is audible. Same path
  killed it.

A runtime-owned lease fixes both without weakening the enforcement. One owner: a
WeakSet in the runtime closure, with lease/release published on the existing
window.__hf surface for the Studio, which reaches the element across the iframe
boundary and cannot call into the closure. The grading runtime is constructed
with the pair directly. Both the cheap probe and the sync path skip leased
elements while the clock is paused; during playback the transport owns everything
again. Anything that plays while paused without a lease is, by definition, the
defect the enforcement exists for, and is still stopped.

Two corrections to the previous commit's reasoning:

The old leak broke the render path too, deterministically, not only the preview.
packages/producer/src/services/fileServer.ts:375-379 seekToTime flushes the
virtualized rAF queue, and GSAP's global ticker with it, after renderSeek and
before the frame screenshot (fileServer.ts:657-664, hf.seek). A sibling left
unpaused therefore advanced by the full inter-frame delta into the captured
frame. The finally added in the previous commit fixes that as well.

Deleting the second activateSiblingTimelines is safe because nothing between
frames reads a sibling's paused(), verified by grep over the deterministic
adapters, syncTimedElementVisibility, the hf-timelines-built handler and
__hfReseekGpu. Not merely because seekStandaloneRegisteredTimelines pauses each
child. The code comment now gives that reason.

Tests, each proven non-vacuous by reverting the piece it guards:
- a leased element survives repeated paused ticks and is stopped once released
- the colour-grading preview survives, through the real init wiring
- the scrub borrows the element and gives it back on stop
- the existing test that an unleased element is still stopped keeps passing

* test(runtime): a lease ends when the transport plays or the borrower stops

Two assertions the review found not load-bearing. Dropping the isPlaying
branch so leased media stayed exempt during playback left every test
green; a leased out-of-window clip is now asserted stopped the moment the
transport plays. Deleting the release in the grading stop closure also
left the suite green, because the pause on the next line satisfied the
assertion; a restart after stop is now asserted stopped, which only the
release makes true.

* test(studio): the event-stream channel must open /api/events

* fix(studio-server): stop printing a proxy diagnostic line per clip on every render

Review of the pre-warm commit found the diagnostic louder than the thing it
diagnoses, and the two counters measuring different things under one name.

The per-asset `prewarm_requested` line is now behind
HYPERFRAMES_DEBUG_MEDIA_PROXY, matching isGpuProbeDebugEnabled in
packages/engine/src/utils/gpuEncoder.ts. A composition with fifty hostile
clips printed fifty JSON lines into a clack-formatted terminal on every
re-render. One summary line is written at process exit instead, using the
same process.on("exit") shutdown hook as packages/cli/src/cli.ts.

The counters now share a unit. prewarmsRequested counts per asset per render;
proxyRequests counted every HTTP request, including 304s. It now increments
once per resolveProxy call, after the ETag shortcut, so a revalidated repeat
no longer reads as fresh demand. An unconditional Range refill still counts,
and the docstring says so rather than claiming otherwise.

The HEVC justification was false on macOS Chrome, which answers canPlayType
for hvc1 with "probably" and keeps the source, so the pre-warm is redeemed
there only through the reactive zero-videoWidth path. prewarm stays true
because Chrome on Windows/Linux and Firefox do need the substitute; the
comment and test names now say "no cross-platform decode" instead of
"browsers never decode it".

Two test gaps closed. Nulling vp9's representativeMime left the suite green
while making the client skip canPlayType and proxy on every browser, which
would reinstate exactly the transcodes this work removed; the mimes are now
pinned. The Object.prototype test passed with the hasOwn guard deleted, so it
now goes through probeAssetCodec, the input that actually misbehaves without
it. Both fail when the change is reverted.

* perf(cli): stop gzipping the studio bundle on loopback

Compression made the cold open slower on the only transport this server has.
It binds 127.0.0.1 with no --host, and measured there the six bundle assets
took 69.8 ms with gzip against 7.9 ms raw; the 4.1 MB chunk alone was 49.4 ms
against 3.1 ms. hono/compress is removed, which also retires the Vary header
question it raised. If remote serving ever matters, compress at build time
rather than per request.

The cache policy is now decided by route instead of by filename. Reading a
content hash out of a name cannot work: rollup's alphabet is base64url and
includes a hyphen, so roughly one hashed file in ten was misread as unhashed,
and the immutable header also leaked onto hand-authored public/ files served
by the same handler. packages/studio/vite.config.ts sets neither
build.assetsDir nor publicDir, so dist/assets holds only rollup's hashed
emits and every public/ file lands at the dist root. /assets/* is therefore
immutable and /icons/* and /favicon.svg keep revalidating, with no heuristic
in between.

The shell is no-cache rather than no-store. It carries no ETag, so both force
the same full refetch, but no-store puts the document on Chrome's bfcache
blocklist: leaving Studio and pressing Back would cold-boot the app instead
of restoring it.

* docs(runtime): say what the paused-media probe actually filters

* revert(cli): drop the preview prewarm that held the CLI event loop

The fire-and-forget warm added in c9b4263c7 keeps the CLI alive until it
settles. cli.ts drains the event loop instead of calling process.exit, and
two launch paths return straight into exit, so the warm delays them: 2660 ms
with the warm suppressed, 8779 ms against a server answering in 6 s, 13293 ms
against one that hangs. The 10 s abort bounds the hold, it does not remove it.
This is the hazard already documented at preview.ts:1605-1609.

Moving the warm into the server process was the obvious fix, but measurement
says there is nothing there to warm. tsup bundles @hyperframes/core/compiler
into cli.js -- bundleToSingleHtml is a plain inline function in the artifact --
so the await import() at studioServer.ts:397 resolves an already-evaluated
namespace. A probe at listen time measures that import at 1 ms, and a paired
run shows no gain: first request 256 ms without a compiler kick, 308 ms with
one. The cold cost the plan attributed to the module load is somewhere else.

Two claims in the reverted comment were also wrong: routes/preview.ts:332 is
an ETag, not a bundle cache, and the player appends no shader params -- the
only query param is variables, which IS part of the ETag.

Reverts packages/cli to origin/main exactly.

* fix(studio): let a remount retry the player chunk after a failed load

1037456aa hoisted the dynamic import to module scope and shared one promise
across every mount. On rejection the .then never ran again: retryPreviewRef
and previewError stayed null, compositionLoading stayed true, so the preview
sat on an infinite spinner with the retry button at Player.tsx:462 unreachable,
and remounting <Player key={activeKey}> -- the recovery path that used to work
-- awaited the same rejected promise.

Memoize through a getter that clears the memo on rejection instead. The
module-scope kick still fires the chunk request before the shell's first
layout, and a remount performs a fresh import as it did before the hoist.

Discloses what the earlier commit did not: the player barrel re-exports
Player, so every studio module importing that barrel now evaluates this one
and eagerly loads the real player bundle. That is 27 DOM-env test files;
the full studio suite passes, 439 files and 4838 tests.

* docs(studio-server): trim the proxy-counter comment to the repo's four-line cap

* fix(studio): stop one failed player import poisoning every later mount

The previous commit claimed a remount could retry the player chunk after a
failed load. In a browser it cannot: the module map caches a failed fetch
as an errored entry for the document's lifetime, so a repeated import of
the same specifier rejects from cache without touching the network
(measured in headless Chrome: three attempts, one request). Clearing the
memo on rejection is still right, because it stops every later mount
awaiting the same poisoned promise, but recovery is a page reload, and the
comment now says so.

The tests said what vitest does, not what the browser does. Two assertions
survived their own mutation: removing the memoisation left the attempt
counter unchanged because vitest caches a resolved mock, and deleting the
module-scope kick, the optimisation this branch exists for, left the suite
green. Both are now asserted directly: the kick has run once at import
time before any mount, and every mount receives the identical promise.

* chore(ci): allow the deletion of the dead sdk-session reload test

* fix(runtime): the timeline resolver no longer unpauses children it cannot drive

Exercising the branch in a real browser still showed sub-compositions
free-running while paused, on a path no seek follows: the timeline
resolver, which runs on every rebind (after an edit, on the periodic bind),
unpaused every registered child BEFORE trying to nest it into the root.
A child the root actually holds is driven by the paused root and is
harmless; a child the root never takes stays on GSAP's global ticker, and
unpaused there means running. The seek fix in this branch could not
reach it, because a rebind is not a seek.

The resolver now reads back which children the root holds and unpauses
only those; the same rule applies to the composite fallback timelines.
Standalone registry children stay paused, as the transport's per-child
seek already requires.

Regression test: a hosted registry child whose root cannot nest it stays
paused across a forced rebind. Fails on the previous resolver.

* test(runtime): pin that the resolver unpauses a child only once the root holds it

The resolver's positive half had no fixture: nothing anywhere nested a
candidate for real, so computing the held set before the add loop instead
of after it left the suite green. A root that reports children only once
added now distinguishes the two orders, and the unpause is asserted at
the moment it happens, because the transport's first seek pauses every
hosted child again straight after resolution. Also drops a cast that
re-declared getChildren, which RuntimeTimelineLike already has.

* fix(runtime): the paused side stops every clip the transport drives

The paused-side enforcement scanned only media carrying its own data-start.
A clip inside a composition inherits its timing from the host and has no
data-start of its own, yet the transport plays it, so it could start while
paused and run unopposed, exactly the hole the invariant was written to
close. Reviewer probe: a hosted video with data-duration only kept
running while paused; the same element started under __player.play().

"Media the transport drives" now has one definition, shared by the media
cache and the paused-side probe, so neither can be narrower than the
other. Regression test: hosted media with no data-start started while
paused is stopped. Fails against the data-start-only scan.
2026-09-12 12:56:00 -04:00
Vance IngallsandClaude Fable 5.1 4ba8396c27 chore: release v0.8.35
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:30:23 -07:00
Miguel Ángel 849ce0176a fix(catalog): name the better search tier when a search finds nothing (#3872)
* fix(catalog): name the better search tier when a search finds nothing

The hint that tells a scripted caller the on-device tier exists was wired
to the branch where the search succeeded, and was silent on the branch
where it found nothing. The caller with the most reason to hear it was the
only one who never did.

It also only ever reached stderr, so a --json caller never saw it at all.
The hint is now a returned sentence pushed into the same warnings array the
zero-result envelope already serializes, and printed from that one value, so
the terminal and the envelope cannot drift.

Withheld when the query parsed to no searchable tokens, where the advice is
already to search in English, and when a warning has already explained why
the tier cannot run here.

The consent gate is untouched: nothing downloads, and the sentence still
asks the caller to check with a person before enabling it.

* fix(review): pass the resolved model status, and pin both hint guards

Deriving the status inside the hint helper re-read and re-hashed the
on-device model from disk to answer a question the caller had already
answered a few lines earlier. It now takes the resolved value.

Both guards below the status check were invisible to the suite: every
other case pinned a status that already returned null, so deleting either
guard passed everything. Two tests now hold them, and each fails for its
own mutation.

* docs(skills): tell agents to read the tier hint the envelope now carries

The CLI skill said that under --json nothing about the offline tier is
printed at all. An agent following that sentence would never read the
warnings array this branch writes the tier sentence into, so the fix
would land in the code and never reach the caller it was written for.

It also described warnings as firing only when a tier was asked for and
could not run. That is now too narrow: a zero-result search emits the
hint with no tier requested.

Regenerate skills-manifest.json for the changed content hash.
2026-09-11 17:14:10 -04:00
miga-heygen ea7e1dbd0b fix(cli): surface a warning when doctor's HYPERFRAMES_PYTHON override is rejected (#3870)
## Summary
- `findPython()` correctly resolves `HYPERFRAMES_PYTHON` before falling back to the PATH probe — that part already works, confirmed by direct testing. But when the override is set and fails validation (nonexistent path, non-executable, non-Python-3 output, timeout), it silently falls through to the PATH probe with zero diagnostic. A user whose override had any subtle issue got a plain "Not installed" from `doctor` with no signal the variable was even seen.
- This is a corrected, narrower version of a report that originally claimed `doctor` ignores `HYPERFRAMES_PYTHON` entirely — that claim was refuted directly (the override resolution works). The real defect is the silent validation-failure path.
- Extracts the override-validation logic into `validatePythonOverride()` and adds `describeRejectedPythonOverride()`, which `doctor`'s TTS (Kokoro) and BGM (MusicGen) checks now call to append the rejection reason to their `detail` when applicable. `findPython()`'s own behavior (including its fallback) is unchanged.

PRINFRA-669

## Test plan
- [x] New `packages/cli/src/tts/python.test.ts`: `describeRejectedPythonOverride` returns null when unset / when the override validates; names the override + exception message when the override can't run; names the override + actual output when it isn't Python 3; `findPython` still falls back to the PATH probe when the override is rejected (unchanged behavior) and still uses a valid override directly.
- [x] Confirmed RED against the pre-fix source (tagged stash) — all 4 new `describeRejectedPythonOverride` tests failed with "not a function"; GREEN after restoring the fix.
- [x] `bunx tsc --noEmit`, `bunx oxlint`, `bunx oxfmt --write` clean on changed files.
- [x] `bunx fallow audit --base origin/main --fail-on-issues`: no issues in the 3 changed files.
- [x] Full `packages/cli` vitest suite: 3033/3038 passing (2 pre-existing unrelated failures — a PID/socket sandbox quirk and an agent-env-var-pollution test — plus 4 browser-test files failing at collection on a pre-existing `node:` builtin import issue under this sandbox's happy-dom setup; all confirmed identical on pristine `origin/main` and unrelated to this change, consistent with every other fix from this backlog-drain session).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-09-10 23:57:11 -04:00
miga-heygen bafc7b4e00 fix(cli): stop promising a universal per-time filmstrip for --layout strip (#3867)
## What

`keyframes --shot --layout strip`'s help text, its type's inline doc comment, and the CLI reference docs all described `strip` as an unqualified "filmstrip by time." The tool doesn't actually do that for the overwhelmingly common case.

## Why

A real per-time pixel filmstrip is only produced when the sampled selector is an SVG element (gated by an internal shape check — `typeof element.getBBox === "function" && typeof element.getScreenCTM === "function"`). Any other selector — including every nested sub-composition host, which is always a `<div data-composition-src>` — silently falls back to one live screenshot plus vector position markers instead.

This isn't a capture bug: for a non-SVG selector, real per-time pixel compositing was never implemented, only 3D bbox/marker sampling. But the documented behavior over-promised what the tool does, so a user following the docs on the common case (a DOM/sub-composition selector) sees root captions and empty image boxes where they expected the nested composition's actual content to move across frames — the diagnostic strip is misleading, even though the real render is correct.

## How

Reworded all three descriptions (CLI help text, `ShotOptions.layout` TSDoc, and the reference docs table) to state the SVG-only condition and the DOM/sub-composition fallback explicitly. No behavior changed — this is a documentation-accuracy fix, per the ticket's own framing that a doc-only fix fully resolves the reported symptom (a silent, misleading omission) for a P3.

## Testing

Added a test asserting the CLI help text no longer makes the unqualified "filmstrip by time" claim and does disclose the SVG-only condition — guards against a future regression back to the misleading wording. Verified RED (fails against the pre-fix string) and GREEN (passes after the fix) via a local before/after comparison.

- `bunx vitest run src/commands/keyframes.test.ts src/commands/motionShotLayout.test.ts` — 46/46 passing
- `bunx tsc --noEmit` in `packages/cli` — clean
- `bunx oxlint` / `bunx oxfmt --write` on changed files — clean
- Full CLI suite (excluding known-broken-in-sandbox browser-launch tests, unrelated to this change): 217 test files / 3025 tests passing

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-09-10 23:22:14 -04:00
miga-heygen 5a95211e36 fix(cli): apply the canvas/video pixel-hash carve-out to img (#3865)
## What

`check`'s sweep_static guard false-positives on a composition that swaps between equal-size, equal-position opaque `<img>` elements — a common authoring pattern for revealing frame N of a still sequence from a paused GSAP cursor. The render itself is correct; only `check`'s verdict is wrong.

## Why

The sweep guard fingerprints every visible element's box + opacity + font-variation-settings per seeked sample. That's deliberately blind to pixel-only motion (a canvas repainting, a video playing) with no element moving, so an existing carve-out downsamples each visible `canvas`/`video` to 8x8 and folds its pixels into the fingerprint specifically to catch that class of motion.

That carve-out's element selector (`root.querySelectorAll("canvas, video")`) never included `img`. An img src/visibility swap between equal-size opaque images moves zero geometry and zero opacity, so it stays outside both the base fingerprint and the pixel-hash carve-out — the whole-run fingerprint reads byte-identical across every sample and `sweep_static` fires on an animating composition.

## How

Widened the selector to `canvas, video, img`. No other change was needed: `mediaPixelHash` already handles `img` correctly — `drawImage` accepts any `CanvasImageSource`, and its width/height detection already falls back to the element's bounding rect the same way it does for `canvas`/`video`.

**Scope note:** this repo has an open PR (#3707) touching the same function (`collectLayoutGeometry` in this same file) for a different, unrelated bug (text/counter fingerprinting). This change is deliberately isolated to the `canvas, video` → `canvas, video, img` selector line and a new comment above it — verified against #3707's current diff that neither touches this exact loop, so the two PRs shouldn't conflict regardless of merge order.

## Testing

Added a test mirroring the existing "changes the sweep fingerprint when visible video pixels advance" test, using an `<img>` element instead of `<video>` with the same pixel-mock approach.

- `bunx tsc --noEmit` in `packages/cli` — clean
- `bunx oxlint` / `bunx oxfmt --write` on changed files — clean, no changes needed
- Full CLI suite (excluding known-broken browser-launch tests unrelated to this change): 217 test files / 3023 tests passing

One local-environment caveat, disclosed for transparency: `layout-audit.browser.test.ts` (the file the new test lives in) can't execute in my local sandbox — it fails identically with or without this change (`No such built-in module: node:`, a happy-dom + Vite externalization issue affecting every test file in this repo that imports Node builtins at the top under `@vitest-environment happy-dom`, not specific to this change). I verified the new test's logic and mocking approach are structurally identical to the existing, CI-passing video test it's modeled on, and will confirm via this PR's CI run rather than a local one.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-09-10 23:21:48 -04:00
miga-heygen 9c84277d28 fix(cli): let .hyperframesignore negation re-include hidden directories (#3859)
## What

`.hyperframesignore` negation rules (e.g. `!/.media/`) could never re-include a hidden (dot-prefixed) directory in the publish or cloud-render project archive, even though negation works correctly for every other kind of path.

## Why

`collectProjectFiles`'s walker called `shouldIgnoreSegment` first for every directory entry, and that check unconditionally excluded any name starting with `.` — before the project's ignore matcher (built from `DEFAULT_PROJECT_IGNORE` + `.hyperframesignore`, where negation is evaluated) ever ran on that path. A hidden directory was discarded at the walk step, so no negation rule downstream could ever reach it.

## How

- Moved the dot-prefix exclusion out of the hard `shouldIgnoreSegment` short-circuit and into the same ignore matcher that already parses `.hyperframesignore`, as a new default pattern (`.*`) in `DEFAULT_PROJECT_IGNORE`. Dot-prefixed paths are still excluded by default, but now via the same gitignore-style negation path as everything else, so a project's `.hyperframesignore` can override it.
- `shouldIgnoreSegment` is now reserved for the fixed, non-negotiable exclusions only (`.git`, `node_modules`, `dist`, `.next`, `coverage`, `.DS_Store`, `Thumbs.db`) — the set no `.hyperframesignore` rule should ever be able to reach.
- Added regression coverage for both directions: a `.hyperframesignore` negation re-including a hidden directory, and an unmatched hidden directory still being excluded by default (no behavior change for existing projects without an explicit negation rule).

## Testing

- `bunx vitest run packages/cli/src/utils/publishProject.test.ts` — 40/40 passing (2 new)
- `bunx vitest run packages/cli/src/commands/cloud/render.test.ts` — 10/10 passing (cloud render reuses the same archive builder)
- `bunx tsc --noEmit` in `packages/cli` — clean
- `bunx oxlint` / `bunx oxfmt --write` on changed files — clean, no changes needed

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-09-10 23:20:33 -04:00
Miguel Ángel 59def1b66d chore(release): v0.8.34 (#3854) 2026-09-10 14:45:17 -04:00
renovate[bot]andJames eae4892ae8 chore(deps): update dependency vitest to v4 [security] (#3789)
* chore(deps): update dependency vitest to v4 [security]

* fix(test): preserve test behavior on Vitest 4

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: James <james.russo@heygen.com>
2026-09-10 13:55:52 -04:00
James Russo 96637aeb1c fix(capture): share the render host policy and block localhost aliases (#3844) 2026-09-10 07:59:30 -04:00
James Russo d393541c2b fix(cloud): preserve existing output when downloads fail (#3840)
* fix(cloud): preserve existing output when downloads fail

* test(cloud): pin same-directory download staging
2026-09-10 05:44:40 -04:00
Miguel Ángel 622ea5b38c test(skills): pin the catalog exemption as a capability and lint the templates list (#3838)
The two skills exempt from the catalog search were pinned only by the
sentence that declares the exemption, so the exemption would go stale the
day either gained a way to install a registry item. The test now also
asserts neither skill contains an install command or a registry path.

The templates list in the scaffold reference claimed to be lint-checked
but lacked the marker that arms the check, so a misspelt template would
have passed. It now carries the marker with its presets and skill names
allowlisted, and a misspelt template fails the lint.
2026-09-10 03:49:21 +00:00
Miguel Ángel 0f8eb892e3 fix(skills): search the component catalog before hand-building a look (#3829)
* fix(motion-graphics): make the catalog search fire before hand-authoring

The workflow's only reuse instruction pointed at catalog-map.md, a
hand-maintained snapshot of ~60 registry items, and no file in the skill
ever named `hyperframes catalog --query`. An agent asked mid-build for
CRT scanlines and a glitch effect had no instruction to search, so it
hand-authored both while caption-glitch-rgb ("RGB chromatic aberration
with CRT scanline overlay") ranks first for that query on either tier.

The search reads the hosted registry and needs nothing installed, from
any directory with no project, so "the components were not installed"
was never the cause. Say that where the reader is, since the wrong
diagnosis is the intuitive one.

Director Part 2 and the Builder now run the search before naming a
block, and catalog-map.md is labelled a partial snapshot whose misses
prove nothing. Pinned by a content test in coreSkillContent.test.ts.

* fix(skills): search the component catalog before hand-building a look

Authoring workflows never told the agent to search the component library,
so agents rebuilt effects the registry already shipped. A user reported
building an effect from scratch that the registry already contained; the
search that would have found it needs nothing installed, which is why the
usual self-diagnosis ("I forgot to install the components") is wrong.

All ten workflow skills carried zero mentions of `hyperframes catalog`.
The instruction lived only in hyperframes-cli and hyperframes-registry,
both loaded on demand, and the registry skill's own trigger named the
command rather than the symptom - circular, because an agent that never
thought to search could not reach the doc telling it to search.

- Eight workflows now run the search at the point they decide what to
  build, before authoring. The two that compile through a closed
  authoring vocabulary (embedded-captions, talking-head-recut) document
  why they deliberately do not.
- hyperframes-registry triggers on the symptom (a named look, effect,
  treatment or transition) instead of the command name; the router table
  and the catalog surfaces carry the same framing.
- Fixes hand-maintained lists that had drifted: bar-chart-race was listed
  as a hand-author gap in two files while shipping in the registry;
  stat-motion was named as an installable block and is not one; the
  caption-* family count was one high; the registry discovery tables
  claimed to be the block list while covering 97 of 180.
- bun run lint:skills now fails when a doc marked as a registry snapshot
  names an item the registry does not have.

* refactor(scripts): reuse native recursive readdir and the shared registry type

Simplify pass on the new registry-snapshot check, behaviour identical:
- collectMarkdownFiles uses readdirSync({ recursive: true }) instead of
  hand-rolled recursion, matching scripts/generate-template-previews.ts.
- registryItemNames types registry.json with the exported RegistryManifest
  instead of an ad hoc inline shape, matching scripts/catalog/build-local-vectors.ts.
  The runtime guard stays: a cast describes the file, it does not validate it.
- One report() helper replaces the duplicated print-and-count block in both
  lint passes.

* refactor(scripts): name the registry check's blind spots and stop self-arming

Applies the review findings on the new check, behaviour identical except
where noted:
- lintRegistryItemRefs returns null for an unmarked file instead of an
  empty array, so "not a snapshot" and "a clean snapshot" have one owner
  and the marker is matched once rather than twice.
- Marker detection ignores fenced blocks, so a doc that documents the
  marker syntax in an example no longer arms the check on itself. The id
  scan still reads full content, so fenced examples stay covered.
- The header comment and two tests now pin both known false negatives:
  identifiers outside backticks, and single-word item names. Measured on
  the six marked files, dropping the hyphen requirement would monitor 3
  more items and force 46 allow= entries for ordinary prose words, so the
  requirement stays and the gap is stated instead of silent.

* chore(skills): regenerate skills manifest after catalog-search edits
2026-09-10 03:21:33 +00:00
James Russo caa72de355 fix(cli): bound and validate registry installation (#3814)
* fix(cli): bound and validate registry installation

* fix(cli): preserve registry install compatibility
2026-09-09 12:41:48 -04:00
James Russo 26916b7bdd fix(cli): validate captured Lottie archives and previews (#3811)
* fix(cli): validate captured Lottie archives and previews

* fix(cli): bound Lottie discovery before buffering

* fix(cli): bound Lottie discovery lifecycle and archive paths

* fix(cli): retain highest-priority Lottie candidates
2026-09-09 08:50:48 -04:00
James Russo 1aa5b9e4fa fix(cli): validate captured image and font downloads (#3809)
* fix(cli): validate captured image and font downloads

* fix(cli): bound nested assets and preserve distinct fonts
2026-09-09 07:15:29 -04:00
Vance Ingalls c2702e4c76 fix(cli): verify Chrome can execute during preflight 2026-09-08 23:42:57 -07:00
3a7fcd10e0 feat(registry): add 25 image carousel blocks (#3790)
* feat(registry): add 25 image carousel blocks (5 families × 5 variants)

Five carousel families, each with 5 style variants:

- Orbit (1–5): image cards on a spinning 3D Fibonacci sphere
- Path (1–5): cards following animated CSS motion paths
- Circle (1–5): circular carousel layouts
- Vision (1–5): Apple Vision-style spatial presentations
- Text Circle (1–5): circular carousels with text overlays

All blocks are 1920×1080 at 6s, with 12–24 configurable image slots.
Includes catalog preview thumbnails for each block.

Co-Authored-By: Jake Moran <jake.moran@heygen.com>

* refactor(registry): host carousel block images on the CDN

The registry is served straight out of this repository
(DEFAULT_REGISTRY_URL points at raw.githubusercontent.com), so every byte a
block ships is permanent history. The 25 carousel blocks added 421 JPEGs,
27.7 MiB in a checkout, and made the diff 472 files. Only 23 of those images
were distinct: the same 12-24 placeholders were copied into every block.

files[] entries gain an optional `url`. When set, the installer fetches the
bytes from there instead of joining the registry base. `path` does not change
and still says where the file lands relative to the item, so composition HTML,
target mirroring and `hyperframes add` behave exactly as before.

Keys are content-addressed, so the 396 manifest entries resolve to 23 objects,
and a changed image gets a new URL rather than a stale one cached behind
`immutable, max-age=31536000`.

The catalog preview renderer copies an item's directory and renders it, so it
needs the same materialisation step. Without it the preview draws every card
blank and reports success, which is worse than failing.

Also drops registry/catalog/, 25 hand-made thumbnails referenced by nothing;
catalog previews are rendered by CI and served from docs/images/catalog.

Verified: all 23 objects return 200 from the CDN with hashes matching their
keys; `hyperframes add carousel-orbit-1` against a local registry installs 24
real JPEGs; the preview render produces the album art, and produces blank
cards when the fetch step is removed.

* style(registry): format the carousel composition HTML

`oxfmt --check .` covers the whole tree, and these 25 files were never run
through it. The pre-commit hook only formats staged files, so nothing local
caught it.

* feat(catalog): publish the carousel blocks without republishing their images

These 25 blocks had no Catalog page. Every other item in the registry has one,
so they shipped invisible: installable by name, unfindable by browsing.

Generating them naively undid the change they were added by. The Catalog
payload copies an item's assets into docs/public/, which is tracked, so the 396
images this PR just removed came back as 43 MB one directory over — worse than
the 3 MB they started as, because each block got its own copy.

The copy exists because these compositions assemble `img.src` at run time out
of a variable value, so there is no `src="..."` in the markup for the payload's
asset scan to resolve. An unpredictable path can only be satisfied by serving
every file beside it, which is what `needsOwnDirectory` asks for.

An absolute URL needs no directory: the scan already skips any `https:`
reference. So for the payload path only, hosted files are left undownloaded and
the composition's variable defaults are rewritten to their URLs. The preview
renderer still downloads them, because it paints real frames and a missing file
is a blank card.

The explorer posts every value to the preview frame on mount, including
untouched ones, so the page's variable list carries the URLs too. Left as local
paths they would have overridden the payload's own defaults and asked the frame
for a file that was deliberately never published.

Result: 25 pages, 25 payloads, zero bytes of image added.

Verified: a spike item declaring no assets at all rendered its 24 covers from
the CDN, proving the variable-default path; payload generation for a carousel
block now writes no item directory and no shared asset; the preview render
still produces the album art. mint validate and mint broken-links pass on the
new pages. test:scripts is green.

* refactor(catalog): split the hosted-asset step out of prepareProjectDir

Two functions rather than one: finding the composition and rewriting its
variable defaults are separate jobs, and inlining the mode branch pushed
prepareProjectDir past the complexity gate it was already sitting on.

Behaviour is unchanged. Re-verified both paths after the split: the payload for
a carousel block still writes no item directory and no shared asset, and the
preview render still produces the album art.

* feat(catalog): give the carousels their own shelf

25 image carousels landed in Showcases and were 53% of it, so the scenes that
shelf exists for disappeared underneath them. That is the same shape the 24
editor themes made, and it gets the same fix they got.

Keyed on the first tag, which is this file's stated grouping rule, rather than
on the name. `screen-flow-carousel` leads with `product-demo` and stays on the
shelf that says what it is for; a future carousel that is not named
`carousel-*` still lands here.

Showcases 47 -> 22, Carousels 25, and no existing item changed shelf.

* fix(registry): centre the circle-5 carousel path in its composition

Its ring was centred at x=3832.6 in a 3840-wide composition, so it sat on the
right edge and most of it fell outside the frame. Only a few cards were ever
visible, cropped, with two thirds of the composition empty.

The exported path carried absolute coordinates from a layout that was never
recentred. Shifting the four vertices by (-1912.597, -4.340) puts the ring on
the composition centre. Handles are relative, so only the anchor points move
and the shape is unchanged.

carousel-text-circle-5 shares the identical path and had the identical fault.

The other three circle variants sit within 12% of centre, which reads as
authored placement rather than the same bug, so they are left alone.

* fix(catalog): rebuild the circle-5 payloads after recentring the path

The Catalog preview plays the payload, not the composition on disk, so
recentring the source changed nothing a reader sees. The payload still carried
the old vertices and the ring still hung off the right edge of the frame.

Verified the consumer this time, not just the producer: both payloads now
resolve to a path centre of x=1920. The other 23 rebuild byte-identical, so the
formatting pass did not reach them.

* feat(catalog): promote Carousels to its own section

It was a shelf inside Scenes & demos, which is where a scene type belongs by
kind but not by weight. At 25 items it is larger than Data & charts (17) and
Blocks (13), each of which is already a section holding a single shelf, so the
catalog's own precedent puts it one level up.

Pulling it out also takes the largest section in the catalog from 120 items to
95, which is the reason the shelf was added in the first place.

The two circle-5 pages change because their embedded source block carries the
recentred path; nothing else in them moved.

---------

Co-authored-by: Jake Moran <jake.moran@heygen.com>
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
2026-09-09 00:47:31 -04:00
miga-heygenandMiguel Ángel 6e3308be4f chore: release v0.8.33 (#3796)
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-08 22:12:51 -04:00
miga-heygenandMiguel Ángel 662f96b3f4 chore: release v0.8.32 (#3788)
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-08 19:40:29 -04:00
Miguel Ángel c5b7f6aeab fix(check): retry system Chrome after managed launch crash (#3701) 2026-09-08 18:11:13 +00:00
Miguel Ángel e5a6023228 fix(skills): guard canonical stores from mirror aliases (#3714) 2026-09-08 18:10:44 +00:00
Miguel Ángel 400925f432 fix(keyframes): resolve helper-returned DOM targets (#3688) 2026-09-08 18:10:38 +00:00
Miguel ÁngelandVance Ingalls 9308eadcfc fix(render): bound and recover capture stalls (#3700)
* test(producer): pin unpinned drawElement stall retry

* fix(engine): bound drawElement frames so a wedged renderer falls back instead of failing (#3172)

* fix(engine): bound drawElement frames so a wedged renderer falls back instead of failing

A single drawElement frame could kill an entire render. On one comp
(caption-editorial-emphasis) drawElementImage returns normally and the renderer
then stops draining its task queue: the setTimeout(…, 0) that schedules
toDataURL never fires, the capture page.evaluate never settles, and 60s later
the stage watchdog fails the whole render. Deterministic on 152.0.7977.30,
always the same frame. Root-cause detail in PRINFRA-488.

Two gaps, both closed here.

1. Nothing below the 60s stage watchdog bounded a frame. Adds a per-frame
   deadline (HF_DE_FRAME_TIMEOUT_MS, default 15000, 0 disables) around the WHOLE
   frame operation, not just the drawElementImage call — the stall surfaces at
   whichever page round-trip comes next, and was observed at both the capture
   and the seek's background-image decode. Bounding one call missed it.

   Deliberately NO per-frame screenshot fallback for this error: once the
   renderer stops scheduling it is wedged for every subsequent round-trip on
   that page. Measured — the screenshot fallback blew the same deadline. The
   frame fails fast instead, so the recovery that actually works can run.

2. That recovery was ineligible. shouldRetryViaPinnedFallback only retried a
   generic capture failure when the worker count was PINNED by the inversion or
   the router; a comp that engaged drawElement on the ordinary single-worker
   path had no whole-render fallback at all. A renderer stall is now retryable
   on any routing — the failure is a property of drawElement itself, and the
   retry re-renders on a fresh page via screenshot. Reported as
   de_fallback_reason "de_renderer_stall" so it is distinguishable from
   capture_error in telemetry, and counted per-session as deFrameTimeouts.

Verified end to end on the repro: previously exit 1 with "stalled: no frame
progress for 60000ms"; now the deadline fires, the render retries via
screenshot, and RENDER_OK with a valid 1920x1080 / 240-frame / 8.0s MP4.

Tests cover the predicate both ways plus the cross-package error match. Engine
1,481 and producer 587 green (audioPadTrim.integration flakes only under
full-suite parallel load — passes in isolation, and on clean HEAD it is green
too, so it is not from this change).

Refs PRINFRA-488

* fix(engine,producer,cli): count drawElement frame timeouts where they happen

`session.deFrameTimeouts` was incremented inside `captureFrameCore`'s catch,
but the deadline races that function from OUTSIDE it — `withFrameDeadline` is
applied by `captureFrameToBuffer`, and `DeFrameTimeoutError` is constructed
nowhere else. So the branch was unreachable: on a wedged renderer the counter
stayed 0, the engine-side stall log never printed, and
`CapturePerfSummary.deFrameTimeouts` reported 0 on every affected render. The
end-to-end recovery worked throughout — `isDeRendererStallError` matches by
name at the producer — only the observability was dead.

Moved the increment and the log into an `onTimeout` hook that fires from the
deadline itself, deleted the unreachable branch and the now-unused
`isDeFrameTimeoutError`, and threaded the count out to
`render_completed.de_frame_timeouts` so the rate is graphable apart from the
other `capture_error` fallbacks. A stall always costs a whole-render re-run,
which is why it deserves its own series.

Also corrected the `fallbackReason` docstring, which still listed only
`psnr | blank | oom | capture_error` after this branch added
`de_renderer_stall`.

`withFrameDeadline` is exported for a two-case fake-timer test: the deadline
fires and calls `onTimeout` once, and a resolving inner promise passes its
value through with no late timer. The first case fails if the counter moves
back inside the work promise.

(cherry picked from commit ec2b2b5b2a)

* fix(render): retry sequential screenshot stalls

* fix(render): bound parallel worker capture phases

---------

Co-authored-by: Vance Ingalls <vance@heygen.com>
2026-09-08 18:10:28 +00:00
Miguel Ángel 776c291f42 fix(check): clip overlap geometry to visible fragments (#3698) 2026-09-08 18:10:02 +00:00
Miguel Ángel 00c91d6807 fix(check): allow missing caption overrides (#3716) 2026-09-08 18:09:35 +00:00
Miguel Ángel 8fc8294dbd docs: clarify paused composition roots in project instructions (#3670) 2026-09-08 18:09:29 +00:00
Miguel Ángel 2f7b7a9529 fix(render): reject unsupported ProRes rate controls (#3702) 2026-09-08 18:09:19 +00:00
Miguel Ángel 63182f6a93 fix(cli): reject confirmed unsupported SDR encoders (#3679) 2026-09-08 18:08:59 +00:00
Xuanru LiandCursor e5d89f770f feat(check): flag connectors that point at nothing and stylesheets that leak into the frame (#3736)
* feat(lint): catch leftover marker heads and guessed marked shafts

Dash-draw on a marked path shows the arrowhead before the shaft exists.
A long marked path that misses every node in both user and screen space
is the same detach as the CTM-paste bug, without the counterfactual.

* feat(check): flag orphan connectors and unbalanced style tags

Catch a visible shaft while fewer than two nodes are on stage (enter-early /
exit-late), and extra </style> that dumps CSS onto the frame.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(check): judge connector_orphan by the shaft's own endpoints

The rule counted anchors stage-wide and skipped whenever two were on. That
tier is every visible text-bearing or opaque element under 15% of the
stage, so a title and a footer alone satisfied it — the check could not
fire on a composition that had any chrome, which is all of them.

Endpoints are now resolved per shaft, against candidates gathered by
layout instead of by visibility, so a node hidden with opacity:0 still
owns the endpoint that meets it and its visibility is what the rule tests.
An endpoint that meets no node is left to connector_detached.

* revert(lint): drop the marker rules this repo would only duplicate

marker_orient_typo and marker_dash_draw_on already run in Zephyr's Python
connector lint, down to the same regexes and the same message and fixHint
strings. Zephyr concatenates its local findings onto this bridge's result
with no dedupe, so shipping them here reports each one twice and counts it
twice, and every later edit has to land in two places.

The browser-check work stays: a marked shaft that meets no node, and an
orphan shaft, are both runtime facts this repo can see and a source-reading
rule cannot.

* chore(check): drop the comments added with the orphan rewrite

* fix(check): score orphan and detach on overflow-opted connector layers

Allow-overflow is a clip opt-out, not a lifecycle skip. Orphan now uses
the same connector names as detach so flow and arrow shafts are judged.

* fix(check): see axis-aligned shafts, and script tags closed with a space

connector_orphan gated on isVisibleElement, which rejects anything whose
bounding box has no height or width. A straight horizontal or vertical
connector is exactly that, and it is the common shape, so the rule was
blind to most of what it exists to catch: across 47 production
compositions it produced nothing. It now tests paint -- display,
visibility and the opacity chain -- and leaves size to the 80px chord
floor it already had. The same 47 compositions now yield three findings,
each naming the endpoint that is still dark.

unbalanced_style_tags stripped scripts with a regex that only matched
`</script>`. `</script >` is valid, so the script survived the strip and
a `"</style>"` string literal inside it counted toward the tag balance,
reporting an error on a composition whose tags are paired. Both the strip
and the closer count now tolerate whitespace before the `>`, which is also
what CodeQL flagged on this branch.

* fix(lint): count style tags in one pass instead of stripping scripts

CodeQL reads the `source.replace(/<script.../)` as an incomplete HTML
sanitizer. It was never one — the rule only needs to not count `<style`
tokens that live inside a script string. A single alternation scan, where
the script branch consumes the whole block, gets the same counts without
a replace.

* test(check): cover the two orphan guards that no fixture was crossing

Neutering `shaftIsPainted`'s opacity check, or the endpoint proximity
threshold, left the suite green. Both are false-positive guards: a
connector staged hidden before its reveal, and a hidden element that
happens to be the nearest thing to an endpoint without belonging to it.

The hidden-shaft case now runs over all four ways a shaft can be
invisible, so the display and visibility clauses are crossed too.

* test: pin the two boundaries raised in review

A closer inside an html comment is already dropped upstream by
stripHtmlComments, so the rule never sees it. The test fails if that
stripping regresses; adding a comment branch to the rule's own scan
would have reintroduced the polynomial-redos pattern that stripper
exists to avoid.

A node hidden with display:none has no geometry, so nothing can place it
near an endpoint and connector_orphan stays silent. connector_detached
does not pick it up either, since it allows a half-attached shaft by
design. That case is uncovered, and the fixture says so.

* fix(check): type the hidden-shaft fixture table as a style record

* fix(check): key connector_orphan by geometry and let a live node win the endpoint

Two defects found in review.

connector_orphan was persistence-tiered but absent from the geometry key,
so several id-less shafts orphaning at one sample each collapsed into a
single finding that then read as held rather than transient. On the 47
corpus compositions this recovers 9 findings across 5 compositions where
3 across 2 were reported; one composition fades its nodes and its
connectors on a shared stagger, so four shafts each outlive a different
node and only one of the four survived the collapse.

The endpoint scan took the nearest candidate and asked whether that one
was hidden, without asking whether a visible box was also in range. A
staged halo sitting on the node it belongs to produced a finding naming
the halo. A visible candidate within threshold now settles the endpoint.

Fixtures added for both, and for the two guards a mutation sweep found
unpinned: the connector-shape test and the dash-offset skip.

* test(check): kill every surviving mutant in the connector_orphan path

Seven guards still passed the suite when deleted: the four candidate
filters, the connector layer's own visibility gate, the defs/marker skip
and the chord floor. Each now has a fixture where that guard alone
decides the outcome.

The earlier sweep that missed them was mutating the wrong copy of a
shared line — connectorAnchorRects and connectorEndpointCandidates carry
the same filter text, and a first-match replace edited the detached one.
Mutations are scoped by enclosing function now.

* test(check): order the halo before the node it hides under

The halo fixture proved nothing. Both boxes contain the endpoint, so both
score gap 0, and the strict tie-break keeps whichever comes first in
document order — which was the visible node. The old code picked the same
winner and stayed silent too.

Putting the halo first makes the tie-break hand it the slot, so only the
visible-candidate check keeps the endpoint attached.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 01:22:48 +00:00