diff --git a/README.md b/README.md index 1bb7028..107e218 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,9 @@ output ### Input multiple images -blockSize: `50` +blockSize: `50` (with `--cross-image-shuffle`) + +When processing multiple images with cross-image shuffle enabled, blocks are shuffled across all images, creating a unified fragmentation pattern. | input 1 | input 2 | input 3 | |:-------:|:---------------:|:---------------:| @@ -152,7 +154,8 @@ manifest.json: "blockSize": 2, "prefix": "img", "seed": 72411, - "preserveName": false + "preserveName": false, + "crossImageShuffle": false }, "images": [ { diff --git a/packages/cli/README.md b/packages/cli/README.md index 289b6ea..3b6fcec 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -50,6 +50,7 @@ image-shield shuffle -o [options] | `-p, --prefix ` | Prefix for fragment files | ❌ | "fragment" | | `-s, --seed ` | Random seed (integer) | ❌ | auto-generated | | `--preserve-name` | Preserve original file names | ❌ | false | +| `--cross-image-shuffle` | Shuffle blocks across all images instead of within each image independently | ❌ | false (per-image shuffle by default) | #### Examples @@ -68,6 +69,11 @@ image-shield shuffle *.jpg -o ./output -b 20 -p "my_fragment" --preserve-name image-shield shuffle image.png -o ./output -s 12345 ``` +**Cross-image shuffle (shuffle blocks across all images):** +```bash +image-shield shuffle image1.png image2.png image3.png -o ./output --cross-image-shuffle +``` + #### Output Structure After fragmentation, the output directory will contain: diff --git a/packages/cli/src/commands/shuffle.ts b/packages/cli/src/commands/shuffle.ts index 1640fa6..c978c76 100644 --- a/packages/cli/src/commands/shuffle.ts +++ b/packages/cli/src/commands/shuffle.ts @@ -30,6 +30,10 @@ export function registerShuffleCommand(program: Command): void { return num; }) .option("--preserve-name", "Preserve original file names") + .option( + "--cross-image-shuffle", + "Shuffle blocks across all images instead of within each image independently", + ) .action(handleShuffleCommand); } @@ -53,6 +57,7 @@ async function handleShuffleCommand( if (options.prefix !== undefined) config.prefix = options.prefix; if (options.seed !== undefined) config.seed = options.seed; if (options.preserveName) config.preserveName = true; + if (options.crossImageShuffle) config.crossImageShuffle = true; await ImageShield.shuffle({ imagePaths, diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 60d2c47..0bab7d9 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -4,6 +4,7 @@ export interface ShuffleOptions { prefix?: string; seed?: number; preserveName?: boolean; + crossImageShuffle?: boolean; } export interface RestoreOptions { diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index b1d717d..3706858 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -4,4 +4,5 @@ export const DEFAULT_FRAGMENTATION_CONFIG = { BLOCK_SIZE: 2, PREFIX: "img", PRESERVE_NAME: false, + CROSS_IMAGE_SHUFFLE: false, }; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index de8de86..32d1942 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -25,6 +25,8 @@ export interface FragmentationConfig { seed?: number | string; /** Preserve original file name (optional, default: false) */ preserveName?: boolean; + /** Shuffle blocks across all images instead of within each image independently (optional, default: false) */ + crossImageShuffle?: boolean; } /** diff --git a/packages/node/src/fragmenter.test.ts b/packages/node/src/fragmenter.test.ts index 190cacc..5b23e4c 100644 --- a/packages/node/src/fragmenter.test.ts +++ b/packages/node/src/fragmenter.test.ts @@ -275,4 +275,256 @@ describe("ImageFragmenter", () => { } }); }); + + describe("crossImageShuffle", () => { + test("fragments with per-image shuffle (default)", async () => { + const fragmenter = new ImageFragmenter({ + blockSize: 2, + seed: "test-seed", + }); + + const result = await fragmenter.fragmentImages([testImagePath]); + + expect(result.manifest.config.crossImageShuffle).toBe(false); + expect(result.fragmentedImages).toHaveLength(1); + }); + + test("fragments multiple images with per-image shuffle (default)", async () => { + const fragmenter = new ImageFragmenter({ + blockSize: 2, + seed: "test-seed", + }); + + const result = await fragmenter.fragmentImages([ + testImagePath, + testImagePath, + ]); + + expect(result.manifest.config.crossImageShuffle).toBe(false); + expect(result.manifest.images).toHaveLength(2); + expect(result.fragmentedImages).toHaveLength(2); + }); + + test("per-image shuffle produces different results than cross-image shuffle", async () => { + const seed = "test-seed-123"; + + const fragmenterCrossImage = new ImageFragmenter({ + blockSize: 2, + seed, + crossImageShuffle: true, + }); + + const fragmenterPerImage = new ImageFragmenter({ + blockSize: 2, + seed, + crossImageShuffle: false, + }); + + const resultCrossImage = await fragmenterCrossImage.fragmentImages([ + testImagePath, + testImagePath, + ]); + + const resultPerImage = await fragmenterPerImage.fragmentImages([ + testImagePath, + testImagePath, + ]); + + // With the same seed but different shuffle modes, results should differ + expect(resultCrossImage.fragmentedImages[0]).not.toEqual( + resultPerImage.fragmentedImages[0], + ); + }); + + test("defaults to per-image shuffle when crossImageShuffle is not specified", async () => { + const fragmenter = new ImageFragmenter({ + blockSize: 2, + seed: "test-seed", + }); + + const result = await fragmenter.fragmentImages([testImagePath]); + + expect(result.manifest.config.crossImageShuffle).toBe(false); + }); + + test("handles images of different sizes with per-image shuffle", async () => { + // Create test images of different sizes + const smallImagePath = path.join(tmpDir, "small_test.png"); + const largeImagePath = path.join(tmpDir, "large_test.png"); + + // Create 2x2 image + const smallImageData = Buffer.from([ + 255, + 0, + 0, + 255, // Red + 0, + 255, + 0, + 255, // Green + 0, + 0, + 255, + 255, // Blue + 255, + 255, + 0, + 255, // Yellow + ]); + const smallImage = Jimp.fromBitmap({ + data: smallImageData, + width: 2, + height: 2, + }); + await smallImage.write(smallImagePath, JimpMime.png); + + // Create 6x6 image + const largeImageData = Buffer.alloc(6 * 6 * 4); + for (let i = 0; i < 6 * 6 * 4; i += 4) { + largeImageData[i] = 128; // R + largeImageData[i + 1] = 128; // G + largeImageData[i + 2] = 128; // B + largeImageData[i + 3] = 255; // A + } + const largeImage = Jimp.fromBitmap({ + data: largeImageData, + width: 6, + height: 6, + }); + await largeImage.write(largeImagePath, JimpMime.png); + + try { + const fragmenter = new ImageFragmenter({ + blockSize: 2, + seed: "test-seed", + crossImageShuffle: false, + }); + + const result = await fragmenter.fragmentImages([ + smallImagePath, + largeImagePath, + ]); + + // Should successfully fragment both images + expect(result.manifest.images).toHaveLength(2); + expect(result.fragmentedImages).toHaveLength(2); + expect(result.manifest.config.crossImageShuffle).toBe(false); + + // Verify different block counts + expect(result.manifest.images[0].x).toBe(1); // 2x2 image with blockSize 2 + expect(result.manifest.images[0].y).toBe(1); + expect(result.manifest.images[1].x).toBe(3); // 6x6 image with blockSize 2 + expect(result.manifest.images[1].y).toBe(3); + } finally { + // Clean up + if (fs.existsSync(smallImagePath)) fs.unlinkSync(smallImagePath); + if (fs.existsSync(largeImagePath)) fs.unlinkSync(largeImagePath); + } + }); + + test("fragments with cross-image shuffle enabled", async () => { + const fragmenter = new ImageFragmenter({ + blockSize: 2, + seed: "test-seed", + crossImageShuffle: true, + }); + + const result = await fragmenter.fragmentImages([testImagePath]); + + expect(result.manifest.config.crossImageShuffle).toBe(true); + expect(result.fragmentedImages).toHaveLength(1); + }); + + test("fragments multiple images with cross-image shuffle", async () => { + const fragmenter = new ImageFragmenter({ + blockSize: 2, + seed: "test-seed", + crossImageShuffle: true, + }); + + const result = await fragmenter.fragmentImages([ + testImagePath, + testImagePath, + ]); + + expect(result.manifest.config.crossImageShuffle).toBe(true); + expect(result.manifest.images).toHaveLength(2); + expect(result.fragmentedImages).toHaveLength(2); + }); + + test("handles images of different sizes with cross-image shuffle", async () => { + // Create test images of different sizes + const smallImagePath = path.join(tmpDir, "small_cross_test.png"); + const largeImagePath = path.join(tmpDir, "large_cross_test.png"); + + // Create 2x2 image + const smallImageData = Buffer.from([ + 255, + 0, + 0, + 255, // Red + 0, + 255, + 0, + 255, // Green + 0, + 0, + 255, + 255, // Blue + 255, + 255, + 0, + 255, // Yellow + ]); + const smallImage = Jimp.fromBitmap({ + data: smallImageData, + width: 2, + height: 2, + }); + await smallImage.write(smallImagePath, JimpMime.png); + + // Create 6x6 image + const largeImageData = Buffer.alloc(6 * 6 * 4); + for (let i = 0; i < 6 * 6 * 4; i += 4) { + largeImageData[i] = 128; // R + largeImageData[i + 1] = 128; // G + largeImageData[i + 2] = 128; // B + largeImageData[i + 3] = 255; // A + } + const largeImage = Jimp.fromBitmap({ + data: largeImageData, + width: 6, + height: 6, + }); + await largeImage.write(largeImagePath, JimpMime.png); + + try { + const fragmenter = new ImageFragmenter({ + blockSize: 2, + seed: "test-seed", + crossImageShuffle: true, + }); + + const result = await fragmenter.fragmentImages([ + smallImagePath, + largeImagePath, + ]); + + // Should successfully fragment both images + expect(result.manifest.images).toHaveLength(2); + expect(result.fragmentedImages).toHaveLength(2); + expect(result.manifest.config.crossImageShuffle).toBe(true); + + // Verify different block counts + expect(result.manifest.images[0].x).toBe(1); // 2x2 image with blockSize 2 + expect(result.manifest.images[0].y).toBe(1); + expect(result.manifest.images[1].x).toBe(3); // 6x6 image with blockSize 2 + expect(result.manifest.images[1].y).toBe(3); + } finally { + // Clean up + if (fs.existsSync(smallImagePath)) fs.unlinkSync(smallImagePath); + if (fs.existsSync(largeImagePath)) fs.unlinkSync(largeImagePath); + } + }); + }); }); diff --git a/packages/node/src/fragmenter.ts b/packages/node/src/fragmenter.ts index 8e265a4..72305a4 100644 --- a/packages/node/src/fragmenter.ts +++ b/packages/node/src/fragmenter.ts @@ -30,18 +30,27 @@ export class ImageFragmenter { seed: config.seed || SeededRandom.generateSeed(), preserveName: config.preserveName ?? DEFAULT_FRAGMENTATION_CONFIG.PRESERVE_NAME, + crossImageShuffle: + config.crossImageShuffle ?? + DEFAULT_FRAGMENTATION_CONFIG.CROSS_IMAGE_SHUFFLE, }; } async fragmentImages(imagePaths: string[]): Promise { - const { manifest, allBlocks, fragmentBlocksCount } = + const { manifest, allBlocks, fragmentBlocksCount, imageBlockCounts } = await this._prepareFragmentData(imagePaths); - const shuffledBlocks = shuffle(allBlocks, manifest.config.seed); + const shuffledBlocks = this.config.crossImageShuffle + ? shuffle(allBlocks, manifest.config.seed) + : this._shufflePerImage( + allBlocks, + imageBlockCounts, + manifest.config.seed, + ); const fragmentedImages = await this._createFragmentedImages( shuffledBlocks, - fragmentBlocksCount, + this.config.crossImageShuffle ? fragmentBlocksCount : imageBlockCounts, manifest, ); @@ -71,6 +80,24 @@ export class ImageFragmenter { ); } + private _shufflePerImage( + allBlocks: Buffer[], + fragmentBlocksCount: number[], + seed: number | string, + ): Buffer[] { + const shuffledBlocks: Buffer[] = []; + let offset = 0; + + for (const blockCount of fragmentBlocksCount) { + const imageBlocks = allBlocks.slice(offset, offset + blockCount); + const shuffled = shuffle(imageBlocks, seed); + shuffledBlocks.push(...shuffled); + offset += blockCount; + } + + return shuffledBlocks; + } + private _calculateBlockRange( fragmentBlocksCount: number[], targetIndex: number, @@ -127,6 +154,7 @@ export class ImageFragmenter { manifest: ManifestData; allBlocks: Buffer[]; fragmentBlocksCount: number[]; + imageBlockCounts: number[]; }> { const manifestId = generateManifestId(); @@ -140,7 +168,10 @@ export class ImageFragmenter { imagePaths.length, ); - return { manifest, allBlocks, fragmentBlocksCount }; + // Calculate actual block counts per image for per-image shuffle + const imageBlockCounts = imageInfos.map((info) => info.x * info.y); + + return { manifest, allBlocks, fragmentBlocksCount, imageBlockCounts }; } private async _processSourceImages(imagePaths: string[]): Promise<{ diff --git a/packages/node/src/restorer.test.ts b/packages/node/src/restorer.test.ts index 68c3576..e8dc019 100644 --- a/packages/node/src/restorer.test.ts +++ b/packages/node/src/restorer.test.ts @@ -282,6 +282,27 @@ describe("ImageRestorer", () => { expect(restoredJimp.bitmap.data.length).toBe( originalJimp.bitmap.data.length, ); + + // Verify pixel RGBA values match + for (let y = 0; y < originalJimp.bitmap.height; y++) { + for (let x = 0; x < originalJimp.bitmap.width; x++) { + const pixelIndex = (y * originalJimp.bitmap.width + x) * 4; + const originalR = originalJimp.bitmap.data[pixelIndex]; + const originalG = originalJimp.bitmap.data[pixelIndex + 1]; + const originalB = originalJimp.bitmap.data[pixelIndex + 2]; + const originalA = originalJimp.bitmap.data[pixelIndex + 3]; + + const restoredR = restoredJimp.bitmap.data[pixelIndex]; + const restoredG = restoredJimp.bitmap.data[pixelIndex + 1]; + const restoredB = restoredJimp.bitmap.data[pixelIndex + 2]; + const restoredA = restoredJimp.bitmap.data[pixelIndex + 3]; + + expect(restoredR).toBe(originalR); + expect(restoredG).toBe(originalG); + expect(restoredB).toBe(originalB); + expect(restoredA).toBe(originalA); + } + } }); }); @@ -383,4 +404,469 @@ describe("ImageRestorer", () => { expect(manifest.config.prefix).toBe("custom-prefix"); }); }); + + describe("crossImageShuffle restoration", () => { + test("restores single image with per-image shuffle (default)", async () => { + const fragmenter = new ImageFragmenter({ + blockSize: 2, + seed: "test-seed", + }); + const { manifest, fragmentedImages } = await fragmenter.fragmentImages([ + testImagePath, + ]); + + const restorer = new ImageRestorer(); + const restoredImages = await restorer.restoreImages( + fragmentedImages, + manifest, + ); + + expect(restoredImages).toHaveLength(1); + expect(manifest.config.crossImageShuffle).toBe(false); + + const jimpImage = await Jimp.read(restoredImages[0]); + expect(jimpImage.bitmap.width).toBe(4); + expect(jimpImage.bitmap.height).toBe(4); + }); + + test("restores multiple images with per-image shuffle (default)", async () => { + const fragmenter = new ImageFragmenter({ + blockSize: 2, + seed: "test-seed", + }); + const { manifest, fragmentedImages } = await fragmenter.fragmentImages([ + testImagePath, + testImagePath, + ]); + + const restorer = new ImageRestorer(); + const restoredImages = await restorer.restoreImages( + fragmentedImages, + manifest, + ); + + expect(restoredImages).toHaveLength(2); + expect(manifest.config.crossImageShuffle).toBe(false); + + for (const restoredImage of restoredImages) { + const jimpImage = await Jimp.read(restoredImage); + expect(jimpImage.bitmap.width).toBe(4); + expect(jimpImage.bitmap.height).toBe(4); + } + }); + + test("round-trip with per-image shuffle preserves image data", async () => { + const fragmenter = new ImageFragmenter({ + blockSize: 1, + seed: "deterministic-seed", + crossImageShuffle: false, + }); + + const { manifest, fragmentedImages } = await fragmenter.fragmentImages([ + testImagePath, + ]); + + const restorer = new ImageRestorer(); + const restoredImages = await restorer.restoreImages( + fragmentedImages, + manifest, + ); + + const originalJimp = await Jimp.read(testImagePath); + const restoredJimp = await Jimp.read(restoredImages[0]); + + expect(restoredJimp.bitmap.width).toBe(originalJimp.bitmap.width); + expect(restoredJimp.bitmap.height).toBe(originalJimp.bitmap.height); + expect(restoredJimp.bitmap.data.length).toBe( + originalJimp.bitmap.data.length, + ); + + // Verify pixel RGBA values match + for (let y = 0; y < originalJimp.bitmap.height; y++) { + for (let x = 0; x < originalJimp.bitmap.width; x++) { + const pixelIndex = (y * originalJimp.bitmap.width + x) * 4; + const originalR = originalJimp.bitmap.data[pixelIndex]; + const originalG = originalJimp.bitmap.data[pixelIndex + 1]; + const originalB = originalJimp.bitmap.data[pixelIndex + 2]; + const originalA = originalJimp.bitmap.data[pixelIndex + 3]; + + const restoredR = restoredJimp.bitmap.data[pixelIndex]; + const restoredG = restoredJimp.bitmap.data[pixelIndex + 1]; + const restoredB = restoredJimp.bitmap.data[pixelIndex + 2]; + const restoredA = restoredJimp.bitmap.data[pixelIndex + 3]; + + expect(restoredR).toBe(originalR); + expect(restoredG).toBe(originalG); + expect(restoredB).toBe(originalB); + expect(restoredA).toBe(originalA); + } + } + }); + + test("round-trip with multiple images and per-image shuffle", async () => { + const fragmenter = new ImageFragmenter({ + blockSize: 1, + seed: "deterministic-seed", + crossImageShuffle: false, + }); + + const { manifest, fragmentedImages } = await fragmenter.fragmentImages([ + testImagePath, + testImagePath, + ]); + + const restorer = new ImageRestorer(); + const restoredImages = await restorer.restoreImages( + fragmentedImages, + manifest, + ); + + expect(restoredImages).toHaveLength(2); + + const originalJimp = await Jimp.read(testImagePath); + for (const restoredImage of restoredImages) { + const restoredJimp = await Jimp.read(restoredImage); + expect(restoredJimp.bitmap.width).toBe(originalJimp.bitmap.width); + expect(restoredJimp.bitmap.height).toBe(originalJimp.bitmap.height); + expect(restoredJimp.bitmap.data.length).toBe( + originalJimp.bitmap.data.length, + ); + + // Verify pixel RGBA values match + for (let y = 0; y < originalJimp.bitmap.height; y++) { + for (let x = 0; x < originalJimp.bitmap.width; x++) { + const pixelIndex = (y * originalJimp.bitmap.width + x) * 4; + const originalR = originalJimp.bitmap.data[pixelIndex]; + const originalG = originalJimp.bitmap.data[pixelIndex + 1]; + const originalB = originalJimp.bitmap.data[pixelIndex + 2]; + const originalA = originalJimp.bitmap.data[pixelIndex + 3]; + + const restoredR = restoredJimp.bitmap.data[pixelIndex]; + const restoredG = restoredJimp.bitmap.data[pixelIndex + 1]; + const restoredB = restoredJimp.bitmap.data[pixelIndex + 2]; + const restoredA = restoredJimp.bitmap.data[pixelIndex + 3]; + + expect(restoredR).toBe(originalR); + expect(restoredG).toBe(originalG); + expect(restoredB).toBe(originalB); + expect(restoredA).toBe(originalA); + } + } + } + }); + + test("round-trip with different-sized images and per-image shuffle", async () => { + // Create test images of different sizes + const smallImagePath = path.join(tmpDir, "small_restore_test.png"); + const largeImagePath = path.join(tmpDir, "large_restore_test.png"); + + // Create 2x2 image + const smallImageData = Buffer.from([ + 255, + 0, + 0, + 255, // Red + 0, + 255, + 0, + 255, // Green + 0, + 0, + 255, + 255, // Blue + 255, + 255, + 0, + 255, // Yellow + ]); + const smallImage = Jimp.fromBitmap({ + data: smallImageData, + width: 2, + height: 2, + }); + await smallImage.write(smallImagePath, JimpMime.png); + + // Create 6x6 image + const largeImageData = Buffer.alloc(6 * 6 * 4); + for (let i = 0; i < 6 * 6 * 4; i += 4) { + largeImageData[i] = 128; // R + largeImageData[i + 1] = 128; // G + largeImageData[i + 2] = 128; // B + largeImageData[i + 3] = 255; // A + } + const largeImage = Jimp.fromBitmap({ + data: largeImageData, + width: 6, + height: 6, + }); + await largeImage.write(largeImagePath, JimpMime.png); + + try { + const fragmenter = new ImageFragmenter({ + blockSize: 2, + seed: "deterministic-seed", + crossImageShuffle: false, + }); + + const { manifest, fragmentedImages } = await fragmenter.fragmentImages([ + smallImagePath, + largeImagePath, + ]); + + const restorer = new ImageRestorer(); + const restoredImages = await restorer.restoreImages( + fragmentedImages, + manifest, + ); + + expect(restoredImages).toHaveLength(2); + + // Verify restored dimensions match originals + const smallJimp = await Jimp.read(restoredImages[0]); + const largeJimp = await Jimp.read(restoredImages[1]); + + expect(smallJimp.bitmap.width).toBe(2); + expect(smallJimp.bitmap.height).toBe(2); + expect(largeJimp.bitmap.width).toBe(6); + expect(largeJimp.bitmap.height).toBe(6); + } finally { + // Clean up + if (fs.existsSync(smallImagePath)) fs.unlinkSync(smallImagePath); + if (fs.existsSync(largeImagePath)) fs.unlinkSync(largeImagePath); + } + }); + + test("restores single image with cross-image shuffle", async () => { + const fragmenter = new ImageFragmenter({ + blockSize: 2, + seed: "test-seed", + crossImageShuffle: true, + }); + const { manifest, fragmentedImages } = await fragmenter.fragmentImages([ + testImagePath, + ]); + + const restorer = new ImageRestorer(); + const restoredImages = await restorer.restoreImages( + fragmentedImages, + manifest, + ); + + expect(restoredImages).toHaveLength(1); + expect(manifest.config.crossImageShuffle).toBe(true); + + const jimpImage = await Jimp.read(restoredImages[0]); + expect(jimpImage.bitmap.width).toBe(4); + expect(jimpImage.bitmap.height).toBe(4); + }); + + test("restores multiple images with cross-image shuffle", async () => { + const fragmenter = new ImageFragmenter({ + blockSize: 2, + seed: "test-seed", + crossImageShuffle: true, + }); + const { manifest, fragmentedImages } = await fragmenter.fragmentImages([ + testImagePath, + testImagePath, + ]); + + const restorer = new ImageRestorer(); + const restoredImages = await restorer.restoreImages( + fragmentedImages, + manifest, + ); + + expect(restoredImages).toHaveLength(2); + expect(manifest.config.crossImageShuffle).toBe(true); + + for (const restoredImage of restoredImages) { + const jimpImage = await Jimp.read(restoredImage); + expect(jimpImage.bitmap.width).toBe(4); + expect(jimpImage.bitmap.height).toBe(4); + } + }); + + test("round-trip with cross-image shuffle preserves image data", async () => { + const fragmenter = new ImageFragmenter({ + blockSize: 1, + seed: "deterministic-seed", + crossImageShuffle: true, + }); + + const { manifest, fragmentedImages } = await fragmenter.fragmentImages([ + testImagePath, + ]); + + const restorer = new ImageRestorer(); + const restoredImages = await restorer.restoreImages( + fragmentedImages, + manifest, + ); + + const originalJimp = await Jimp.read(testImagePath); + const restoredJimp = await Jimp.read(restoredImages[0]); + + expect(restoredJimp.bitmap.width).toBe(originalJimp.bitmap.width); + expect(restoredJimp.bitmap.height).toBe(originalJimp.bitmap.height); + expect(restoredJimp.bitmap.data.length).toBe( + originalJimp.bitmap.data.length, + ); + + // Verify pixel RGBA values match + for (let y = 0; y < originalJimp.bitmap.height; y++) { + for (let x = 0; x < originalJimp.bitmap.width; x++) { + const pixelIndex = (y * originalJimp.bitmap.width + x) * 4; + const originalR = originalJimp.bitmap.data[pixelIndex]; + const originalG = originalJimp.bitmap.data[pixelIndex + 1]; + const originalB = originalJimp.bitmap.data[pixelIndex + 2]; + const originalA = originalJimp.bitmap.data[pixelIndex + 3]; + + const restoredR = restoredJimp.bitmap.data[pixelIndex]; + const restoredG = restoredJimp.bitmap.data[pixelIndex + 1]; + const restoredB = restoredJimp.bitmap.data[pixelIndex + 2]; + const restoredA = restoredJimp.bitmap.data[pixelIndex + 3]; + + expect(restoredR).toBe(originalR); + expect(restoredG).toBe(originalG); + expect(restoredB).toBe(originalB); + expect(restoredA).toBe(originalA); + } + } + }); + + test("round-trip with multiple images and cross-image shuffle", async () => { + const fragmenter = new ImageFragmenter({ + blockSize: 1, + seed: "deterministic-seed", + crossImageShuffle: true, + }); + + const { manifest, fragmentedImages } = await fragmenter.fragmentImages([ + testImagePath, + testImagePath, + ]); + + const restorer = new ImageRestorer(); + const restoredImages = await restorer.restoreImages( + fragmentedImages, + manifest, + ); + + expect(restoredImages).toHaveLength(2); + + const originalJimp = await Jimp.read(testImagePath); + for (const restoredImage of restoredImages) { + const restoredJimp = await Jimp.read(restoredImage); + expect(restoredJimp.bitmap.width).toBe(originalJimp.bitmap.width); + expect(restoredJimp.bitmap.height).toBe(originalJimp.bitmap.height); + expect(restoredJimp.bitmap.data.length).toBe( + originalJimp.bitmap.data.length, + ); + + // Verify pixel RGBA values match + for (let y = 0; y < originalJimp.bitmap.height; y++) { + for (let x = 0; x < originalJimp.bitmap.width; x++) { + const pixelIndex = (y * originalJimp.bitmap.width + x) * 4; + const originalR = originalJimp.bitmap.data[pixelIndex]; + const originalG = originalJimp.bitmap.data[pixelIndex + 1]; + const originalB = originalJimp.bitmap.data[pixelIndex + 2]; + const originalA = originalJimp.bitmap.data[pixelIndex + 3]; + + const restoredR = restoredJimp.bitmap.data[pixelIndex]; + const restoredG = restoredJimp.bitmap.data[pixelIndex + 1]; + const restoredB = restoredJimp.bitmap.data[pixelIndex + 2]; + const restoredA = restoredJimp.bitmap.data[pixelIndex + 3]; + + expect(restoredR).toBe(originalR); + expect(restoredG).toBe(originalG); + expect(restoredB).toBe(originalB); + expect(restoredA).toBe(originalA); + } + } + } + }); + + test("round-trip with different-sized images and cross-image shuffle", async () => { + // Create test images of different sizes + const smallImagePath = path.join(tmpDir, "small_cross_restore_test.png"); + const largeImagePath = path.join(tmpDir, "large_cross_restore_test.png"); + + // Create 2x2 image + const smallImageData = Buffer.from([ + 255, + 0, + 0, + 255, // Red + 0, + 255, + 0, + 255, // Green + 0, + 0, + 255, + 255, // Blue + 255, + 255, + 0, + 255, // Yellow + ]); + const smallImage = Jimp.fromBitmap({ + data: smallImageData, + width: 2, + height: 2, + }); + await smallImage.write(smallImagePath, JimpMime.png); + + // Create 6x6 image + const largeImageData = Buffer.alloc(6 * 6 * 4); + for (let i = 0; i < 6 * 6 * 4; i += 4) { + largeImageData[i] = 128; // R + largeImageData[i + 1] = 128; // G + largeImageData[i + 2] = 128; // B + largeImageData[i + 3] = 255; // A + } + const largeImage = Jimp.fromBitmap({ + data: largeImageData, + width: 6, + height: 6, + }); + await largeImage.write(largeImagePath, JimpMime.png); + + try { + const fragmenter = new ImageFragmenter({ + blockSize: 2, + seed: "deterministic-seed", + crossImageShuffle: true, + }); + + const { manifest, fragmentedImages } = await fragmenter.fragmentImages([ + smallImagePath, + largeImagePath, + ]); + + const restorer = new ImageRestorer(); + const restoredImages = await restorer.restoreImages( + fragmentedImages, + manifest, + ); + + expect(restoredImages).toHaveLength(2); + expect(manifest.config.crossImageShuffle).toBe(true); + + // Verify restored dimensions match originals + const smallJimp = await Jimp.read(restoredImages[0]); + const largeJimp = await Jimp.read(restoredImages[1]); + + expect(smallJimp.bitmap.width).toBe(2); + expect(smallJimp.bitmap.height).toBe(2); + expect(largeJimp.bitmap.width).toBe(6); + expect(largeJimp.bitmap.height).toBe(6); + } finally { + // Clean up + if (fs.existsSync(smallImagePath)) fs.unlinkSync(smallImagePath); + if (fs.existsSync(largeImagePath)) fs.unlinkSync(largeImagePath); + } + }); + }); }); diff --git a/packages/node/src/restorer.ts b/packages/node/src/restorer.ts index f02da7e..e3efd16 100644 --- a/packages/node/src/restorer.ts +++ b/packages/node/src/restorer.ts @@ -12,12 +12,18 @@ export class ImageRestorer { fragmentImages: (string | Buffer)[], manifest: ManifestData, ): Promise { - const { allBlocks } = await this._prepareRestoreData( + const { allBlocks, imageBlockCounts } = await this._prepareRestoreData( fragmentImages, manifest, ); - const restoredBlocks = unshuffle(allBlocks, manifest.config.seed); + const restoredBlocks = manifest.config.crossImageShuffle + ? unshuffle(allBlocks, manifest.config.seed) + : this._unshufflePerImage( + allBlocks, + imageBlockCounts, + manifest.config.seed, + ); const reconstructedImages = await this._reconstructAllImages( restoredBlocks, @@ -63,7 +69,10 @@ export class ImageRestorer { private async _prepareRestoreData( fragmentImages: (string | Buffer)[], manifest: ManifestData, - ): Promise<{ allBlocks: Buffer[]; fragmentBlocksCount: number[] }> { + ): Promise<{ + allBlocks: Buffer[]; + imageBlockCounts: number[]; + }> { this._validateInputs(fragmentImages, manifest); const totalBlocks = this._calculateTotalBlocks(manifest.images); @@ -72,13 +81,21 @@ export class ImageRestorer { fragmentImages.length, ); + // Calculate actual block counts per image for per-image unshuffle + const imageBlockCounts = manifest.images.map((info) => info.x * info.y); + + // Use imageBlockCounts when crossImageShuffle is false + const blocksCounts = manifest.config.crossImageShuffle + ? fragmentBlocksCount + : imageBlockCounts; + const allBlocks = await this._extractBlocksFromFragments( fragmentImages, manifest, - fragmentBlocksCount, + blocksCounts, ); - return { allBlocks, fragmentBlocksCount }; + return { allBlocks, imageBlockCounts }; } private _validateInputs( @@ -141,4 +158,22 @@ export class ImageRestorer { const { w, h } = imageInfo; return await blocksToPngImage(blocks, w, h, blockSize); } + + private _unshufflePerImage( + allBlocks: Buffer[], + fragmentBlocksCount: number[], + seed: number | string, + ): Buffer[] { + const unshuffledBlocks: Buffer[] = []; + let offset = 0; + + for (const blockCount of fragmentBlocksCount) { + const imageBlocks = allBlocks.slice(offset, offset + blockCount); + const unshuffled = unshuffle(imageBlocks, seed); + unshuffledBlocks.push(...unshuffled); + offset += blockCount; + } + + return unshuffledBlocks; + } }