* 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>
A composition whose root declares portrait data-width/data-height renders
at the correct output size, but a project scaffolded landscape (init
without --resolution) still ships html/body CSS and a viewport meta at the
old landscape size. The stale body's overflow:hidden then clips the
correctly-sized root at the old height, so everything past it renders as
page background instead of real content.
applyCompositionSizing now mirrors the SAME forced width/height it already
computes for the root onto documentElement/body, reusing rather than
recomputing them. overflow:hidden is left untouched (a deliberate
anti-white-bar guard set earlier in the same init pass): once body's own
size agrees with the root it contains, it clips nothing that matters.
Runs in both preview and render, since both load the one bundled runtime
artifact this file builds into.
Adds a warning-level root_dimensions_mismatch lint rule alongside
root_missing_dimensions, comparing the root's dims against the html/body
CSS block and the viewport meta - the two other places a scaffold's
resolution lives besides the root itself. The read-only regexes live in
@hyperframes/parsers (packages/lint cannot depend on packages/cli, which
owns the closest existing pattern in applyResolutionPreset) so a future
caller has one place to update if the scaffold's shape changes, rather
than a rule silently comparing against a guess.
Closes#4001.
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
Add regression coverage for finite SDR video slots that start past EOF in mixed HDR timelines, and verify lint exclusions with successful local duration probes.
Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com>
Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com>
Allow explicit finite non-looping video slots to use the existing held-final-frame extraction path when data-media-start is at or beyond source duration. Warn during project lint for local static slots that will use this behavior.
Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com>
Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com>
Capture explicit source name bounds before attributes are parsed. Preserve malformed names, Unicode lowercase expansion, and existing implied-tag ranges while retaining malformed-attribute lint failures.
* feat(cli): render summary names the capture path, gpu mode and stage timings
* fix(lint): drop gsap_exit_missing_hard_kill, its hazard does not reproduce on cold workers
* fix(cli): render slow-path hint fires only when the gpu was auto-probed
* test(cli): isolate agent hint suite from ambient agent env keys
The suite runs inside agent sessions. Ambient env keys fill the 16-key
hint cap and fail assertions that a plain shell would pass.
* docs(music-to-video): drop leftover hard-kill mandate after lint rule removal
Frame workers still required a tl.set hard-kill that the dropped lint
rule no longer enforces. Align the self-check with montage.md.
* refactor(cli): share hint-key pattern and collapse render summary args
Export HINT_KEY_PATTERN for the test isolation strip. Read duration and
frame count from the perf summary instead of passing them beside it.
* test(cli): lock capture-mode preference and 16-key hint isolation
Session mode, including drawelement, must print over observability.
The hint suite now fails if ambient keys already fill the 16-key cap.
* feat(cli): default BeginFrame, agent blank, and looks quality
Local auto render opts out of the software-GPU screenshot clamp so
BeginFrame can run. init --agent scaffolds a centered Inter blank with
no prompts. --quality looks (now the default) is CRF 16; delivery is high.
* fix(cli): honor screenshot env and teach agent pitfalls on the happy path
Local auto still requests BeginFrame unless PRODUCER_FORCE_SCREENSHOT=true.
The agent blank fills its canvas so --resolution can resize it. Skills now
name the four pitfalls and tell agents to read the render summary line.
* feat(cli): default init to the centered blank
Bare init scaffolds the centered Inter stage. --agent is a hidden alias.
* fix(cli): pack from-file and use it for video init
TTY --video with no --example selects from-file. Pack copies that template.
* fix(cli): wire --video onto from-file even with --example blank
One resolver picks the scaffold. Spawned init --video asserts a-roll src.
* fix(cli): narrow looks CRF on the quality alias
Only looks carries crf. Typecheck failed on the union without a guard.
* style(cli): shorten render-pipeline summary comment
A start tag missing its closing `>` before the next `<` (e.g. a bad
string-replace that leaves `<img ... <div class="hl"></div>` behind) is
parsed leniently by the browser's HTML5 tokenizer: the `<div` text is
consumed as a bogus attribute name on the still-open `img` tag, and the
intended element never becomes a real DOM node. No existing lint/check gate
catches this — they all operate on the resolved DOM, which looks
structurally valid once the browser has already dropped the element.
Adds `unclosed_tag_swallowed_element`, a new core lint rule that flags any
parsed tag whose attribute text contains a `<` outside a quoted value (a
legitimate attribute value may itself contain a raw `<`, e.g.
`data-expr="x < y"`, which htmlparser2 parses correctly and is not flagged).
PRINFRA-668
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* fix(build): stop generated files from being read half-written during the build
Two unrelated pull requests kept failing CI for reasons that had nothing to do
with their changes.
Typecheck failed with "TS1002: Unterminated string literal" pointing at a
generated file. The root build compiles several packages at the same time, and
more than one of them regenerates files under a package's src/generated while a
sibling's tsc is already reading them. A plain write empties the file and then
refills it, so for a few milliseconds what is on disk is the first half of the
new file. Whichever build read it in that gap saw a truncated file and stopped.
It never happened locally because locally nothing else is reading.
Every generator now writes the new version under a temporary name and then
renames it over the target, which the filesystem does in one step. A reader
either gets the whole previous version or the whole new one; there is no moment
where it can see half of either. Content that has not changed is not rewritten
at all, so repeat builds no longer touch these files. Four generators were
affected and all four now go through one shared helper, which is where the rule
lives from now on.
The build also rebuilt the core package a second time, in parallel with the
packages that read it. That second rebuild was already redundant, and removing
it takes the writers out of the window entirely. Renaming is what makes the file
safe; dropping the duplicate build makes the build shorter as well.
Separately, a linter performance test failed on four of the last ten failed runs
on main. It timed a scan with a stopwatch and demanded the result come in under
two seconds; one run took 3.7s. That is a statement about how busy the shared
runner was, not about the code, because a stopwatch also counts the time the
machine spent running someone else's job. The test now counts only the processor
time this process actually used, and checks that the cost grows in step with the
input rather than against a fixed number of milliseconds. Measured on a machine
under load, the stopwatch ratio for the same input reached 45x while the
processor-time ratio stayed at 20x.
Both fixes come with a check that fails if the fix is removed.
* fix(build): keep the shared write helper inside the core package
The helper the generators call had been placed in the repo-root scripts folder.
Every container image that builds a package copies the packages folders whole
but cherry-picks root scripts one file at a time, so the image builds failed on
a missing module. The repo already shows both halves of that convention: the one
root script a package build imports has a matching copy line in the image, and
cross-package imports into the core package need none.
The helper now lives with the core package's other build scripts, and the one
generator outside that package reaches it the way the engine package already
reaches core.
* test(engine): keep the probe cache bound test off the filesystem
The eviction test wrote, stat'ed and deleted 129 temp files to exercise an
in-memory LRU rule. Its runtime tracked filesystem contention rather than
the code under test, and on a busy Windows runner the file churn alone
pushed a ~300ms test past the 5s timeout, failing unrelated pull requests.
Cache identity comes from stat, so the test now synthesises stat results
and never touches disk. While here, the test also asserts the LRU touch:
re-probing an entry before the bound is hit must keep it resident and
evict the next-oldest one instead. The previous shape never hit the cache
during the fill, so that branch was untested.
* test(lint): keep the scaling check inside the scanner's linear range
The CPU-ratio version sampled 320k characters, where the output string's
own growth dominates and the whole test cost seconds of CPU; on a shared
runner that tripped the default test timeout, the failure this change
exists to remove. Sample 10k and 80k characters instead, where 8x input
measures ~8x and a quadratic scan still measures 60x or more, and give
the test an explicit timeout so a slow runner reports the ratio.
* test(core): snapshot file identity through a descriptor
The before/after inode and mtime checks read the file through a path stat
and then exercised the writer on the same path, which reads as a
check-then-use race to static analysis. Snapshot through an open
descriptor instead; the assertions are unchanged.