Skip to content

Commit 7b2a063

Browse files
RuitingMaclaude
andcommitted
Score: fix loop fresh-start render after tiling refactor
Three regressions stacked from previous commits showed up only on loop scores at fresh-start (before first play): the music was completely absent from the visible region — chrome + overlay rendered, the rest was empty. Once the reader clicked play it recovered, masking the bug. 1. `musicWidth` was using `panRect.width` indiscriminately. For non-loop that's correct (pan = one continuous piece, sliced or not). For loop the pan spans N copies side-by-side, so panRect.width was N× too large. The wrong value broke padAnchors's loop-tail anchor and the render-loop's wrap math. Use tile0's own bounding rect for loop. 2. The pan's flex children were defaulting to flex-shrink: 1, so when the pan's containing block (`.score-pan-mask` at 100% of stage = small) constrained it via shrink-to-fit, three loop copies got squeezed to ~1/3 their natural width at mount time. tile0's bounding rect read ~600 px instead of ~1700, throwing musicWidth out by ~3×. Pin direct svg children to flex-shrink: 0. 3. After the pre-roll removal, fresh-start lands at translateX = +3 px (first note's natural x sits slightly left of the playhead). The render loop's `while (translateX > 0)` wrap was firing on that legitimate tiny positive offset and snapping the pan a full musicWidth left, hiding the music entirely. Gate the downward wrap on `wrapOffset !== 0` — only normalize when there's actually accumulated wrap state from prior iter changes / backward seeks. All four scores verified at fresh-start: first note's center aligns to the playhead within sub-pixel rounding. Loop continues to wrap correctly across iter transitions during sustained playback. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8b7209b commit 7b2a063

3 files changed

Lines changed: 30 additions & 6 deletions

File tree

src/components/Score.astro

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,16 @@ const { slug, loop, playhead = 0.22, height = 200 } = Astro.props;
223223
fill: currentColor;
224224
stroke: currentColor;
225225
}
226+
/* Pin direct SVG children of the pan to their natural width. Without
227+
this, flex-shrink (default 1) collapses them when pan's shrink-to-
228+
fit width can't accommodate the sum of all copies/tiles — the pan
229+
ends up sized to one child's intrinsic width and the SVGs each
230+
squeeze down to ~1/N. That broke loop scores at mount: tile0's
231+
bounding rect read ~600px instead of the natural ~1700, throwing
232+
`musicWidth` out of sync with the wrap math. */
233+
.score-pan > :global(svg) {
234+
flex-shrink: 0;
235+
}
226236
/* Pedal markings (Ped. text + the bracket showing range of depression).
227237
A dense piece like Ravel's Jeux d'eau has hundreds of these, and at
228238
small stage heights the bracket reads as a foreign trapezoid below

src/lib/score/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,15 @@ export async function mountScore(host: HTMLElement): Promise<MountedScore | null
137137
// measure-1 header in the pan's own coordinates — drives loop
138138
// copy-shifting so music tiles end-to-end across loop copies.
139139
const actualHeaderWidth = measureHeaderWidth(firstMeasure, panRect0);
140-
const musicWidth = panRect0.width - actualHeaderWidth;
140+
// Music width = ONE iteration's width. For non-loop that's the whole
141+
// pan (single SVG OR all tiles laid out left-to-right summing to the
142+
// full piece). For loop it's just one copy — pan spans N copies so
143+
// panRect.width would be N× too large, breaking padAnchors's loop-
144+
// tail anchor and the render loop's wrap math.
145+
const oneIterationWidthPx = loop
146+
? tile0.getBoundingClientRect().width
147+
: panRect0.width;
148+
const musicWidth = oneIterationWidthPx - actualHeaderWidth;
141149

142150
const anchors = padAnchors(
143151
buildAnchors(pan, rendered, panRect0),

src/lib/score/render.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1910,12 +1910,18 @@ export function setupRenderLoop(args: {
19101910
lastIterSeen = iter;
19111911
}
19121912
translateX = playheadPx - containerX + wrapOffset;
1913-
// Keep translate in (-musicWidth, 0]. The phantom leader tile
1914-
// positioned at `-musicWidth` means either side of that range
1915-
// has identical content to what the wrap reveals — the snap
1916-
// is visually seamless.
1913+
// Normalize translate into (-musicWidth, 0] via a phantom leader
1914+
// tile at `-musicWidth` so the seam is visually seamless. The
1915+
// forward wrap (`<= -musicWidth`) handles natural playback drift.
1916+
// The downward wrap (`> 0`) only matters when wrapOffset has
1917+
// accumulated state from prior iterations / backward jumps —
1918+
// skipping it when wrapOffset is zero preserves the legitimate
1919+
// fresh-start pose where translateX is a small positive value
1920+
// (first note's natural x sits slightly left of the playhead).
19171921
while (translateX <= -musicWidth) { translateX += musicWidth; wrapOffset += musicWidth; }
1918-
while (translateX > 0) { translateX -= musicWidth; wrapOffset -= musicWidth; }
1922+
if (wrapOffset !== 0) {
1923+
while (translateX > 0) { translateX -= musicWidth; wrapOffset -= musicWidth; }
1924+
}
19191925
} else {
19201926
translateX = playheadPx - xAtMs(ms);
19211927
}

0 commit comments

Comments
 (0)