* fix(core): color grading applies to canvas compositions
data-color-grading on a <canvas> was ignored by the runtime and refused by
lint, the Studio panel and the CLI writer. One source-tag list in the grading
contract now feeds all of them. A canvas is sized from its drawing buffer and
re-uploaded on every seek and transport tick.
* fix(core): keep the comment share of grading files at the base
duplicate_audio_track compared raw float ends, so 21.03 + 0.1 (21.130000000000003)
overlapped a clip starting at 21.13. The one-instant span helpers move from core's
clipFacts to parsers, which lint and core both depend on; core re-exports them and
the rule uses spansOverlap.
projectShaders reports real paths, and macOS's temp folder is /var, a link
to /private/var, so the tests' expected paths never matched there. The
fixture root is now the real path.
* fix(cli): timeline lists clips nested in a timed element and lint flags ones that never show
* docs(lint): wrap the nested-clip entries
* docs(skills): format the nested-clip note and refresh the skills manifest
* fix(cli): keep timeline edits from colliding with a clip's own wrapper
* fix(cli): cut clips nested in a wrapper at their sub-composition's in-point
* refactor(lint): split the nested-clip visibility checks to keep the audit green
* style(cli): restore the timeline test's formatting
* fix(inspector): report nested hosts' authored window, one location per source line
The runtime strips data-duration/data-end from nested composition hosts after
boot and keeps them as data-hf-authored-*. Timeline inspection built its
resolver without authored attrs, so a host with data-duration="4" reported
its GSAP timeline length (3.72s) instead.
Timeline text and events[].sources listed the same file:line once per mapped
snippet (creation, caller, dependency), so a one-line script printed one
location up to nine times.
* refactor(inspector): carry the dedupe reason in the helper's name
* fix(inspector): end continuation on a page that fits no snippet; fill budget with candidates
* fix(inspector): timeline groups by component, names attr values, eases, film end
* fix(studio-server): stamp hf ids into a file without reformatting it
* fix(inspector): find an element's tag before its served hf id reaches the source
* fix(inspector): live element map shows authored classes and text, not the playhead's
* fix(inspector): collapse script-built text so the element map ignores the playhead
Class names stay in source order, a runtime-stripped id is shown as id="…"
rather than a selector, and a grouped row names which children it covers.
* fix(inspector): keep paging, stamping, and the live map still on the reported edges
A snippet larger than the page ended the walk, so later snippets could not
be reached. Raw text ended at a longer tag name, which could stamp an id
inside a script. An empty or mixed source element showed live text, and an
unknown duration read as a film that ends at 0s.
* fix(inspector): mint ids for an empty hf id and keep an unquoted class
An empty data-hf-id was treated as already assigned, so stamping left it
blank while the older stamper minted one. An unquoted class was read as no
class, so the live map dropped it.
* fix(inspector): narrow markup before reading a child tag
skipMarkup returns a boolean, so the build still sees Markup where a tag
is required. Narrow on the kind first so the inspector typechecks.
* fix: quiet check false positives and name the ancestor of an id-less target
Endpoint contact, disjoint fromTo pairs, and a negative z-index under a
stacking-context parent were warnings with bad advice. Layout and contrast
blamed a dot grid, a circular avatar's panel, and a visibility override.
A faint texture veil is no longer the click target.
* fix(lint): drop a history sentence and six gsap comments
The comments check treats "load-bearing" as history, and gsap.ts added six
comment lines. The names already carry what those lines said.
* fix(parsers): ignore an empty id written inside another attribute
The empty-id scan matched the first data-hf-id="" in the tag, including one
inside a quoted title. It now skips quoted text, so the real attribute is minted.
* fix(lint): keep real covers and a negative z-index under a card
A gradient scale-down treated every fill like a radial dot, so a panel
that starts transparent stopped counting as a cover. A stacking context
on any ancestor cleared a negative z-index, and a faint wrapper made
images inside it unpickable.
* fix: keep an emoji from shifting a stamped id
The quote mask replaced one emoji with one space, so the id landed before
the closing quote. A radial cover that turns opaque after a clear stop
stays fully painted.
* fix(lint): score a letter-only dot as a dot, not a color name
namesAColor saw #fff and rgba as words, so those dot grids counted as a
full cover. Hex and rgb colors are stripped before that word check.
* fix(keyframes): report unresolved GSAP timing instead of guessing rest poses
* fix(keyframes): drop later times when a delay cannot be resolved
Boolean(null) treated an unreadable delay as no delay,
so the next cursor-relative tween kept a guessed start.
* fix(keyframes): keep a staggered rest pose in Studio
The parser stops are what Studio holds, so a staggered from keeps its rest
pose there. The CLI reports that pose as unresolved, and an unread position
no longer seeds the next tween at zero.
A sub-composition that links a shared stylesheet with a plain path like
assets/fonts.css was only looked up next to its own HTML file, so lint
reported font_family_without_font_face for fonts that stylesheet declares.
The ../ spelling is rejected by invalid_parent_traversal_in_asset_path, so
no spelling passed.
Lint still looks next to the sub-composition first and now falls back to
the project root, which is where the preview and the bundler load the link
from. Links that start with / or contain a .. segment keep their old lookup.
A folder with the stylesheet's name is skipped instead of throwing EISDIR.
Fixes#5074
* fix(lint): a double-quoted media src with an apostrophe is read whole, not reported missing
* fix(lint): every media src read goes through the attribute parser, decoded
* refactor(lint): the src pattern only finds the tag; the attribute parser reads the value
* fix(lint): a media src whose name starts with a quote character is checked too
* fix(lint): the missing-audio check skips audio tags that scripts build as strings
* docs(lint): mediaSrcTagRe's comment states the src rule without the history
* fix(studio): text in Studio reads without em or en dashes
* fix(studio): the dash check also catches HTML entities, and peak reads Unavailable
* test(studio): the dash check test covers every entity form
* fix(studio): messages Studio shows from lint, sdk, core and the renderer drop dashes too
* fix(producer): the gate check stops early on the engine's new fallback wording
* fix(lint): check passes valid sub-compositions and scene-relative asset paths
* fix(lint): keep the browser lint build free of node modules
* refactor(lint): tidy sub-composition lint helpers and guard bare node imports
* fix(lint): resolve stylesheet asset paths from the stylesheet's own folder
* fix(lint): catch every written form of a path that climbs out of the project
* fix(parsers): sub-composition asset paths written with backslashes load in preview
* fix(parsers): an encoded backslash stays in the file name and a leading backslash stays absolute
* fix(lint): an encoded %2f counts as a separator when checking for a climb out of the project
* fix(core): namespace SVG ids in runtime-mounted compositions
* fix(core): keep SVG selectors and captured ids working after loaded scenes rename them
* chore(core): generated artifacts carry a plain do-not-edit banner
* test(catalog): record the font subsets the new scene-script installer requests
* test(catalog): keep earlier mirror recordings, as re-records only add
* fix(lint): flag digit-leading ids used by toArray and ScrollTrigger selectors
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
* refactor(lint): drop the selector sink comment
* fix(lint): ignore hyphenated and $-prefixed keys that end in pin or trigger
* fix(lint): match scroller, pinnedContainer and pinSpacer selector keys
* fix(lint): flag seek-unsafe CSS transitions
* fix(lint): allow zero-duration transition resets in css_transition_used
The registry's reduced-motion and render-lock resets use transition-duration: 0s and
transition-delay: 0s, which never run on the browser clock. Also drops the one real
seek-unsafe transition the rule finds in the product-promo example.
* refactor(lint): split the zero-time check out of isSeekUnsafeTransition
* fix(lint): report seek-unsafe CSS transitions as a warning
---------
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
When one declaration (e.g. transform: scale(1.08) translate3d(1.5%, 0, 0))
matches both the translate and scale selector maps, cssFromTranslate and
cssFromScale end up equal, and join(" ") concatenated the identical string
with itself in both the finding message and fixHint.
Claude-Session: https://claude.ai/code/session_01LQXJUdfkq6CGWh6KNfVXyw
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
* fix(lint): block on digit-leading ids instead of only warning
A digit-leading id (e.g. id="1-hook") breaks any bare `#1-hook` CSS
selector with a SyntaxError, including inline GSAP string selectors
like tl.fromTo("#1-hook", ...). That throw silently aborts the rest of
the <script> block that built the selector, so the referenced element
never animates — but the render itself still reports success, and the
freeze has no visible error anywhere.
id_requires_css_escape already detects this correctly; it was only
"warning" severity, which hyperframes render and hyperframes check
both treat as non-blocking by default. Promote it to "error" so
`hyperframes check` (which always treats errors as blocking) catches
this unconditionally instead of only under --strict.
Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
* refactor(lint): drop the digit-leading id severity comments
* fix(lint): error on digit-leading ids only when a selector targets them
* fix(lint): say a CSS-only digit selector drops the rule instead of throwing
---------
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
* fix(lint): flag an id override written with reduced-specificity selector forms
An attribute selector on id (`[id="x"]`, class-level specificity) or a
`:where(#x)`-wrapped id selector (spec-defined zero specificity) genuinely
loses to a compound class selector like `.parent .row` under standard CSS
cascade rules — unlike a bare `#id`, which always wins regardless of how the
competing class rule is written. When either form is used to override a
position property (left/top/right/bottom), the element can silently render
at the class-default position instead of its authored one, with lint,
validate, inspect, and layout all reporting clean (a real, resolved position
exists, it's just the wrong one).
No existing check performs any CSS rule-specificity analysis — confirmed at
the source level (dist/cli.js has zero "specificity" occurrences, and
createInspectCommand reports position via getBoundingClientRect() only).
Adds `id_override_reduced_specificity`, a new core lint rule flagging this
authoring pattern specifically (not general cascade-conflict detection,
which would require full rule-graph modeling well beyond this P3's scope).
Extracted `rightmostCompoundNodes()` out of `selectorAliasesRuntimeHiddenStyle`
so the new rule's selector-subject walk doesn't duplicate it.
* fix(lint): share the selector-safety parse and skip compounds that keep a bare id
* style(lint): wrap the id attribute check for oxfmt
* refactor(lint): move the reduced-specificity id check into its own helper
* fix(lint): hint an escaped #id and skip prefix and !important id overrides
* style(lint): wrap the prefix-selector test for oxfmt
* refactor(lint): split the weak-id node check out of the selector walk
---------
Co-authored-by: Miguel Angel Simon Sierra <miguel.sierra@heygen.com>
* feat(studio): order the clip menu into time, sound, picture and delete groups
Split and Freeze frame; Normalize, Voice, link items and Duck; Look and Crop;
then Delete beside Delete this clip only. Each item file renders its pieces
separately, empty groups collapse, and the viewport clamp uses the measured
menu height instead of a row-count estimate.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio-server): mint freeze-frame right-half links with the shared lk-N helper
Freeze frame now calls core relinkSplitHalves, so split halves and freeze
halves share one link-id convention instead of L -> L-2.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): apply speed edits to every data-link member in one undo step
The inspector speed slider, rate lane edits and ramp presets on a linked clip
now commit once, writing the selection and, via expandToLinkedMembers, each
partner from its own current value. Unlinked clips keep their usual path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(studio): drop the audio parent tick in favour of the data-link chain badge
A linked clip now shows one link indicator: the chain badge driven by
data-link. The waveform no longer draws its 1px parent tick.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): follow late clip-menu rows and target link partners like S2
The menu height follows a ResizeObserver so rows that read the live preview
after mount still clamp inside the viewport. Speed fan-out builds partner
targets with buildPatchTarget, matching the link edit plan.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): show the sound strip and audio menu items without studio's shell
The audible-video waveform strip drops AudioWaveform's 16px label band, which
left a 0px canvas on short lanes. Normalize loudness and Duck under voice fall
back to the timeline session's project, the live preview iframe store and a new
onNotice edit callback when no StudioShellProvider is mounted.
usePreviewIframeStore is exported so a host can publish its preview.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): show duck under voice only when a voice overlaps
The item now appears only when an overlapping voice exists or the bed is
already ducked. Its check mark moves to the right edge so unchecked rows
align with siblings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): name the partner in the clip menu link and unlink items
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): clip menu stops offering unlink once the clips are unlinked
The timeline skipped store updates when only data-link changed,
so the menu kept reading the stale link.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(core): sync origin, offset math and move/slip into sync for linked pairs
data-sync-origin survives Unlink; split gives right halves a fresh origin.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio-server): split re-mints the sync origin of unlinked pairs too
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): detach, link and keep-sound cutout write a sync origin that unlink keeps
TimelineElement.syncOrigin is read on both parse paths; merge back drops it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(studio): expect sync-origin on keep-sound cutout write
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): linked selection toggle in the timeline toolbar, on by default
Off: a click selects one clip; drag, trim, razor, speed and delete stay on it
(delete unlinks the survivor). Alt-click still selects one half when on.
Right-click holds the out-of-sync indicator preference. Both persist in UI prefs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): link groups share a label colour; out-of-sync badges with move and slip
Red badge on both halves of a sync-origin pair that drifted (frames, s:ff past
a second); hidden when rates differ or the preference is off. The whole badge
opens Move / Slip into Sync; each edits that clip alone in one undo step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): take link label colours from theme tokens
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(sdk): syncOffset, moveIntoSync and slipIntoSync for sync-origin pairs
Core gains findSyncPartner, the DOM pairing shared by the SDK and the CLI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(cli): timeline rows report syncOffsetFrames and print out-of-sync
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(cli): the timeline row fixture carries syncOffsetFrames
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(skills): data-sync-origin in hyperframes-core
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(core,sdk): keep a one-sided cut's sync origin and slip the in-point playback reads
relinkSplitHalves reminted the right half's sync origin even when only one
member of an unlinked source pair was cut, so the uncut partner kept the old
origin and the new half lost its badge. The origin is now reminted only when
both the video and the audio were cut.
Slip into Sync wrote data-media-start whenever it existed, but playback
reads data-playback-start first, so a clip carrying both never moved. The
link timing reader now uses playback's precedence and the slip writes the
attribute that wins; lint compares the same field. A sync partner is also
looked up only in the clip's own composition (core) or source file (Studio).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): flip the out-of-sync menu above the pointer near the bottom edge
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor: trim comments the sync-origin layer added
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(core): split the scoped candidate lookup out of findSyncPartner
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(core,studio): find sync partners inside the link scope
findSyncPartner and Studio's syncPartnerOf now scope by the same rule as
link groups: the nearest data-composition-id or data-composition-file
ancestor, plus the row's compositionScope in Studio. An origin reused in a
file host or an inline composition no longer pairs across it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(core): add data-link media link helpers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(lint): warn on out-of-sync and orphaned linked clips
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): select, drag and trim linked clips together
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): detach, merge, link and unlink source transforms
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): link-aware split, delete, alt-edit unlink and link edit actions
Split and delete fan out to link partners. Alt drag/trim edits one member
and unlinks it. onLinkEdit runs unlink, link, detach and merge as one undo
step each. Single-clip timing edits of a linked clip skip the SDK path so
its linked default cannot move the partner behind the store.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio-server): give split right halves of a linked pair their own link
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): clip menu detach, unlink, link, merge back and delete-one items
Items live in clipMenuLinkItems.tsx with a one-line hook in ClipContextMenu;
the same resolver drives the Cmd+L, Opt+Shift+D and Opt+Delete shortcuts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(sdk): setTiming applies to data-link partners by default
{ linked: false } edits one member and unlinks it, matching Studio's Alt-edit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(skills): document data-link linked clips in hyperframes-core
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* chore(studio): keep comment share at or below base in link plumbing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(studio): audio pill link follows data-link, not a shared file name
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): alt-click selects one linked member even inside the pair selection
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(lint): drop the rule-list comment for the linked clip rules
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(sdk,lint): keep a link group inside its own composition
Link ids are minted per file, so lk-1 is normal in the root and in a child
composition. The SDK's linked-timing query descended into nested hosts and
addressed every match with the initiating scope, so moving a root clip moved
the child's pair too; lint grouped them into one drifting group. Both now
group only members of the same composition scope.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): match linked media by full asset path, scope links, keep hidden sound silent
Merge and Link compared only a lowercased basename, so assets/one/talk.mp4
and assets/two/talk.mp4 enabled a destructive Merge. Clips now compare the
src resolved against each clip's own source file, and Merge re-checks the
two srcs in the source before it runs. Link membership in the timeline
(linked members, gesture expansion, unlink orphans) is scoped to a source
file. Detach and the keep-sound cutout carry a hidden video's data-hidden
to its audio, and Merge refuses when only one member is hidden.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(studio): move the timeline move-edits handler into its adapter
App.tsx sat at 600 lines on this layer and 602 once merged with main.
useTimelineMoveEditsHandler now lives beside persistTimelineMoveEditsAtomically,
same callback and dependencies, bringing App.tsx to 592.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(sdk,studio,lint): keep link groups inside inline compositions
A link group now stays inside the nearest data-composition-id or
data-composition-file ancestor. The SDK used to recognise only file hosts,
and Studio compared only sourceFile, so an inline child composition that
reused a link id was edited together with the root group. core/media-link
linkScopeOf is the shared rule for the SDK and Studio. Lint cannot import
core, so it uses the same two attributes directly. Studio rows record their
compositionScope, and link and merge refuse pairs that cross it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(sdk): refuse a link edit whose partner address names another clip
A partner addressed in the caller's scope can resolve to a different element
when an inline composition reuses a root clip's data-hf-id. setTiming now
checks that each partner's address resolves back to that partner and throws
before mutating when it does not, instead of editing the root clip.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): a remote media url keeps its query when matching an asset
Two different videos at one remote path with different queries were treated as
the same file, so Merge could delete the audio and use the wrong video's
sound. Project-local srcs still drop query and hash.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix(studio): keep a remote url's query exactly as written when matching assets
Decoding the whole url folded %26 into & so two different media urls matched.
Decode only the path; the query is compared as authored.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* fix(studio): compare a remote url as serialized, decode only local paths
A remote pathname with an encoded reserved character (%3F) matched the same
path with a literal ?, though they can return different media. Remote urls
keep origin, path and query exactly; local srcs are still decoded and
stripped of query and hash.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(core): add isAudibleVideoElement predicate and audible media selector
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(runtime): route processed audible videos through web audio in preview
An audible <video> joins the Web Audio schedule, hidden-toggle and
group-capture passes when it needs a gain el.volume cannot express: a
boost above unity, an fx chain, automation, or an audio group. A plain
audible video at unity stays on native output. The clock master stays
audio-only; a video whose capture fails stays on native output (never
decoded, never failed closed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(audio): let audible videos join audio groups in preview and render
resolveAudioGroups, audioGroupOf, group render ids, the engine mixer and
the audio_group_no_members lint rule accept an audible <video>. A hidden
group drops its video members from the mix; muted videos stay inert.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(editing): offer the audio fx section on audible videos
EditableElementFacts gains hasAudio; studio and sdk derive it from muted and data-has-audio.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): show audio meters when a project has an audible video
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): offer audible videos as carve voices
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(studio): let audible video clips join audio groups
Drops the video refusal; grouping and group audition count audible videos.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(engine): honor hidden audio groups for auto-detected video audio
Muted videos that declare data-has-audio are no longer mixed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): keep authored src, loop and honest status when keeping cutout sound
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(core): treat only data-has-audio=true as audible in runtime and lint
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(core): trim the web audio route and group-key comments
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(runtime): capture ramped clips so a group's bus carries them in preview
A rate lane skipped Web Audio entirely, so a ramped video or audio clip in a
group played native output without the group's fader, mute or FX while the
export applied them. Only the decoded-buffer fallback cannot follow a rate
curve; the media-element capture can, so the skip now guards that fallback.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): show meters only for videos Web Audio carries
A plain audible video at unity stays on native output, which the meters
cannot read, so it no longer advertises them. The runtime's join rule and the
meter gate now share audibleVideoNeedsWebAudio (core/audible-video).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(cli): one owner for init media placeholders, audio stays on the video
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(cli): init keeps a video's sound on the clip; silent files scaffold muted
Supersedes the patchVideoSrc changes in #3977 and #3942 and carries their tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: miga-heygen <miga-heygen@users.noreply.github.com>
Co-Authored-By: kapelame <kapelame@users.noreply.github.com>
* feat(registry): example templates keep the a-roll's sound on the video
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(lint): video audio rules recommend keeping sound on the video
Update video_missing_muted and video_audio_double_source message and
fixHint text to recommend the new default: keep audio on the video with
data-has-audio="true" (not muted). Tests verify the new wording is in place.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(parsers): a video is either audible (data-has-audio) or muted, never both
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): follow the compiler's audio rule in the audio-track toggle
Confirm before background removal drops a video's sound.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(skills): hyperframes-core teaches sound on the video by default
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(skills): workflows and scaffold guidance keep a video's sound on the clip
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs: footage with sound keeps it on the video clip
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(cli): init from-file a-roll is timed, muted strip is attribute-exact
The from-file a-roll video gets data-start="0" so a scaffold lints clean, and
the orphaned audio-only slot comment is dropped. setVideoAudio now walks the
tag attribute by attribute, so a class value containing the word muted
survives. The ffprobe-missing warning says the video is assumed to have sound
and how to switch to muted, and DEFAULT_META notes the failure surfaces at
render until lint support lands.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): keep has-audio in step with the muted toggle, test cutout confirm
Muting a video now clears has-audio and unmuting sets it to true, in both the
Design and flat media panels, matching the Has audio track toggle. Audio
elements are unchanged. Adds coverage for both panels plus the cutout
window.confirm guard: declined leaves src alone, accepted swaps src and mutes,
a silent video never prompts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(parsers): updateElementInHtml hasAudio only applies to videos, true or false
An audio element or an undefined hasAudio no longer picks up a muted attribute;
only an explicit false on a video writes muted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs: video_missing_muted rule text, silent-footage notes, consistent audible examples
lint.mdx and cli.mdx describe the current video_missing_muted rule instead of
the retired unmuted-video wording. The editing recipes say how to adapt for
silent footage and require the outgoing clip's sound to end at a J-cut. The
Remotion media example shows the audible mapping consistently, data-attributes
states that has-audio is required unless muted, and the hairline frame note no
longer claims the video is muted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(cli): encode init media filenames and probe --audio duration
Function replacers stop dollar-sign pattern expansion in filenames; src is percent-encoded.
--audio now passes the probed track length as the composition duration in both init paths.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(lint): audio-state-aware media rules and time-aware double-source check
video_missing_muted honors any explicit data-has-audio.
video_audio_double_source only considers audible videos and only fires
when the same-src clips overlap in time.
duplicate_audio_track now covers audible videos.
audio_file_without_element no longer claims silence when an audible video
carries the sound.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(skills): audio-stream check for remotion ports, followable J-cut recipe
Remotion Video maps to data-has-audio only when the file has an audio stream.
The J/L-cut recipe now shows both shots with muted pictures and separate audio.
embedded-captions removes the audio.mp3 scratch file it extracts.
skills-manifest.json regenerated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(lint): trim comments to satisfy the comment ratchet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): atomic muted edits, cutout keeps sound on a linked audio
Muted and Has audio track commit one batched write (one persist, one
undo entry) and remove data-has-audio instead of writing an empty
marker. Unmuting a video probes its src: audible -> true, silent ->
false, probe failed -> absent. Remove background on an audible video
inserts a linked sibling <audio> with the original sound in the same
write, replacing the drop-the-sound confirm.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(producer): skip hidden videos when auto-adding video audio
A video hidden in Studio is dropped by the mixer, so its src never reached
existingAudioSrcs and the render re-added it at full volume. The video parser
now records the shared hidden predicate and the auto-detect step skips hidden
videos.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(studio): keep useDomEditSession under the 600-line cap
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: miga-heygen <miga-heygen@users.noreply.github.com>
Co-authored-by: kapelame <kapelame@users.noreply.github.com>
* fix(producer): render names a data-composition-src that points at a folder instead of crashing
* fix(lint): name a data-composition-src that points at a folder instead of crashing
* fix(cli): compositions, keyframes and Studio insertion survive a folder composition source
* fix(lint): tell a folder composition source to point at the HTML file inside it
* fix: read a composition source through one file handle so its type check and read cannot race
* fix(parsers): report a named pipe as a composition source instead of blocking on it
* revert(parsers): open composition sources read-only again, which CodeQL reads as no file creation
* fix(parsers): open composition sources with a private mode, skip pipes, and treat bad paths as missing
* fix(lint): a missing image, audio or mask file names the composition that references it
* test(cli): a missing file is reported once per composition that references it
* fix(lint): Studio's lint panel and the CLI label a finding by the composition it names
* refactor(lint): drop a comment and unused first-entry lines from the grouping change
* fix(lint): a missing image, audio or mask file names the composition that references it
* test(cli): a missing file is reported once per composition that references it
* fix(sdk): read a composition's variables from its root when its html has none
* fix: composition metadata and lint also read variables declared on the root
* fix(sdk): keep root declarations on the root through undo and prefer them like the runtime
* fix(sdk): read variables from both <html> and the root, editing each where declared
Rebased onto main again now that #4413 has landed. No behaviour change.
`composition.ts` applied cleanly for the second time — the negative-z rule sits
at a different insertion point from both the zoom rule and the viewport-cap
rule. The collision is the test file only: all three describe blocks append at
the same position in the outer describe. Purely additive, so the resolution
keeps all three and edits none.
Verified against 9af8ec21, the head under review, diffed against ITS OWN parent
rather than the branch's original base:
79 lines this PR added to composition.ts -> 0 absent after the rebase
81 lines this PR added to composition.test.ts -> 0 absent after the rebase
every non-blank line of main -> 0 absent after the rebase
Both files parse with zero syntax errors and are oxfmt-clean.
The message under review is untouched: the mechanism stated as fact, the hiding
conditional on an opaque root background, and the transparent-root case named
explicitly.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rebased onto main now that #4412 has landed. No behaviour change.
The conflict was an append collision in both files: #4412's zoom rule and this
viewport-cap rule are appended at the same position in `compositionRules`, and
their test blocks at the same position in the outer describe. Both were purely
additive, so the resolution keeps both and changes neither.
Verified rather than asserted, against the approved head 75f9c993:
38 lines this PR added to composition.ts -> 0 absent after the rebase
35 lines this PR added to composition.test.ts -> 0 absent after the rebase
every non-blank line of main -> 0 absent after the rebase
So nothing of either side was dropped and nothing was edited; the rules only
moved. Both files parse with zero syntax errors and are oxfmt-clean.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(lint): flag a canvas-level CSS zoom that rescales a fixed frame
The capture frame is sized from the root's data-width/data-height, which are
layout pixels. CSS `zoom` on the canvas itself -- the composition root, or
html/body above it -- rescales the painted content inside that frame without
changing the frame, so the two disagree.
Measured at 0.8.71 on an 800x400 composition holding two 100x100 boxes at
left:100 and left:600, rendered to a PNG sequence with the boxes located by
colour:
root zoom:0.5 canvas stays 800x400; boxes paint at (50,25) and (300,25)
at 50x50 -- the composition occupies the top-left quarter
and the rest of every frame is dead space.
root zoom:2 canvas stays 800x400; the left:100 box paints at (200,100)
at 200x200, and the left:600 box renders ZERO PIXELS. It is
inside the declared composition and absent from the output,
and nothing in lint, check or the render log says so.
A `zoom` on a DESCENDANT is deliberately not flagged. Measured in the same
run: a child at left:100 with zoom:2 paints at (200,100) at 200x200, which is
exactly what CSS `zoom` specifies. It is honoured, not ignored, so flagging
every `zoom` would be flagging correct authoring.
The `(?<![\w-])` lookbehind on the declaration pattern is load-bearing:
`--zoom: 2` and `--panel-zoom: 0.5` are author variables, and a plain \b
boundary flags both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* style(lint): format to the repo's 100-column width
`oxfmt --check` is a CI gate and these lines exceeded the repo's 100-column
printWidth. Formatting only: verified whitespace- and trailing-comma-identical
to the previous revision, with the comparison shown to detect a real change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(lint): satisfy noUncheckedIndexedAccess in the zoom rule
Two strictness failures under the package's `noUncheckedIndexedAccess` +
`strict`, both mine, both breaking `@hyperframes/core`'s build (and every job
downstream of it):
- `hits[0]` is `T | undefined`, and a preceding `hits.length === 0` check does
not narrow it. Guard on the element instead of the length.
- The local `hits` type declared `snippet: string`, stricter than
`truncateSnippet`, which returns `string | undefined` — it returns undefined
for an empty normalised input, and `Finding.snippet` is optional for exactly
that reason. Mirrored that contract rather than coercing with `?? ""`, so an
absent snippet stays absent instead of becoming "".
Reproduced both in isolation against the real signatures before fixing, and
confirmed each mutation reproduces the original error verbatim.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(lint): split canvas-zoom collection by source
The `Fallow audit` gate flagged the rule's callback as critical:
`fallow/high-crap-score`, CRAP 116.3 against a threshold of 30, cyclomatic 21.
That is a fair reading — the callback collected hits from two unrelated sources
and then built the finding, all inline.
Split the collection into named functions by SOURCE (`inlineCanvasZoomHits`,
`stylesheetCanvasZoomHits`, composed by `canvasZoomHits`), leaving the callback
to resolve the first hit and word the finding. Decision points in the callback
go 21 -> 7.
Behaviour-preserving, and measured rather than asserted: both revisions were
executed against 16 inputs covering inline/<html>/<body>, stylesheet selectors,
comma lists, at-rules, comments, `!important`, class-matched roots, multiple
style blocks, and the inline-before-stylesheet precedence. Output is identical
on all 16, and the comparison was shown to detect a difference by swapping the
concatenation order, which diverges on exactly the precedence case.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor(lint): split the stylesheet zoom walk into selector, rule, walk
The first split took `Fallow audit` from critical (CRAP 116.3, cyclomatic 21) to
minor (37.1, cyclomatic 11) — right direction, still over the threshold of 30.
CRAP weights complexity against COVERAGE, and I cannot measure coverage on this
machine, so rather than tune to the margin this splits the stylesheet walk into
the three things it was doing: find the first canvas-targeting selector in a
comma list (`firstCanvasSelector`), turn one CSS rule into at most one hit
(`canvasZoomInRule`), and walk the rules. No function in the rule now exceeds 6
decision points, which clears the threshold at any plausible coverage rather
than at the measured one.
`firstCanvasSelector` returning the first match preserves the previous `break`
exactly: one hit per rule, not one per selector.
Behaviour re-verified against the ORIGINAL revision of this branch — identical
on all 16 inputs, with the comparison shown to detect a real difference by
dropping the `targetsCanvasRoot` guard, which diverges on two cases.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Since the source-location mapping landed, building the whitespace-collapsed copy of a file grew a
string with += and called endsWith on it per character. V8 flattens that string on every call, so a
1.2 MB index.html cost ~31 s of CPU in every lint run, including the one preview runs before it
listens.
* feat(core): a composition with no authored length takes it from its clips
A new resolver in @hyperframes/parsers returns a composition length as authored,
derived from its latest clip end, or unresolved, and counts clips whose length is
still pending. The runtime uses it in its two existing max-end loops with identical
results, and as a last resort only when no timeline, floor or caller length exists,
so a composition that has a length today keeps it. The derived source is published
on window.__hf.durationSource for render telemetry.
Lint reports a root that has timed clips but no data-duration as a warning that says
where to author it, and still errors when there is nothing to derive from.
* refactor(core): resolve the runtime duration floor in one call
The declared duration and sub-composition ends were resolved separately and
maxed by hand; one resolver call over both gives the same latest end.
* fix(core): a derived composition length waits for every clip length and reports honestly
Review follow-up. The runtime returns a content-derived length only when no clip is
still pending, so a renderer that reads duration once cannot lock in a short one.
window.__hf.durationSource is cleared whenever a timeline or floor supplies the length,
and the diagnostic fires only for a derived length. Lint says "at least Ns", counts
reference-start clips as known only at runtime, and ignores a data-end before data-start.
* fix(core): a sub-composition with no known length counts as pending
A host whose own timeline is not registered yet no longer lets the content-derived length
show early. Tests pin the pending host and that the diagnostic fires only for a derived length.
* fix(core): a loaded Lottie with no registered animation keeps the length pending
Lottie registers from author scripts with no DOM marker, and nothing re-derives the length
after it registers. A composition with clips and a loaded Lottie library therefore stays at
zero, as before, instead of locking in the clips length and cutting the animation short.
* test(core): a registered Lottie supplies the length and the clips length is not used
Pins the other side of the pending Lottie rule. Once an animation reports a length it enters the
duration floor, so the content-derived fallback is never consulted.
* test(core): the declared and DotLottie pending branches are covered
* fix(engine): audio source-not-found names the file and shares one src resolver with lint
* fix(engine): audio source-not-found prints the resolved path with forward slashes on Windows
* fix(parsers): the shared src resolver ignores a media fragment like the browser
A new resolver in @hyperframes/parsers decides how long a video, audio or image
occupies the timeline. The core runtime start resolver, clip manifest and clip
tree now read length through it. An image with data-start and no data-duration
gets the dropped-image default of 3 seconds; a bare image stays a static layer.
Video and audio take their length from the file until an authored duration trims it.
The playback rate bound (0.1 to 10) now lives in parsers, and core re-exports it.
A rate lane keeps its own arithmetic in core.
The lint rule that asked images for data-duration is removed, and the docs no
longer say an image requires it.
* feat(core): speed ramps as a rate lane in data-automation
A clip can carry a rate lane in data-automation. sourceTimeAt integrates it to source time and is the one mapping the runtime uses for media seeking, playback rate, natural duration, child timelines and the audio-master clock. Rate is clamped to 0.1-10 in one place, pitch is preserved unless data-preserve-pitch is false, and ramped clips play through the media element. Lint warns on a rate lane outside video and audio.
* fix(core): format the speed-ramp files and expect the unified rate clamp in the engine tests
* test(engine): read the playback-rate clamp from core instead of a literal
* feat(lint): flag nested structure, missing timing and caption tracks on the root composition
One rule set for the root composition: nested layout inside a top-level
element, a timed element or media without data-duration, captions not marked
data-track-kind=captions, and captions spread over more than one track.
Findings are errors when the host is Studio and warnings elsewhere.
Sub-composition files are left free to hold layout.
* test(lint): keep the structure rules silent on the shipped catalog and skills
Runs the rules over every registry and skills html file and asserts no
findings, and treats a bare media element with no timing as a static layer.
* fix(lint): run the structure rules as Studio in both adapters and skip non-root files
The Vite Studio adapter now lints as the studio host, and HTML the project
lint did not treat as a root (components, snippets) is linted as a
sub-composition. Style, script and template content no longer count as nested
layout, sub-composition hosts need no duration, and a caption row without a
lane is not a second track.
* fix(lint): count legacy data-end as a length and pin the catalog test to real roots
* 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>