Skip to content
This repository was archived by the owner on Dec 4, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|:-------:|:---------------:|:---------------:|
Expand All @@ -152,7 +154,8 @@ manifest.json:
"blockSize": 2,
"prefix": "img",
"seed": 72411,
"preserveName": false
"preserveName": false,
"crossImageShuffle": false
},
"images": [
{
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ image-shield shuffle <images...> -o <output_directory> [options]
| `-p, --prefix <prefix>` | Prefix for fragment files | ❌ | "fragment" |
| `-s, --seed <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

Expand All @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/commands/shuffle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export interface ShuffleOptions {
prefix?: string;
seed?: number;
preserveName?: boolean;
crossImageShuffle?: boolean;
}

export interface RestoreOptions {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export const DEFAULT_FRAGMENTATION_CONFIG = {
BLOCK_SIZE: 2,
PREFIX: "img",
PRESERVE_NAME: false,
CROSS_IMAGE_SHUFFLE: false,
};
2 changes: 2 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
252 changes: 252 additions & 0 deletions packages/node/src/fragmenter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
});
});
Loading