diff --git a/packages/core/src/core/ui/thumbnail/tests/thumbnail-core.test.ts b/packages/core/src/core/ui/thumbnail/tests/thumbnail-core.test.ts index c91691bd..c0b700eb 100644 --- a/packages/core/src/core/ui/thumbnail/tests/thumbnail-core.test.ts +++ b/packages/core/src/core/ui/thumbnail/tests/thumbnail-core.test.ts @@ -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 }; diff --git a/packages/core/src/core/ui/thumbnail/thumbnail-core.ts b/packages/core/src/core/ui/thumbnail/thumbnail-core.ts index b5fb75aa..9bf982bf 100644 --- a/packages/core/src/core/ui/thumbnail/thumbnail-core.ts +++ b/packages/core/src/core/ui/thumbnail/thumbnail-core.ts @@ -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, }; }