Skip to content

feat(studio): fade handles on every clip, not just audio - #3351

Open
miguel-heygen wants to merge 2 commits into
feat/studio-clip-fade-handlesfrom
feat/studio-fade-handles-everywhere
Open

feat(studio): fade handles on every clip, not just audio#3351
miguel-heygen wants to merge 2 commits into
feat/studio-clip-fade-handlesfrom
feat/studio-fade-handles-everywhere

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #3347 (fade handles for audio), and it carries #3350's core
commit so it builds on its own. Once #3350 lands in main that commit rebases
away. Review order: #3341#3343#3347 → this.

What

The fade grips now appear on every clip, not just audio. Drag one in to set the
fade, drag it back to the corner to remove it, double-click to step the shape
through linear, smooth and sharp — same gesture whatever the clip is.

Fading a div clip with the grips, stepping its curve, then fading an audio clip with the same gesture

Why

The grips were audio-only because the volume envelope was the only place a fade
had somewhere to live. #3350 gives the runtime data-fade-in / data-fade-out,
so now everything does.

How

One gesture, two storages, chosen by what the clip is. Each is a projection
that reads back exactly what it wrote:

Clip Where the fade lives Why there
Audio the head and tail of its volume envelope it stays editable as breakpoints in the automation lane afterwards
Everything else data-fade-in / data-fade-out / data-fade-curve the runtime applies it, and it survives a trim or a move
  • The wedge is drawn with the sampler of whatever will actually play it — the
    runtime's easing for a visual fade, the envelope's own curvature for an audio
    one. Both are named smooth; they are not the same numbers, and drawing one
    with the other's shape would be a picture of the wrong fade.
  • The fade is applied to the store on commit. The attribute write reaches
    the preview and the file, but the timeline's element list is only re-derived
    on a refresh the quiet commit skips.
  • updateElement's writable surface is now a named EditableTimelineFields
    type rather than a Pick list re-spelled at the store.

Test plan

  • Unit tests added/updated — 19 in the binding (which storage each clip
    gets, preview vs commit, only-the-end-that-moved, attribute removal, the
    curve default staying off the markup, the read-only cases, the store
    apply) and 23 in the fade math, including that the audio and visual
    samplers draw different shapes for the same curve name.

  • Manual testing performed — drove a real Chrome and read the project
    file
    back after each gesture, on a div clip (0–8s) and an audio clip:

    Step On disk
    Drag fade in 2s (div) data-fade-in="2"
    Drag fade out 1.5s (div) data-fade-out="1.5", fade-in kept
    Double-click the grip data-fade-curve="smooth", both fades kept
    Drag fade in 2s (audio) data-automation volume [{t:0,v:0},{t:2,v:1}]

    And the runtime attenuates it, read out of the live preview: opacity(0) at
    t=0, opacity(0.5) at t=1, no filter at t=4, opacity(0.2593) at t=7.5 —
    which is smoothstep a third of the way into the 1.5s tail.
    The same grips, each clip fading in the way its own medium does:

    A div clip with smooth fades at both ends and an audio clip with a linear fade in, both drawn by the same grips
  • Full packages/studio suite green (4353 tests), typecheck, oxlint, oxfmt.

Two bugs this pass caught, both only visible by looking

  • The first version of the optimistic store apply ran on every pointer move,
    which moved the value each write compared against — so by release "nothing
    changed" and the persisted write never happened. The preview faded and the
    file did not. It applies on commit only now, and the probe above is what
    caught it.
  • The wedge skipped sampling when it thought the curve was a straight line, and
    the probe for that was the level at the midpoint — where every symmetric
    easing sits exactly on the straight line. Smooth fades drew as straight ramps.
    There is no shortcut now, and the test samples a quarter in.

Not covered

  • A video's audio. data-fade-in on a <video> fades the picture; ducking
    its sound is still the envelope's job, and a video has no automation lane in
    the timeline yet.
  • Rebasing a fade when its clip is trimmed — carried over from feat(studio): fade handles on the corners of an audio clip #3347.
  • Keyboard. The grips carry slider semantics but are not arrow-adjustable.

A fade had to be animated: an opacity tween positioned against the clip's
own start and duration, which then had to be kept in sync every time the
clip moved or was trimmed. This makes it declarative — the author says how
long the fade lasts and the runtime attenuates the clip across that stretch
of its own window, so the fade follows the clip wherever it goes.

    <video class="clip" data-start="0" data-duration="4"
           data-fade-in="0.5" data-fade-out="0.75" data-fade-curve="smooth">

Applied as `filter: opacity()`, not `opacity`. Opacity is the property
animation engines drive, and a runtime that rewrites it every frame fights
them for it; a filter multiplies with whatever they set instead. The
element's authored filter is captured on first touch and composed onto, and
restored exactly whenever the clip is not attenuating — so a clip outside
its fades carries only what its author gave it.

Three curves: linear, smooth (eases both ends) and sharp. Fades too long for
the clip share it in proportion rather than overlapping into a dip, so a
trimmed clip still resolves to a clean in and out.

Visual only. A fade on a sound is volume automation, it already has
`data-automation` to live in, and putting it there keeps it editable as
breakpoints instead of as one number.
The grips only appeared on audio, because the volume envelope was the only
place a fade had to live. Now that the runtime reads `data-fade-in` and
`data-fade-out`, every clip has somewhere to keep one — so every clip gets
the grips.

One gesture, two storages, chosen by what the clip is: audio still rides its
volume envelope, so a fade drawn with the grip is there as breakpoints to
refine afterwards; everything else carries the attributes. Each is a
projection that reads back exactly what it wrote, and the wedge is drawn with
the sampler of whichever thing will actually play it — the runtime's easing
for a visual fade, the envelope's own curvature for an audio one.

The attribute write reaches the preview and the file, but the timeline's
element list is only re-derived on a refresh the quiet commit skips, so the
fade is also applied to the store — on commit only. Applying it during the
drag moved the value each write compares against, and by release "nothing
changed" and the persisted write never happened: the preview faded and the
file did not.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant