Commit Graph
2476 Commits
Author SHA1 Message Date
Miguel Angel Simon Sierra 9f6c20e482 perf(studio): grow composition duration live on extend, no preview remount
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.
2026-07-08 23:46:28 -04:00
Miguel Angel Simon Sierra 580676bbf4 fix(studio): freeze timeline zoom during extend-drag so clips don't jump
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.
2026-07-08 23:46:28 -04:00
Miguel Angel Simon Sierra d7e7fca8ec feat(studio): magnetic snapping to clip edges, playhead, and bounds
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.
2026-07-08 23:46:27 -04:00
Miguel Angel Simon Sierra 6b4870246c fix(studio): dropping an overlapping clip on a lane restacks it, not a no-op
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.
2026-07-08 23:46:27 -04:00
Miguel Angel Simon Sierra 5e3ca6ae63 feat(studio): drag a clip past the video end to extend its duration
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.
2026-07-08 23:46:27 -04:00
Miguel Angel Simon Sierra ed8bf475d3 feat(studio): lane-model timeline (non-overlapping clips share a row)
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.
2026-07-08 23:46:26 -04:00
Miguel Angel Simon Sierra bc5fc410ee feat(studio): timeline layer UI — context headers, audio lanes, drop affordances
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.
2026-07-08 23:46:26 -04:00
Miguel Angel Simon Sierra 82fe779bd8 fix(studio): sub-composition child clips restack via self-contained intent
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).
2026-07-08 23:46:26 -04:00
Miguel Angel Simon Sierra eef500c890 fix(studio): realm-safe isHTMLElement so timeline z-index commits land
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.
2026-07-08 23:46:25 -04:00
Miguel Angel Simon Sierra e5ed512529 feat(studio): timeline track = stacking layer (NLE-style layering)
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)
2026-07-08 23:46:25 -04:00
Miguel Angel Simon Sierra 03f8089e52 fix(studio): capture effective (computed) z-index for timeline ordering
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.
2026-07-08 23:46:25 -04:00
Miguel Angel Simon Sierra 5ce362299c refactor(studio): single-source the timeline stacking key + guard audio reorder
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.
2026-07-08 23:46:24 -04:00
Miguel Angel Simon Sierra dd980697a2 feat(studio): unify timeline vertical reorder with z-index stacking
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
2026-07-08 23:46:24 -04:00
Miguel Angel Simon Sierra 070ee91694 feat(studio): expose resolved z-index and stacking context on timeline clips 2026-07-08 23:46:24 -04:00
Miguel Angel Simon Sierra dae26e72b2 refactor(studio): extract shared layerOrdering module from LayersPanel 2026-07-08 23:46:24 -04:00
Miguel Ángel 0ac000181e feat(studio): storyboard empty state with agent copy-prompt and skeleton preview (#2091)
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.
2026-07-08 22:32:40 -04:00
Miguel Ángel 623f91d6a1 feat(studio): always-on crop with reposition handle, drop crop mode (#2090)
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.
2026-07-08 22:24:26 -04:00
Miguel Ángel 6f0b57d608 fix(core): scope sub-composition html/body styles to the composition box (#2089)
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.
2026-07-08 22:21:47 -04:00
Miguel ÁngelandClaude Opus 4.8 57b3c78987 feat(media-use): color grading — grade/lut resolve, smart-grade, grade-compare + compare (#2041)
* 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>
2026-07-08 22:20:16 -04:00
Vance Ingalls 7face26f04 Merge pull request #2085 from heygen-com/release/v0.7.44
chore: release v0.7.44
v0.7.44
2026-07-08 18:43:00 -07:00
Miguel ÁngelandClaude Opus 4.8 8e04d01969 fix(cli): map ggml model stem to whisper.cpp dotted DTW preset (fixes large-v3) (#2086)
`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>
2026-07-08 19:53:26 -04:00
Vance Ingalls 1e8dd29815 chore: release v0.7.44 2026-07-08 16:21:05 -07:00
Vance Ingalls 20b9034577 Merge pull request #2056 from heygen-com/de2-15-parallel-de-verified
feat(engine,producer): verified interleaved parallel drawElement streaming (opt-in)
2026-07-08 16:20:25 -07:00
Vance Ingalls cba416693d Merge pull request #2045 from heygen-com/de2-14-subtimeline-failfast
fix(engine): fail-fast the sub-composition timeline wait when a script 404s
2026-07-08 16:13:18 -07:00
Vance Ingalls 381887541f fix(engine,producer): fix quadratic dedup rescan, correct race justification
Address two max-effort code-review findings on PR #2056 not covered by
the earlier review-gap commit:

- captureFrameToBufferPipelined's static-dedup reuse branch never
  advanced session.lastEncodeResultFrame, unlike its sibling real-capture
  branches. The gap-check window is computed from that watermark, so
  every consecutive reuse in a static run rescanned an ever-widening
  window instead of just the newest frame — O(n^2) total work over a
  long static stretch instead of O(n).

- The "single-threaded, no race" justification on the shared
  parallelGuard closure was wrong: the guard has real internal await
  points (recapture, PSNR) between reading and writing its
  sizes/absFloor/acceptedSmall state, so concurrent workers' calls do
  interleave there (confirmed). Replaced with the actual reason it's
  safe: absFloor only ratchets down, sizes is append-only and
  order-independent for the median, and acceptedSmall's fast path
  re-validates by exact byte-equality regardless of which worker wrote
  the reference buffer.
2026-07-08 16:11:46 -07:00
Vance Ingalls 2dbe958a49 fix(engine,producer): close review gaps in parallel drawElement streaming
Address PR #2056 review feedback:
- Fix totalFrames progress inflation for interleaved tasks — divide
  each task's span by its frameStride to match the actual per-worker
  frame count (captureFrameRange steps by stride), instead of summing
  raw endFrame-startFrame which double(N)-counts interleaved tasks.
- Attach a no-op .catch to each frame's pipelined encodeResult at kick
  time so an abandoned promise (loop exits early on abort/error before
  draining it) can't surface as an unhandled rejection during teardown.
- Document why the pipelined branch's stride=1 path is validation-only
  in production (HF_DE_PARALLEL_STREAM always uses interleaved
  distribution) so a future refactor doesn't unknowingly widen it.
- Comment the intentional single shared parallelGuard/parallelStats
  across workers (safe single-threaded, better rolling-median signal).
2026-07-08 16:11:46 -07:00
Vance Ingalls 81a4f04360 docs(producer): disambiguate compositor frame scheduling from the beginframe capture mode 2026-07-08 16:11:46 -07:00
Vance IngallsandClaude Fable 5 b3493a7b61 feat(engine,producer): verified interleaved parallel drawElement streaming (opt-in)
Step 2 of the DE engagement plan: multi-worker drawElement capture through
the streaming encoder, with the full runtime self-verification net riding
along — the confinement rule that kept the parallel clamp in place is now
satisfied on this path. Opt-in via HF_DE_PARALLEL_STREAM=true; default
routing (including the #2026 single-worker inversion) is unchanged.

Mechanism:
- distributeFramesInterleaved + WorkerTask.frameStride: worker i captures
  frames i, i+N, i+2N... — seek-based capture makes stride free and the
  ordered writer's reorder window shrinks from totalFrames/N to N (contiguous
  chunks serialize workers behind the writer).
- Depth-2 pipelined worker-encode produce in the parallel worker loop (the
  same shape as the sequential loop; frame k's in-page encode overlaps
  k+stride's produce). HF_DE_PAR_DEBUG=1 traces the first frames per worker.
- Drain guard extracted to createDrainFrameGuard (session-parameterized):
  every parallel frame gets the SAME blank-guard + PSNR self-verify as the
  sequential drain, against its owning worker's pre-injection ground truth
  (all sessions arm identical sample indices from
  CaptureOptions.compositionDurationSeconds).
- FrameReorderBuffer.abort(err): a failed worker (e.g. verification error)
  rejects all parked and future waiters — without this, peers park forever
  in waitForFrame and the pool (which awaits ALL workers before surfacing
  errors) deadlocks. Found by the verify-trip test; unit-tested.
- The typed DrawElementVerificationError is preserved past the pool's
  error-string flattening so the orchestrator's verify-retry recognizes it.
- Static-dedup stride hazard fixed: lastEncodeResult reuse now requires EVERY
  frame in (lastEncodeResultFrame, i] to be predicted-static (sequential
  capture reduces to the old has(i) check).
- Workers get separate browser PROCESSES under the flag: pages co-tenant in
  one browser starve non-active pages of BeginFrames on the paint-wait path
  (measured 86s vs 30s on a 3,245-frame rAF comp).

Validation:
- Happy path W3: verify samples pass across workers (4x inf on the 2,381f
  comp), output vs single-worker DE = 59.3dB (encode noise floor) — the
  interleave + dedup-stride produce identical pixels.
- Verify-trip (marginal comp + HF_DE_VERIFY_MIN_DB=45): fails at frame 649
  (32.2dB < 45), peers abort instead of deadlocking, whole render retries
  via parallel screenshot, RENDER_OK in 42.6s.
- Canary suite 7/7 with the flag off (default paths untouched); producer
  orchestrator tests 99/99; engine suite 909 passed (14 pre-existing main
  failures, stash-A/B verified); reorder-buffer abort unit tests.

Perf note: capture-only parallel speedup measured 1.38x (W2) / 1.52x (W3)
over single-worker DE in the spike; end-to-end numbers on this machine are
currently noisy (separate-browser init overhead + bench load) — clean
benchmarks before any default routing change. The flag stays explicit
opt-in; promoting it into the router replaces the #2026 W=1 pin for the
same cohort.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 16:11:46 -07:00
Vance Ingalls df1d20b765 fix(cli): check buildId when resolving the managed Chrome cache
Address the highest-severity max-effort code-review finding on the now-
merged #2082 (the drawElement Chrome-version-pin fix): findFromHyperframesCache
matched a cached Chrome by browser type only, never comparing its
buildId against CHROME_VERSION. Any machine that already rendered with
an older hyperframes version has an old build (this pin has moved
131 -> 151 -> 152 across releases) sitting in ~/.cache/hyperframes/chrome,
which satisfied the lookup and silently defeated the whole point of
#2082's version bump for exactly the population it was meant to fix —
drawElement's new capability probe would then permanently and silently
fall back to screenshot capture instead of ever fetching a build that
implements canvas.drawElementImage.

Verified directly (not just via review): seeded ~/.cache/hyperframes/chrome
with the old 131 build, confirmed a real render previously kept using it
forever; with this fix it's correctly ignored and 152 is downloaded.
New regression test locks in the buildId mismatch case.
2026-07-08 16:10:43 -07:00
Vance Ingalls 8fee20a525 fix(engine): log composition-id attribution on script-failure bail too
Address max-effort code-review finding on PR #2045 (confirmed, not
addressed by the earlier review-gap commit): the script_failure bail
path skipped the composition-id enumeration entirely, so a render with
multiple sub-compositions sharing a failed script only logged the raw
failed URL(s), never which composition(s) were still waiting on it —
a real observability regression versus the pre-#2045 behavior, which
always logged the missing-id list on any non-ready outcome.

Now enumerate unregistered composition ids unconditionally and log them
alongside whichever reason (script_failure or natural timeout) fired.
2026-07-08 16:09:48 -07:00
Vance Ingalls 8ba3c33915 fix(engine,producer,cli): close review gaps in sub-timeline fail-fast
Address PR #2045 review feedback:
- Share a SubTimelineWaitOutcome type (engine) end-to-end instead of
  widening to string across CapturePerfSummary / RenderPerfSummary /
  telemetry, so the three layers can't drift.
- Dedupe scriptLoadFailures on push — a 4xx response and its trailing
  requestfailed both recorded the same URL, doubling the failed-URL
  list in the fail-fast warning.
- Thread the sub-timeline-wait outcome into render_error (not just
  render_complete): a render that fail-fasts and then fails downstream
  (pollVideosReady, extract, encode) previously dropped this signal on
  the floor. dedupPerfs is now function-scoped so the catch path can
  read it, same treatment as the existing captureAttempts array.
2026-07-08 16:09:47 -07:00
Vance IngallsandClaude Fable 5 54359f3d6a fix(engine): fail-fast the sub-composition timeline wait when a script 404s
pollSubCompositionTimelines waits for every [data-composition-id] host to
register window.__timelines[id]. When the script carrying that registration
fails to load (404 / request failure), the registration can never arrive —
but the poll still burned the full playerReadyTimeout (45s), then warned and
shipped a silently animation-less render. Wild scale: the capture-setup
histogram over 30 days of local renders decays smoothly (402/503/364/282/191
per 5s bucket) then spikes to 705 at the 45s bucket — ~1,000 renders/month
across 402 distinct users, ~15 user-hours of pure waiting.

- Sessions now record failed SCRIPT resources (requestfailed + HTTP>=400
  response, listeners that already existed for diagnostics) in
  session.scriptLoadFailures.
- pollSubCompositionTimelines takes a failure getter and cuts the wait to a
  2s grace once any script failed, with a loud warning naming the URL(s).
  Late-registering fetch-async comps are unaffected: no script failure means
  the full timeout still applies, and a registration landing inside the
  grace window still wins (tested).
- Outcome telemetry: session.subTimelineWaitOutcome ("ready" | "timeout" |
  "script_failure") -> CapturePerfSummary -> RenderPerfSummary.subTimelineWait
  (worst across sessions) -> render_complete sub_timeline_wait, so the wild
  rate becomes directly trackable instead of setup-histogram forensics.

Validation: the discovery comp (0768f038, its animations.js unreachable)
drops from ~72s to 23.1s total — poll cut at 2.1s with the script named;
healthy comp reports "ready". Canary suite 7/7 (PSNRs identical). 4 new
poll unit tests; engine suite 907 passed (14 failures are PRE-EXISTING on
main at v0.7.42 — 18 fail on a clean checkout, stash A/B verified).
tsc/oxlint/oxfmt clean.

Corpus note: 258/1,762 corpus comps (14%) reference local scripts missing
from the corpus fetch — their historical eval INIT timings measured this
timeout, not the engine. Capture-stage ratios remain valid (both paths paid
it equally).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 16:09:47 -07:00
Vance Ingalls 230cc5bf7d Merge pull request #2083 from heygen-com/release/v0.7.43
chore: release v0.7.43
v0.7.43
2026-07-08 16:08:52 -07:00
Vance Ingalls f8f945d42e chore: release v0.7.43 2026-07-08 15:51:39 -07:00
Vance Ingalls a56616e586 Merge pull request #2082 from heygen-com/fix/drawelement-chrome-version-pin
fix(engine,cli): resolve drawElement to a Chrome build that actually has it
2026-07-08 15:50:17 -07:00
Vance Ingalls 5f9ee0b678 fix(cli,engine): close review gaps in Chrome resolution fix
Address PR #2082 review feedback:
- Route studio thumbnail + render call sites through preferManagedChrome
  so studio renders no longer silently fall back to whatever system
  Chrome happens to be installed.
- `hyperframes browser ensure` now resolves through the same
  preferManagedChrome path render uses, so it reports what render will
  actually pick instead of any system Chrome it happens to find.
- Point the unsupported-Chrome fallback log at `browser ensure --force`
  instead of `doctor`, which doesn't check Chrome/drawElement capability.
- Fix stale findFromCache comment: the HF pin is now a Dev-channel build
  that can be newer than a user's puppeteer-cache Stable install.
2026-07-08 15:25:59 -07:00
Vance Ingalls 8854bad8f9 fix(engine,cli): resolve drawElement to a Chrome build that actually has it
canvas.drawElementImage is an unlaunched Dev/Canary-only Blink feature
(~151+). The CLI's pinned CHROME_VERSION fallback was still 131.0.6778.85 —
a puppeteer 24→25.2.1 bump that pinned it to Chrome Dev 151.0.7912.0 was
written on 2026-06-29 but never merged (orphaned local commit, no PR). Any
render on that pin, or on the shared puppeteer-cache binary, or on system
Chrome (Stable, no drawElementImage at all) got a canvas.getContext("2d")
missing the method and crashed mid-capture with "ctx.drawElementImage is
not a function" instead of falling back (HF#2060).

Three changes:
- Bump puppeteer/puppeteer-core to ^25.2.1 across every package that
  depends on it, and CHROME_VERSION to 152.0.7928.2 (today's Dev channel;
  confirmed via direct probe to implement drawElementImage, unlike 131).
- `ensureBrowser({ preferManagedChrome: true })`, always used by `render`:
  resolve straight to our pinned/cached build, skipping both the shared
  puppeteer-cache preference and system Chrome. Rendering shouldn't depend
  on whatever arbitrary Chrome a machine happens to have — that's exactly
  how this regressed (any Mac with Chrome.app installed bypassed the CLI's
  pin entirely).
- A runtime capability probe in the engine, right before any other
  drawElement work: if `drawElementImage` isn't a function on the injected
  canvas, route to the existing screenshot-fallback gate instead of
  crashing. This is the real backstop — it protects every resolution path
  (env override, stale cache entry, a future Chrome regression), not just
  the ones `preferManagedChrome` reaches.

Verified end-to-end: rendering against chrome-headless-shell 131 (confirmed
to lack drawElementImage) now falls back cleanly and produces a valid MP4
instead of crashing; rendering against a capable build still engages
drawElement normally. 922 engine tests + 1373 CLI tests pass.

Fixes #2060.
2026-07-08 14:14:28 -07:00
Miguel Ángel 38b27c4d82 fix(cli): report unknown-flag errors + cover nested subcommands (HF#2033) [P2] (#2072)
* 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.
2026-07-08 16:24:36 -04:00
James RussoandClaude Opus 4.8 5ebc5bb10f fix(producer): scope per-instance variables for repeated sub-composition mounts (#2070)
#2066 fixed sub-composition data-variable-values on the render path for a single
mount, but the reusable-template pattern from #2064 (the same sub-comp mounted
multiple times with different values) still diverged from preview/snapshot:
every mount shared one __hfVariablesByComp key and one CSS scope selector, so
the last mount's values clobbered the earlier ones and all-but-one instance
rendered blank.

The producer now assigns per-instance runtime composition ids
(assignBundledRuntimeCompositionIds) and threads hostIdentityMap into the shared
inliner, mirroring the preview bundler. The shared inliner's default
buildScopeSelector already scopes by the runtime id, and timelines remap to it
via the scoping proxy, so each instance's variables, CSS, and timeline land
under its own id.

Pixel-verified end to end: two mounts of one sub-comp with different
data-variable-values now render their own content (green CARD_A / blue CARD_B),
matching snapshot; single-instance behavior is unchanged.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 13:15:53 -07:00
Miguel Ángel 701ae9e9b9 fix(cli): contrast audit reads an element's own opaque background (#1975)
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.
2026-07-08 15:53:21 -04:00
Miguel Ángel ab129023de docs(cli): fix render examples that pass a file as the project dir [P2] (#1974)
* docs(cli): fix render examples that pass a file as the project dir

The render command's positional argument is the project directory (default
"."), resolved via resolveProjectOrThrow; a specific composition file is
passed with -c/--composition. Several docs showed `hyperframes render
index.html` / `render ./my-composition.html`, which treats the HTML file as
the project dir and fails with "Not a directory". Correct the guide and the
cli README to render the project's index.html directly (or point at a file
with -c).

* docs: fix render index.html example in the Open Design guide too (R1)

R1 flagged that open-design-hyperframes.md carried the identical
`npx hyperframes render index.html` example this PR fixes in the Claude
guide — same failure vector ("Not a directory" for a file positional).
Corrected to `npx hyperframes render` run from the project directory.
2026-07-08 15:53:17 -04:00
Miguel Ángel e018318225 fix(cli): add --no-clipboard no longer throws "Unknown flag: --clipboard" (#2067)
The add command declared its flag literally as `"no-clipboard"`, but citty
treats `--no-<name>` as the negation of a boolean `<name>` arg. So
`--no-clipboard` parsed as negating a (nonexistent) `clipboard` arg and
assertKnownFlags threw "Unknown flag: --clipboard" — even though --help
advertised --no-clipboard as valid.

Declare the positive `clipboard` (boolean, default true) instead and read
`args.clipboard === false`; citty's built-in negation then handles
`--no-clipboard` correctly. --help still lists both spellings.

Verified: `hyperframes add data-chart --no-clipboard` now succeeds instead of
erroring on the flag.
2026-07-08 15:53:13 -04:00
Miguel Ángel f5f94a9495 fix(cli): warn when a WebM render silently drops its alpha channel [P2] (#2044)
* fix(cli): warn when a WebM render loses its requested alpha channel

HyperFrames always encodes WebM with an alpha-capable pixel format
(yuva420p), but some ffmpeg/libvpx builds silently emit opaque yuv420p
even when handed alpha input and -pix_fmt yuva420p. The render succeeds
and plays back fine, so the lost transparency is only discovered after
compositing (users report shipping a solid-black clip and colorkeying it
out by hand).

After a WebM render, best-effort ffprobe the output's pix_fmt; if it
lacks alpha, print a non-blocking warning that names the concrete remedy
(--format mov / ProRes 4444). Only WebM is checked (mp4 is intentionally
opaque; mov/png carry alpha through paths that don't hit libvpx-vp9), and
a failed probe stays silent rather than warning speculatively.

Pure decision (pixelFormatHasAlpha / webmAlphaAdvisory) unit-tested;
verified end-to-end that a transparent WebM render now surfaces the
warning while an MP4 render stays silent.

* fix(cli): key WebM alpha check on ALPHA_MODE tag, not pix_fmt (R1 blocker)

R1 (Rames/Via) correctly flagged the detection as ~100% false-positive on
working builds. libvpx-vp9 stores the alpha plane in a Matroska
BlockAdditional sidecar, so ffprobe ALWAYS reports pix_fmt=yuv420p for a
correct transparent WebM (per docs/guides/rendering.mdx #1823 and the
webm-concat-copy smoke test). The real signal is the stream-level
ALPHA_MODE=1 tag: a working encode writes it; a build that can't emit the
sidecar omits it and produces genuinely opaque output.

Re-cut the probe to read stream_tags=alpha_mode (JSON, case-insensitive) and
warn only when a probed WebM lacks ALPHA_MODE=1. Tests inverted accordingly
(alphaMode:true → silent; alphaMode:false → warn). Verified end-to-end: a
transparent webm render on an alpha-preserving build (ALPHA_MODE=1) now emits
0 warnings; previously it warned on every webm.
2026-07-08 15:53:08 -04:00
Miguel Ángel 6f8bf5f364 fix(engine): resolve relative data-start references for audio tracks (#2062)
parseAudioElements read data-start with a bare parseFloat, so a relative
reference (data-start="introClip", the documented 'start when that clip
ends' pattern) resolved to NaN. The mixer then silently dropped the track,
rendering the whole segment as pure digital silence — even though the SAME
reference on the sibling <video> placed the visual correctly (#2030 taught
parseVideoElements/parseImageElements to resolve refs; audio never learned).

Root fix, single source of truth: extract the Node-side reference resolver
out of videoFrameExtractor into referenceResolver.ts and use it in
parseAudioElements for both <audio> and <video data-has-audio> tracks. Now
every media parser resolves relative timing identically, so audio and video
cannot drift again. The two near-identical parse loops share one builder;
end stays a numeric read (mixer derives real length downstream), NaN-guarded.

Verified end-to-end: a composition with <audio data-start="clipId"> now
renders an audio stream that is silent before the referenced clip ends and
audible after (matches the numeric-start control); previously the output had
no audio stream at all. 78 engine media tests pass (4 new).
2026-07-08 15:53:04 -04:00
James RussoandClaude Opus 4.8 bb423dd217 docs: add modal deployment template to deploy guide (#2069)
Add Modal as a third official deployment template alongside Vercel and
Cloudflare: comparison-table row, a Modal tab (deploy commands, what-you-get,
performance, pricing, async spawned-function note), architecture/pre-baking
updates, swap-the-composition steps, and a source card. Update the cloud.mdx
cross-reference to include Modal.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:01:38 -07:00
Miguel Ángel 41ad5b4690 fix(engine): inject sub-composition variables on the render path (#2066)
render left window.__hyperframes.getVariables() empty inside every
sub-composition mounted via data-composition-src, so each instance rendered
its declared JS defaults instead of the per-instance data-variable-values.
preview/snapshot injected them correctly, so the composition looked right in
every authoring/QA surface and then rendered wrong content silently (exit 0).
Any template-library workflow (reusable sub-comp scenes parametrized per
video) shipped placeholder/default text in the final MP4.

The plumbing already existed on main: htmlCompiler passes
readVariableDefaults/parseHostVariables and populates result.variablesByComp,
and the CSS-custom-property path (emitRootCompositionVariableStyles) reaches
the render. But the render compiler emitted only the CSS vars and never the
JS table window.__hfVariablesByComp that the scoped getVariables reads, while
the preview bundler (htmlBundler) did -- so getVariables() returned {} only
during render.

Fix, so the paths cannot drift again: buildVariablesByCompScript, colocated
with the reader in compositionScoping.ts and shared by both compile paths.
htmlBundler now calls it instead of an inline string; htmlCompiler injects it
before the inlined sub-comp scripts, using the already-populated
result.variablesByComp.

Verified end-to-end: a sub-comp painting its background from a color variable
now renders the injected value under render, matching snapshot; previously it
rendered the default. 3 new producer tests; 89 htmlCompiler + core-compiler
tests pass.

Closes #2064.
2026-07-08 14:23:40 -04:00
WaterrrForever 17b852784b feat(skills): mode-first briefs, value-first storyboards, and destination defaults across creation workflows (#2058)
* feat(skills): add brief contract — interaction modes + shared intake fields across workflows

New hyperframes-core/references/brief-contract.md, the shared intake
contract every creation workflow now runs its brief against:

- §1 interaction mode: collaborative (default) vs autonomous, ongoing
  vs one-time signals, mode set once and carried forward, and a gate
  taxonomy (preference / checkpoint / quality / routing) — autonomous
  skips waiting, never verification
- §2 field registry: destination→aspect derivation (feed → 1:1,
  Shorts/TikTok → 9:16, else 16:9), message, angle, length, audience,
  language, narration — each workflow binds fields as ask or state
- §3 question rules: one round with one question per asked field
  (native question UI mandatory when available, recommended option
  first with a receipt), never drop a question as inferable, and a
  mode legend advertised in the intro text instead of asked

Wired into the surfaces:

- hyperframes router: detect mode at entry, derive aspect from
  destination instead of stating 16:9
- product-launch-video / pr-to-video / faceless-explainer: ask/state
  binding tables at Step 0; Step 3/6 checkpoint-gate branches
  (autonomous posts a heads-up with a preview hint before render)
- website-to-video: local mode definition now defers to the contract
- music-to-video, general-video, embedded-captions, talking-head-recut,
  slideshow, motion-graphics: mode semantics wired per gate type
- storyboard-format: new optional 'mode' frontmatter key
- pr-to-video: length tier is a ceiling, not a floor — a one-headline
  PR recommends inside the 30–90s sweet spot regardless of diff size

* feat(skills): story spine + mode-first brief across creation workflows

Story — the reverse-iceberg feedback:

- New hyperframes-creative/references/story-spine.md, three rules for
  the narrated workflows: the hook speaks the viewer's outcome
  language, the value claim lands by beat 2 (implementation is the
  footnote of the story, not the spine), and the storyboard is
  presented as a proposal — 'This video tells [audience] that
  [message]' plus a per-frame why: drawn from narrativeRole
- pr-to-video: feature-reveal reordered promise-first (impact leads,
  diff/mechanism follow as evidence); hooks ban file/function names;
  fix-explainer, refactor-walkthrough, changelog unchanged
- product-launch-video / faceless-explainer hook rules aligned to the
  spine; website-to-video's beat summary gains the echo line + why:;
  general-video points at the spine from its plan step

Brief — hardened after live-test drift:

- Mode is now the first question (Collaborative recommended vs
  Autonomous), its own round, skipped when the request carries a
  signal; autonomous asks nothing further until one final
  preview-or-render question before render
- Step 0 rewritten as a literal two-round question script in each
  shot-sequence workflow (website-to-video's editorial register,
  channel-agnostic); brief-contract.md §3 reduced to invariants so the
  procedure lives in exactly one place

* feat(skills): split type minimums by viewing context

typography.md: full-screen viewing keeps body 20px / headline 60px;
in-feed destinations (X / LinkedIn / Instagram — brief-contract's
destination field) scale to body >=32px, headline >=90px, data labels
>=24px. First-pass values, to be calibrated against real renders.

* feat(skills): storyboard proposal as a table + credits close by default

- story-spine § 3: the proposal presents frames as a markdown table
  (frame · beat · on screen · why) instead of dense paragraphs; the
  three shot-sequence workflows and website-to-video's beat summary
  reference the same shape
- pr-to-video: the credits close is now the default ending — every PR
  video ends on a contributors frame (committers by commit count, 1-6
  avatars), with no taste judgment; the only skip is when no avatar
  was fetched, and the user can cut the frame in the proposal

* fix(skills): address review nits on the brief/story contracts

- embedded-captions: the identity procedure now states both sides of the
  preference gate inline (user picks; autonomous picks with a stated why)
- website-to-video step-2-brief: note that its mode section is the
  workflow's application of brief-contract.md, not a second definition
- brief-contract: resuming a project reads mode from STORYBOARD.md
  frontmatter — a recorded mode counts as set, closing the write-only gap

* docs(skills): add a non-code receipts example to the brief contract

Review nit (jrusso1020, #2058): the receipts example in § 3 was
PR-video-shaped only. A destination-shaped example joins it so the rule
reads as workflow-neutral.
2026-07-09 01:58:51 +08:00
81884a7495 fix(cli,skills): install workflow skills on demand instead of re-pulling the full set (#2012)
* fix(cli,skills): install workflow skills on demand instead of re-pulling the full set

Users report every init re-pulls all 21 skills into ~/.agents/skills
whenever anything is stale or missing - heavy, noisy, and it re-expands
deliberate partial installs.

Split the set into two tiers:

- core: the /hyperframes router + hyperframes-* domain skills +
  media-use, which every workflow references structurally. init and
  bare 'skills update' keep these (plus anything already installed)
  fresh, and never expand the install.
- on demand: the end-user workflow skills (and figma). They install at
  trigger time via 'skills update <name...>' - positional names are
  the only way update expands an install: one targeted
  'skills add --skill <name>' covering only stale/missing targets, a
  fast no-op when current, presence-verified after install, exit 1 on
  unknown names, and a presence-only degrade when GitHub is
  unreachable.

The /hyperframes router now runs 'skills update <workflow>' after
routing and before reading the workflow skill, so a routed workflow is
guaranteed present even on a machine that only has the core set. Each
on-demand skill also opens with the same self-maintenance step (run
'npx hyperframes skills update <name>' silently), so a workflow
triggered directly - without the router - still refreshes itself and
restores any missing core skill before relying on it.

When the manifest is unreachable (offline / rate-limited) the engine
degrades honestly instead of claiming success: named runs presence-check
the request plus a pinned fallback core list (unit-pinned to skills/)
and blind-install whatever is absent; a bare strict update fails loudly
so the 'check || update' chain can't pass while everything stays stale;
init reports the skipped freshness check. --json emits structured
errors on failure paths.

skills check still lists every skill, but exits non-zero only for
stale installed skills, an incomplete core set, or removed leftovers -
workflow skills not yet installed are reported as available on demand.
Bare 'hyperframes skills' (and 'skills add --all') remain the explicit
full-set installs.

Verified end-to-end with a sandboxed $HOME: fresh init installs the 9
core skills only; 'skills update slideshow' adds exactly that skill
(no-op on re-run, exit 1 on unknown names); bare update refreshes
without expanding; a live Claude Code run routed PR-to-video, executed
the router's update step, and the workflow skill appeared before use;
and a second live run triggered an installed workflow directly, whose
opening maintenance step restored a deliberately removed core skill.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(skills): clarify update-engine contracts + document lazy-install model

- skills.ts: note the UpdateSkillsResult.unknown strict-mode contract,
  verifyInstalled's non-strict (warn-not-throw) intent, and that a
  partial install stays "refreshed but never expanded" (review nits).
- docs/guides/skills.mdx: add a "Keeping skills current" section covering
  the core-eager / workflow-on-demand model and the skills check|update
  commands, per the repo's catalog-maintenance rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: kiritowoo <295860553+kiritowoo@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Miao Yang <miao.yang@heygen.com>
2026-07-09 01:30:51 +08:00
Vance Ingalls e52bfc246a Merge pull request #2053 from heygen-com/vi/figma-loop-fixes
fix(core,cli,lint): close the figma brand-token loop — runtime CSS variables, --name, snippet lint
2026-07-08 09:27:19 -07:00
WaterrrForever 4d3cdc3e4b feat(media-use): resolve official brand logos via a four-tier cascade (#2061)
* feat(media-use): resolve official brand logos via a four-tier cascade

Third-party brand logos (the meeting's 'credibility signals lost' gap)
had no acquisition path: capture only grabs the product's own site
assets, and HeyGen asset search returns generic look-alike icons for
brand queries (0/3 in testing — an X-in-a-circle for LinkedIn). Workers
could only fake a mark or drop it.

New resolve type 'logo', four tiers verified by a 54-brand stress test
(100% cascade hit across dev tools / big tech / non-tech / CN brands):

- svgl — official full-color vector SVGs + wordmark variants (40/54
  first-hits); search is substring-based, so entities pass through
  alias normalization (nextjs → 'next.js', aws → 'amazon web services')
- simple-icons (pinned CDN build) — official monochrome glyphs; catches
  the long tail (nike, visa, toyota, wechat, bytedance)
- github org avatar — known-org map only; a brand name is not a GitHub
  login, guessing risks same-named personal accounts
- domain favicon (DuckDuckGo ip3) — small-raster last resort; sub-500B
  responses are DDG's placeholder and rejected; frozen with a low_res
  provenance flag (chip-size use only)

logo joins the icon/image equivalence group (typesMatch) and the
images/ subdir, so entity cache hits interop with figma-imported marks.
A total miss falls through resolve's normal failure path — no special
casing. HeyGen search stays the icon provider; it is deliberately
absent from the logo cascade.

Docs: media-use gap/types/providers tables + example; the five
workflow banners now cover logos (catalog claim kept for media, 'from
their official sources' added for logos); product-launch story-design
and motion-graphics logo-reveal point at the new type; catalog
surfaces (CLAUDE.md / README / docs) updated in lockstep.

Verified: 19 unit tests + coverage row green; live smoke across all
four tiers (linkedin→svgl, nike→simple-icons, heygen→github.avatar,
amazon→favicon) plus a fabricated brand exiting 1 on the default miss
path. oxlint + oxfmt clean.

* test(media-use): sanction the four logo providers in the registry allowlist

svgl / simple-icons / github.avatar / favicon.ddg join the sanctioned
list — the logo cascade added in the previous commit. Full lib suite
95/95 green.

* test(media-use): gate the logo cascade behavior in CI + single-fetch favicon tier

Review follow-ups (miga-heygen, jrusso1020 on #2061):

- Eight mocked-network tests pin what the manual 54-brand stress test
  only asserted: descriptor shape, alias retry (svgl non-array payload
  → next query, simple-icons 404 → next slug), network-error → null
  fallthrough, the sub-500B placeholder rejection, github's
  no-guessing (zero fetches for unmapped entities), and the real
  cascade order landing tier by tier under a mocked network.
- faviconSearch now hands its verified bytes over as a local file, so
  the freeze step copies instead of re-downloading — one round-trip,
  and the size check is authoritative over what gets frozen.
- The header's hit counts are labeled as a stress-test snapshot, not a
  live invariant.

Full lib suite 103/103; live smoke re-verified (amazon → favicon.ddg,
frozen .ico).
2026-07-08 23:58:41 +08:00