feat(studio): fade handles on every clip, not just audio - #3351
Open
miguel-heygen wants to merge 12 commits into
Open
feat(studio): fade handles on every clip, not just audio#3351miguel-heygen wants to merge 12 commits into
miguel-heygen wants to merge 12 commits into
Conversation
miguel-heygen
force-pushed
the
feat/studio-clip-fade-handles
branch
from
August 20, 2026 17:32
d1c4fb1 to
c17e375
Compare
miguel-heygen
force-pushed
the
feat/studio-fade-handles-everywhere
branch
from
August 20, 2026 17:33
0537dbb to
d73d895
Compare
miguel-heygen
force-pushed
the
feat/studio-clip-fade-handles
branch
from
August 20, 2026 19:11
c17e375 to
56784d0
Compare
miguel-heygen
force-pushed
the
feat/studio-fade-handles-everywhere
branch
from
August 20, 2026 19:11
d73d895 to
a519cc9
Compare
miguel-heygen
force-pushed
the
feat/studio-clip-fade-handles
branch
from
August 20, 2026 20:28
56784d0 to
75c3db5
Compare
miguel-heygen
force-pushed
the
feat/studio-fade-handles-everywhere
branch
from
August 20, 2026 20:28
7f42440 to
a5edce0
Compare
miguel-heygen
force-pushed
the
feat/studio-clip-fade-handles
branch
from
August 20, 2026 20:45
75c3db5 to
ca73b0f
Compare
miguel-heygen
force-pushed
the
feat/studio-fade-handles-everywhere
branch
from
August 20, 2026 20:45
a5edce0 to
9fc619c
Compare
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
force-pushed
the
feat/studio-clip-fade-handles
branch
from
August 20, 2026 21:03
ca73b0f to
fdb4135
Compare
miguel-heygen
force-pushed
the
feat/studio-fade-handles-everywhere
branch
from
August 20, 2026 21:03
9fc619c to
dd39978
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
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:
data-fade-in/data-fade-out/data-fade-curveruntime'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 onewith the other's shape would be a picture of the wrong fade.
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 namedEditableTimelineFieldstype 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
divclip (0–8s) and an audio clip:data-fade-in="2"data-fade-out="1.5", fade-in keptdata-fade-curve="smooth", both fades keptdata-automationvolume[{t:0,v:0},{t:2,v:1}]And the runtime attenuates it, read out of the live preview:
opacity(0)att=0,
opacity(0.5)at t=1, no filter at t=4,opacity(0.2593)at t=7.5 —which is
smoothstepa third of the way into the 1.5s tail.The same grips, each clip fading in the way its own medium does:
Full
packages/studiosuite green (4353 tests), typecheck, oxlint, oxfmt.Two bugs this pass caught, both only visible by looking
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 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
data-fade-inon a<video>fades the picture; duckingits sound is still the envelope's job, and a video has no automation lane in
the timeline yet.