fix(studio): stop the fade wedge outlining its own straight edges

The wedge was one closed path carrying both the fill and the stroke, so the
fill's straight top and side got outlined too and the eye read a rectangle
butted onto the curve rather than one level line.

Split it: the level is an open path and the only thing stroked, the region it
takes away is that same line closed back through the clip's corner and never
stroked. Both come out of one function so they cannot drift apart, and the
endpoints are pinned by a test.
This commit is contained in:
Miguel Angel Simon Sierra
2026-08-20 17:02:05 -04:00
parent dcebcd32fc
commit fdb41350e3
3 changed files with 66 additions and 31 deletions
@@ -178,22 +178,29 @@ export function TimelineClipFades({
{(["in", "out"] as const).map((edge) => {
const seconds = edge === "in" ? shown.fadeIn : shown.fadeOut;
if (seconds <= 0) return null;
const { line, fill } = fadeWedgePath({
edge,
seconds,
curve,
pixelsPerSecond,
width,
height: VIEW_HEIGHT,
});
// Two paths, not one: stroking the closed wedge would outline the
// fill's straight top and side as well, which reads as a rectangle
// butted onto the curve instead of one continuous level line.
return (
<path
key={edge}
d={fadeWedgePath({
edge,
seconds,
curve,
pixelsPerSecond,
width,
height: VIEW_HEIGHT,
})}
fill="rgba(0,0,0,0.45)"
stroke="rgba(255,255,255,0.75)"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
<g key={edge}>
<path d={fill} fill="rgba(0,0,0,0.45)" stroke="none" />
<path
d={line}
fill="none"
stroke="rgba(255,255,255,0.75)"
strokeWidth={1}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
</g>
);
})}
</svg>
@@ -117,23 +117,22 @@ describe("fadeWedgePath", () => {
edge: "in" | "out",
curve: Parameters<typeof fadeWedgePath>[0]["curve"] = "linear",
) =>
fadeWedgePath({ edge, seconds: 2, curve, pixelsPerSecond: 25, width: WIDTH, height: HEIGHT });
fadeWedgePath({ edge, seconds: 2, curve, pixelsPerSecond: 25, width: WIDTH, height: HEIGHT })
.line;
/** Every [x, y] the path visits, in order. */
const points = (d: string) =>
[...d.matchAll(/[ML] (-?[\d.]+) (-?[\d.]+)/g)].map((m) => [Number(m[1]), Number(m[2])]);
it("draws a fade in rising out of the clip's start", () => {
const path = points(wedge("in"));
expect(path[0]).toEqual([0, 0]); // the corner it shades from
expect(path[1]).toEqual([0, HEIGHT]); // silent, at the very start
expect(path[2]).toEqual([50, 0]); // full level, 2s in at 25px/s
expect(path[0]).toEqual([0, HEIGHT]); // silent, at the very start
expect(path[1]).toEqual([50, 0]); // full level, 2s in at 25px/s
});
it("draws a fade out falling INTO the clip's end, not out of it", () => {
const path = points(wedge("out"));
expect(path[0]).toEqual([WIDTH, 0]);
expect(path[1]).toEqual([WIDTH - 50, 0]); // still at full level, 2s from the end
expect(path[2]).toEqual([WIDTH, HEIGHT]); // silent, exactly on the end
expect(path[0]).toEqual([WIDTH - 50, 0]); // still at full level, 2s from the end
expect(path[1]).toEqual([WIDTH, HEIGHT]); // silent, exactly on the end
});
it("samples a curved fade instead of drawing a straight line", () => {
@@ -154,7 +153,27 @@ describe("fadeWedgePath", () => {
width: WIDTH,
height: HEIGHT,
}),
).toBe("");
).toEqual({ line: "", fill: "" });
});
it("keeps the stroked line open so the fill's closing edges are not outlined", () => {
const { line, fill } = fadeWedgePath({
edge: "in",
seconds: 2,
curve: "linear",
pixelsPerSecond: 25,
width: WIDTH,
height: HEIGHT,
});
// The line is the level and nothing else: no close, no corner.
expect(line).not.toContain("Z");
expect(points(line)).toEqual([
[0, HEIGHT],
[50, 0],
]);
// The fill is that line closed back through the clip's corner.
expect(fill.startsWith(line)).toBe(true);
expect(fill.endsWith("L 0 0 Z")).toBe(true);
});
});
@@ -115,10 +115,15 @@ export function clampClipFades(fades: ClipFades, duration: number): ClipFades {
}
/**
* The filled wedge a fade draws on the clip, in SVG path form: the region the
* fade takes AWAY, from the clip's corner to where the level reaches full.
* The two SVG paths a fade draws, as one pair so they cannot disagree:
*
* Sampled through the same interpolator the runtime plays back, so a curved
* - `line` is the level itself, and the only thing that gets stroked. It is an
* open path: stroking a closed wedge outlines the fill's straight top and
* side too, which reads as a rectangle butted onto the curve.
* - `fill` is that same line closed back to the clip's corner — the region the
* fade takes away — and is never stroked.
*
* Both are sampled through the interpolator the runtime plays back, so a curved
* fade is drawn as the curve it will sound like rather than a straight line
* standing in for one.
*/
@@ -129,10 +134,10 @@ export function fadeWedgePath(input: {
pixelsPerSecond: number;
width: number;
height: number;
}): string {
}): { line: string; fill: string } {
const { edge, seconds, curve, pixelsPerSecond, width, height } = input;
const span = Math.min(seconds * pixelsPerSecond, width);
if (span <= 0) return "";
if (span <= 0) return { line: "", fill: "" };
const curvature = FADE_CURVES[curve];
const lane: HfAutomationLane = {
target: "volume",
@@ -154,14 +159,18 @@ export function fadeWedgePath(input: {
const xAt = (progress: number) =>
edge === "in" ? span * progress : width - span * (1 - progress);
const steps = curvature === 0 ? 1 : WEDGE_SAMPLES;
const line: string[] = [];
const points: string[] = [];
for (let i = 0; i <= steps; i += 1) {
const progress = i / steps;
const level = sampleAutomationLane(lane, seconds * progress, "linear");
line.push(`L ${xAt(progress).toFixed(2)} ${((1 - level) * height).toFixed(2)}`);
points.push(`${xAt(progress).toFixed(2)} ${((1 - level) * height).toFixed(2)}`);
}
const line = `M ${points.join(" L ")}`;
// The fill closes through the clip's own corner: up to the top for a fade-in,
// back along the top for a fade-out. Never stroked, so those closing edges
// stay invisible and only the level reads as a line.
const corner = edge === "in" ? 0 : width;
return `M ${corner} 0 ${line.join(" ")} L ${corner} 0 Z`;
return { line, fill: `${line} L ${corner} 0 Z` };
}
/** Segments used to draw a curved wedge; a straight one needs no sampling. */