fix(core): prevent sprite tile bleeding in thumbnail component (#1053)

Co-authored-by: sampotts <sam@potts.es>
This commit is contained in:
rahim
2026-03-23 15:48:43 -07:00
committed by GitHub
co-authored by sampotts
parent 4ef56eb0a9
commit 07b1c87262
2 changed files with 122 additions and 18 deletions
@@ -221,15 +221,15 @@ describe('ThumbnailCore', () => {
maxHeight: Infinity,
});
// scale = 128/256 = 0.5
// scale = 128/256 = 0.5, inset = 1 (scale !== 1)
expect(result).toEqual({
scale: 0.5,
containerWidth: 128,
containerHeight: 80,
containerWidth: 126,
containerHeight: 78,
imageWidth: 1280,
imageHeight: 800,
offsetX: 256,
offsetY: 160,
offsetX: 257,
offsetY: 161,
});
});
@@ -263,14 +263,17 @@ describe('ThumbnailCore', () => {
maxHeight: Infinity,
});
const scale = 177 / 256;
const inset = 1; // scale !== 1
expect(result).toEqual({
scale: 177 / 256,
containerWidth: 177,
containerHeight: Math.round(160 * (177 / 256)),
imageWidth: Math.round(2560 * (177 / 256)),
imageHeight: Math.round(1600 * (177 / 256)),
offsetX: Math.round(512 * (177 / 256)),
offsetY: Math.round(320 * (177 / 256)),
scale,
containerWidth: Math.floor(256 * scale) - inset * 2,
containerHeight: Math.floor(160 * scale) - inset * 2,
imageWidth: Math.ceil(2560 * scale),
imageHeight: Math.ceil(1600 * scale),
offsetX: Math.ceil(512 * scale) + inset,
offsetY: Math.ceil(320 * scale) + inset,
});
// Verify all pixel values are integers (no sub-pixel rendering gaps).
@@ -279,6 +282,97 @@ describe('ThumbnailCore', () => {
}
});
it('container never exceeds scaled tile dimensions', () => {
const core = new ThumbnailCore();
const thumbnail = createImage({ coords: { x: 512, y: 320 } });
const result = core.resize(thumbnail, 2560, 1600, {
minWidth: 0,
maxWidth: 177,
minHeight: 0,
maxHeight: Infinity,
});
const scale = result!.scale;
expect(result!.containerWidth).toBeLessThanOrEqual(256 * scale);
expect(result!.containerHeight).toBeLessThanOrEqual(160 * scale);
expect(result!.imageWidth).toBeGreaterThanOrEqual(result!.containerWidth);
expect(result!.imageHeight).toBeGreaterThanOrEqual(result!.containerHeight);
});
it('offsets never undershoot the tile origin (prevents top/left bleed)', () => {
const core = new ThumbnailCore();
const thumbnail = createImage({ coords: { x: 512, y: 320 } });
const result = core.resize(thumbnail, 2560, 1600, {
minWidth: 0,
maxWidth: 177,
minHeight: 0,
maxHeight: Infinity,
});
const scale = result!.scale;
expect(result!.offsetX).toBeGreaterThanOrEqual(512 * scale);
expect(result!.offsetY).toBeGreaterThanOrEqual(320 * scale);
});
it('visible edges do not extend past tile boundary', () => {
const core = new ThumbnailCore();
const thumbnail = createImage({ coords: { x: 512, y: 320 } });
const result = core.resize(thumbnail, 2560, 1600, {
minWidth: 0,
maxWidth: 177,
minHeight: 0,
maxHeight: Infinity,
});
const scale = result!.scale;
const nextTileX = (512 + 256) * scale;
const nextTileY = (320 + 160) * scale;
expect(result!.offsetX + result!.containerWidth).toBeLessThanOrEqual(nextTileX);
expect(result!.offsetY + result!.containerHeight).toBeLessThanOrEqual(nextTileY);
});
it('container dimensions are stable across different tile positions', () => {
const core = new ThumbnailCore();
const positions = [
{ x: 0, y: 0 },
{ x: 256, y: 0 },
{ x: 512, y: 0 },
{ x: 768, y: 0 },
{ x: 0, y: 160 },
{ x: 256, y: 160 },
{ x: 512, y: 320 },
{ x: 768, y: 480 },
];
const constraints = { minWidth: 0, maxWidth: 177, minHeight: 0, maxHeight: Infinity };
const results = positions.map((coords) => core.resize(createImage({ coords }), 2560, 1600, constraints));
const widths = new Set(results.map((r) => r!.containerWidth));
const heights = new Set(results.map((r) => r!.containerHeight));
expect(widths.size).toBe(1);
expect(heights.size).toBe(1);
});
it('clamps container dimensions to zero at extreme scales', () => {
const core = new ThumbnailCore();
const thumbnail = createImage();
// maxWidth 3 / tileWidth 256 → scale so small that floor(h*s) - 2 would be negative.
const result = core.resize(thumbnail, 2560, 1600, {
minWidth: 0,
maxWidth: 3,
minHeight: 0,
maxHeight: Infinity,
});
expect(result!.containerWidth).toBeGreaterThanOrEqual(0);
expect(result!.containerHeight).toBeGreaterThanOrEqual(0);
});
it('returns undefined when dimensions are unavailable', () => {
const core = new ThumbnailCore();
const thumbnail: ThumbnailImage = { url: 'thumb.jpg', startTime: 0, endTime: 5 };
@@ -117,14 +117,24 @@ export class ThumbnailCore {
const scale = this.calculateScale(tileWidth, tileHeight, constraints);
const coordX = thumbnail.coords?.x ?? 0;
const coordY = thumbnail.coords?.y ?? 0;
// Inset by 1px to eat the interpolation fringe the browser introduces when
// scaling the sprite sheet (bilinear filtering blends across tile boundaries).
const inset = scale !== 1 ? 1 : 0;
return {
scale,
containerWidth: Math.round(tileWidth * scale),
containerHeight: Math.round(tileHeight * scale),
imageWidth: Math.round(imgNaturalWidth * scale),
imageHeight: Math.round(imgNaturalHeight * scale),
offsetX: Math.round((thumbnail.coords?.x ?? 0) * scale),
offsetY: Math.round((thumbnail.coords?.y ?? 0) * scale),
// Floor container so it never extends past the tile boundary.
containerWidth: Math.max(0, Math.floor(tileWidth * scale) - inset * 2),
containerHeight: Math.max(0, Math.floor(tileHeight * scale) - inset * 2),
// Ceil image so the sprite sheet always fills the container.
imageWidth: Math.ceil(imgNaturalWidth * scale),
imageHeight: Math.ceil(imgNaturalHeight * scale),
// Ceil offset so it never undershoots the tile origin (prevents top/left bleed).
offsetX: Math.ceil(coordX * scale) + inset,
offsetY: Math.ceil(coordY * scale) + inset,
};
}