Skip to content

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

Open
miguel-heygen wants to merge 12 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 12 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.

@miguel-heygen
miguel-heygen force-pushed the feat/studio-clip-fade-handles branch from d1c4fb1 to c17e375 Compare August 20, 2026 17:32
@miguel-heygen
miguel-heygen force-pushed the feat/studio-fade-handles-everywhere branch from 0537dbb to d73d895 Compare August 20, 2026 17:33
@miguel-heygen
miguel-heygen force-pushed the feat/studio-clip-fade-handles branch from c17e375 to 56784d0 Compare August 20, 2026 19:11
@miguel-heygen
miguel-heygen force-pushed the feat/studio-fade-handles-everywhere branch from d73d895 to a519cc9 Compare August 20, 2026 19:11
@miguel-heygen
miguel-heygen force-pushed the feat/studio-clip-fade-handles branch from 56784d0 to 75c3db5 Compare August 20, 2026 20:28
@miguel-heygen
miguel-heygen force-pushed the feat/studio-fade-handles-everywhere branch from 7f42440 to a5edce0 Compare August 20, 2026 20:28
@miguel-heygen
miguel-heygen force-pushed the feat/studio-clip-fade-handles branch from 75c3db5 to ca73b0f Compare August 20, 2026 20:45
@miguel-heygen
miguel-heygen force-pushed the feat/studio-fade-handles-everywhere branch from a5edce0 to 9fc619c Compare August 20, 2026 20:45
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.
White was arbitrary, and harsh against the clip surfaces. The automation
lane already strokes an envelope in the clip's accent at 1.5px — and a fade
IS an envelope, so it now uses the same colour, weight and opacity. The
wedge on the clip and the curve in the expanded lane read as one line
instead of two unrelated marks.

The grips stay white, which is what the lane draws its breakpoints in.
The shape of a fade is something an editor pulls into place, so it has to
be able to land anywhere between the extremes. Three named curves could not
do that: picking one meant a menu or a blind cycle, and the answer was
always one of three.

`data-fade-curve` is now a number in [-1, 1]. Zero is a straight ramp,
negative sags the line so the fade starts slow and finishes fast, positive
bulges it the other way. Underneath is a single exponent, k = 2^(-2·curve),
which makes -0.5 exactly p² and +0.5 exactly √p, keeps every bend monotonic
so a fade never dips, and makes the two directions inverse functions of each
other. That last property is what makes dragging the line back through the
middle land on straight instead of drifting past it.

`fadeCurveThroughMidpoint` inverts the relation: given the level a pointer
is sitting at halfway through the fade, it returns the bend whose curve
passes through that point. Studio's drag is then direct manipulation rather
than a pointer nudging an abstract parameter.

The named curves are dropped rather than kept as aliases. Nothing has
shipped with them, and one attribute with two spellings is one too many.
Picking a curve used to be a double-click on a 9px grip that cycled through
three shapes. Nothing advertised the gesture, you could not see the options
before committing to one, and getting back to where you started meant going
all the way round.

The line is now the control. A dot sits on the curve's midpoint and
dragging it up or down bends the fade, live, with the curve following the
pointer rather than approaching it: the pointer's height inside the clip IS
the level the curve must reach halfway through, and `fadeCurveThroughMidpoint`
returns the bend that does it. Drag back through the middle and the fade is
straight again, exactly, because the two directions are inverse functions.

Both media now draw one shape rather than two lookalikes.
`envelopeCurveForFade` is the whole conversion: a fade is `p^(2^(-2·bend))`
and an envelope segment is `x^(2^(2·curve))`, so an audio fade and a visual
one differ by a sign and nothing else. That collapses the two samplers into
one and is asserted point by point, since the two are stored in different
places and reached through different code.

The dot only appears once there is a fade to bend. On a clip with none there
is no line to pull, and the corner grips are what start one.
…hind them

One page for the whole feature: drawing a fade with the corner grips,
bending it by dragging the line, and the markup either one produces.

The bend section leads with what each direction is FOR, not with the
number. "Drag down and the fade holds near nothing then arrives late,
useful under a voice" is the sentence someone can act on; -0.5 is not.
The number is documented where it belongs, next to the attribute.

Both storages are written up side by side, because which one a clip uses is
the thing that surprises people: picture carries three attributes the
runtime reads, sound carries two breakpoints on the volume envelope it
already had. Same curve either way.

Media is in docs/images/studio (gitignored, CDN-served) and needs
scripts/upload-docs-images.sh before the page renders.
@miguel-heygen
miguel-heygen force-pushed the feat/studio-clip-fade-handles branch from ca73b0f to fdb4135 Compare August 20, 2026 21:03
@miguel-heygen
miguel-heygen force-pushed the feat/studio-fade-handles-everywhere branch from 9fc619c to dd39978 Compare August 20, 2026 21:03
The grip was positioned at `x - GRIP/2`, so with no fade to sit on it landed
at -4.5px. The clip is `overflow: hidden` with a rounded corner, which took
the outer half and then another bite out of what was left. What showed was a
sliver, not a handle.

Handles are now held inside the clip, and the parked spot moves inboard of
the trim handle rather than on top of it. Two controls sharing ten pixels
made every grab a coin toss over which one you got; the trim handle owns the
edge strip, the fade grip sits just inside it, and once a fade exists the
grip leaves anyway to ride the top of its wedge.

The two handles also stop looking alike. A fade has two things you can
change and they move along different axes, so each takes the shape of its
own: the length grip is a square that slides horizontally, the bend handle
is a dot that slides vertically. On a short fade they sit a few pixels
apart, so telling them apart at a glance beats either being pretty.

Both are bigger, both carry a shadow so they read as sitting ON the clip
rather than punched through it, and both are quiet until hovered: parked in
the corner a solid white block reads as damage rather than as an offer.
One clip carried one bend, so shaping the head reshaped the tail. A fade in
that creeps out of black and a fade out that drops away is an ordinary ask,
and a single shared number could not say it.

No new attribute. `data-fade-curve` takes one value or two, the bargain CSS
strikes with `border-radius`: "-0.5" bends both ramps alike, "-0.5 0.3"
bends the head then the tail. Commas read the same as spaces, a third value
is somebody's typo rather than a third ramp, and the pair collapses back to
one value the moment a drag makes the ramps agree, so the common case stays
the short markup.

A ramp with no length inherits the other's shape rather than writing a zero.
Otherwise a clip with only a fade in records "-0.5 0", where the second
number describes a tail that is not there, and inheriting is also the right
value for that tail to start from if it is drawn later.

Audio needed no storage change at all: envelope curvature already lives on
the point it leaves, so the two bends were always there and only the reader
was collapsing them.
The attribute table said "how far the fade bends" when there are two ramps
and two bends. It now says so, with the one-or-two value shorthand written
out and an example carrying opposite shapes on one clip.
The feature had grown three attributes of its own: data-fade-in,
data-fade-out and data-fade-curve. None of them needed to exist. A fade is a
value that moves across a clip's life, and `data-automation` is the
framework's mechanism for exactly that. It already carried audio fades.

So picture rides it too, in a lane targeting `opacity`, and the three
attributes are deleted. Net zero new attributes for the whole feature.

What that buys beyond the count:

- One storage, one gesture, one editor. A fade drawn with a grip is the same
  two breakpoints the automation lane edits, on a video as much as on music,
  so it can be refined by hand afterwards.
- A third point dragged into the middle stops it being a fade and makes it
  an envelope, with nothing to migrate and no second representation to keep
  in step.
- The two ramps bend apart for free. Curvature already lives on the point it
  leaves, so the pair of bends was always expressible; the attribute was the
  thing that could only hold one.

Deleted rather than moved: fadeEase, clampFadeCurve, fadeCurveThroughMidpoint
and formatFadeCurve leave core, since the envelope's own curvature is the
bend and its own points are the shape. clipFadeLevelAt loses the
proportional-overlap rule, because absolute points cannot overlap. Studio
loses useClipFadeWriter, the visual attribute binding, and the fadeIn /
fadeOut / fadeCurve fields the element model carried to feed them.

Lane rows stop being gated on audio, so an opacity envelope draws and edits
in the timeline like a volume one. The narration fixture two automation
tests had each grown their own copy of moves to one file, since touching
them brought the duplication into scope.

The module is still called audioAutomation and still throws
AudioAutomationError. That name is now too narrow, and renaming it touches 58
files, so it is left for a change that does only that.
Draw a fade in, then a fade out a moment later, and the fade in vanished.

The envelope commit is deliberately quiet: it reaches the file and the
preview but skips the refresh that re-derives the timeline, because a
refresh reloads the composition and restarts every playing track. So the
second gesture read the clip as it was BEFORE the first, computed its write
from a clip with no fade, and wrote only the tail.

The write path now catches the store up in the same tick it persists, which
is the one place both media and both surfaces route through. It also fixes
the same latency for a hand-dragged breakpoint, which had it too.

Found by driving the real gesture rather than the unit path: a four second
pause between the two drags made it pass, which is what named it as staleness
rather than arithmetic. The regression test stands in for the store catching
up by binding the second gesture against what the first one produced.

The fades docs page is rewritten for the envelope storage in the same
change, since it still described three attributes that no longer exist.
…nside it

Four handles on a clip became two. A fade had a square for its length and a
dot for its bend, so a clip carrying both fades wore four controls, on top of
its thumbnails, its label and its trim handles.

The diamond sits on the fade curve's own midpoint, which is the one point
where both gestures are direct manipulation: drag it sideways and the fade is
twice as long as you moved, drag it up or down and the curve passes through
the pointer. An axis lock on the first 3px keeps a wandering hand from
changing the property it did not aim at. Inside the diamond, the same sampler
that will play the fade draws the shape, so the handle reads as what the fade
is, not only where it is.

Also fixes a bend that leaked across: the two curvatures were read as
points[0] and the one before last, and on a clip with only a fade-in those are
the same point, so the fade-out inherited a bend nobody gave it.
Verified by driving the real gesture and reading the file back: the midpoint
lands under the pointer on both axes, and the second ramp stays straight.
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