mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
fix(core): prevent sprite tile bleeding in thumbnail component (#1053)
Co-authored-by: sampotts <sam@potts.es>
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user