Skip to content

Commit fdb4135

Browse files
committed
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.
1 parent dcebcd3 commit fdb4135

3 files changed

Lines changed: 66 additions & 31 deletions

File tree

packages/studio/src/player/components/TimelineClipFades.tsx

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -178,22 +178,29 @@ export function TimelineClipFades({
178178
{(["in", "out"] as const).map((edge) => {
179179
const seconds = edge === "in" ? shown.fadeIn : shown.fadeOut;
180180
if (seconds <= 0) return null;
181+
const { line, fill } = fadeWedgePath({
182+
edge,
183+
seconds,
184+
curve,
185+
pixelsPerSecond,
186+
width,
187+
height: VIEW_HEIGHT,
188+
});
189+
// Two paths, not one: stroking the closed wedge would outline the
190+
// fill's straight top and side as well, which reads as a rectangle
191+
// butted onto the curve instead of one continuous level line.
181192
return (
182-
<path
183-
key={edge}
184-
d={fadeWedgePath({
185-
edge,
186-
seconds,
187-
curve,
188-
pixelsPerSecond,
189-
width,
190-
height: VIEW_HEIGHT,
191-
})}
192-
fill="rgba(0,0,0,0.45)"
193-
stroke="rgba(255,255,255,0.75)"
194-
strokeWidth={1}
195-
vectorEffect="non-scaling-stroke"
196-
/>
193+
<g key={edge}>
194+
<path d={fill} fill="rgba(0,0,0,0.45)" stroke="none" />
195+
<path
196+
d={line}
197+
fill="none"
198+
stroke="rgba(255,255,255,0.75)"
199+
strokeWidth={1}
200+
strokeLinecap="round"
201+
vectorEffect="non-scaling-stroke"
202+
/>
203+
</g>
197204
);
198205
})}
199206
</svg>

packages/studio/src/player/components/clipFades.test.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -117,23 +117,22 @@ describe("fadeWedgePath", () => {
117117
edge: "in" | "out",
118118
curve: Parameters<typeof fadeWedgePath>[0]["curve"] = "linear",
119119
) =>
120-
fadeWedgePath({ edge, seconds: 2, curve, pixelsPerSecond: 25, width: WIDTH, height: HEIGHT });
120+
fadeWedgePath({ edge, seconds: 2, curve, pixelsPerSecond: 25, width: WIDTH, height: HEIGHT })
121+
.line;
121122
/** Every [x, y] the path visits, in order. */
122123
const points = (d: string) =>
123124
[...d.matchAll(/[ML] (-?[\d.]+) (-?[\d.]+)/g)].map((m) => [Number(m[1]), Number(m[2])]);
124125

125126
it("draws a fade in rising out of the clip's start", () => {
126127
const path = points(wedge("in"));
127-
expect(path[0]).toEqual([0, 0]); // the corner it shades from
128-
expect(path[1]).toEqual([0, HEIGHT]); // silent, at the very start
129-
expect(path[2]).toEqual([50, 0]); // full level, 2s in at 25px/s
128+
expect(path[0]).toEqual([0, HEIGHT]); // silent, at the very start
129+
expect(path[1]).toEqual([50, 0]); // full level, 2s in at 25px/s
130130
});
131131

132132
it("draws a fade out falling INTO the clip's end, not out of it", () => {
133133
const path = points(wedge("out"));
134-
expect(path[0]).toEqual([WIDTH, 0]);
135-
expect(path[1]).toEqual([WIDTH - 50, 0]); // still at full level, 2s from the end
136-
expect(path[2]).toEqual([WIDTH, HEIGHT]); // silent, exactly on the end
134+
expect(path[0]).toEqual([WIDTH - 50, 0]); // still at full level, 2s from the end
135+
expect(path[1]).toEqual([WIDTH, HEIGHT]); // silent, exactly on the end
137136
});
138137

139138
it("samples a curved fade instead of drawing a straight line", () => {
@@ -154,7 +153,27 @@ describe("fadeWedgePath", () => {
154153
width: WIDTH,
155154
height: HEIGHT,
156155
}),
157-
).toBe("");
156+
).toEqual({ line: "", fill: "" });
157+
});
158+
159+
it("keeps the stroked line open so the fill's closing edges are not outlined", () => {
160+
const { line, fill } = fadeWedgePath({
161+
edge: "in",
162+
seconds: 2,
163+
curve: "linear",
164+
pixelsPerSecond: 25,
165+
width: WIDTH,
166+
height: HEIGHT,
167+
});
168+
// The line is the level and nothing else: no close, no corner.
169+
expect(line).not.toContain("Z");
170+
expect(points(line)).toEqual([
171+
[0, HEIGHT],
172+
[50, 0],
173+
]);
174+
// The fill is that line closed back through the clip's corner.
175+
expect(fill.startsWith(line)).toBe(true);
176+
expect(fill.endsWith("L 0 0 Z")).toBe(true);
158177
});
159178
});
160179

packages/studio/src/player/components/clipFades.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -115,10 +115,15 @@ export function clampClipFades(fades: ClipFades, duration: number): ClipFades {
115115
}
116116

117117
/**
118-
* The filled wedge a fade draws on the clip, in SVG path form: the region the
119-
* fade takes AWAY, from the clip's corner to where the level reaches full.
118+
* The two SVG paths a fade draws, as one pair so they cannot disagree:
120119
*
121-
* Sampled through the same interpolator the runtime plays back, so a curved
120+
* - `line` is the level itself, and the only thing that gets stroked. It is an
121+
* open path: stroking a closed wedge outlines the fill's straight top and
122+
* side too, which reads as a rectangle butted onto the curve.
123+
* - `fill` is that same line closed back to the clip's corner — the region the
124+
* fade takes away — and is never stroked.
125+
*
126+
* Both are sampled through the interpolator the runtime plays back, so a curved
122127
* fade is drawn as the curve it will sound like rather than a straight line
123128
* standing in for one.
124129
*/
@@ -129,10 +134,10 @@ export function fadeWedgePath(input: {
129134
pixelsPerSecond: number;
130135
width: number;
131136
height: number;
132-
}): string {
137+
}): { line: string; fill: string } {
133138
const { edge, seconds, curve, pixelsPerSecond, width, height } = input;
134139
const span = Math.min(seconds * pixelsPerSecond, width);
135-
if (span <= 0) return "";
140+
if (span <= 0) return { line: "", fill: "" };
136141
const curvature = FADE_CURVES[curve];
137142
const lane: HfAutomationLane = {
138143
target: "volume",
@@ -154,14 +159,18 @@ export function fadeWedgePath(input: {
154159
const xAt = (progress: number) =>
155160
edge === "in" ? span * progress : width - span * (1 - progress);
156161
const steps = curvature === 0 ? 1 : WEDGE_SAMPLES;
157-
const line: string[] = [];
162+
const points: string[] = [];
158163
for (let i = 0; i <= steps; i += 1) {
159164
const progress = i / steps;
160165
const level = sampleAutomationLane(lane, seconds * progress, "linear");
161-
line.push(`L ${xAt(progress).toFixed(2)} ${((1 - level) * height).toFixed(2)}`);
166+
points.push(`${xAt(progress).toFixed(2)} ${((1 - level) * height).toFixed(2)}`);
162167
}
168+
const line = `M ${points.join(" L ")}`;
169+
// The fill closes through the clip's own corner: up to the top for a fade-in,
170+
// back along the top for a fade-out. Never stroked, so those closing edges
171+
// stay invisible and only the level reads as a line.
163172
const corner = edge === "in" ? 0 : width;
164-
return `M ${corner} 0 ${line.join(" ")} L ${corner} 0 Z`;
173+
return { line, fill: `${line} L ${corner} 0 Z` };
165174
}
166175

167176
/** Segments used to draw a curved wedge; a straight one needs no sampling. */

0 commit comments

Comments
 (0)