@amplib/steganography

GitHub ↗

Stegassette encodes payloads — text, audio, or arbitrary bytes — into image pixels using the STGC format. Every payload pixel needs a paired key pixel to decode it. The image describes its own encoding, too: combine op, keymap, traversal, and border width are all written into the border, so decode needs nothing but the image. Each option is documented in the library's types.

STGC needs pixels back exactly as written, so it does not survive a JPEG re-save. Stegaprint is a prototype format for JPEG, with about 160× less capacity.

Stegassette

Encoding a basic text entry.

Stegassette.encode({ source: HTMLImageElement | HTMLCanvasElement; entries: Entry[]; combine?: CombineName; keymap?: KeymapName; traversal?: TraversalName; params?: TraversalParams; fit?: FitMode; border?: number; aspectRatio?: number; }): HTMLCanvasElement

const source = await loadImageFromImageUrl({
  url: "./example.jpg"
});
const encoded = Stegassette.encode({
  source,
  entries: [{
    mimetype: "text/plain",
    name: "message.txt",
    data: "",
  }],
  combine: "", // how a byte merges with its key pixel
  keymap: "", // where each byte's key pixel lives
  traversal: "", // the order the payload fills pixels
  border: ,
  aspectRatio: ,
});
source
encoded

Stegassette.decode({ source: HTMLImageElement | HTMLCanvasElement | StegaImageData }): { entries: DecodedEntry[]; opts: StgcOpts }

const { entries, opts } = Stegassette.decode({ source: encoded });
const message = new TextDecoder().decode(entries[0].data);

The STGC header lives in the border ring, so a small image is widened until the header fits, not grown all over to make room.

Audio entries

buildAudioEntry turns decoded PCM channels into an entry. The mimetype carries sample rate, bit depth and channel layout, so a decoded image can rebuild the buffer on its own.

Stegassette.buildAudioEntry({ channels: Float32Array[]; sampleRate: number; bitsPerSample?: 8 | 16 | 24; layout?: "planar" | "interleaved" | "block"; blockSize?: number; name?: string; }): Entry

const audioBuffers = await loadAudioBuffersFromAudioUrl({
  url: "./example.mp3",
  audioContext,
  channels: ,
  sampleRate: ,
});
const audioEntry = Stegassette.buildAudioEntry({
  channels: audioBuffers,
  sampleRate: ,
  bitsPerSample: ,
  layout: "", // how channels share the stream
});
const encoded = Stegassette.encode({
  source,
  entries: [audioEntry],
  combine: "", // how a byte merges with its key pixel
  keymap: "", // where each byte's key pixel lives
  traversal: "", // the order the payload fills pixels
  channels: "", // channels carrying payload; rest keep cover
  border: ,
  aspectRatio: ,
});
source
tap to play

Stegassette.parseAudioEntry(entry: DecodedEntry | Entry): { channels: Float32Array[]; sampleRate: number; bitsPerSample: 8 | 16 | 24; layout: "planar" | "interleaved" | "block"; blockSize: number; }

const { entries, opts } = Stegassette.decode({ source: encoded });
// opts: { combine, keymap, traversal } recovered from STGC header
const { channels, sampleRate } = Stegassette.parseAudioEntry(entries[0]);
const recon = Stegassette.reconstructCover(encoded, opts);
// recon develops on the canvas in sync with audio playback

A keyed keymap reserves the pixels that let reconstructCover bring the picture back; with keymap: "none" the reveal depends entirely on which channels the plan leaves untouched. The line under the encode reports what each choice actually cost.

Audio in pixels

This encode is a demonstration, but the pixels are the actual payload: a black cover under xor leaves every key channel at zero, and a ^ 0 === a, so each channel holds one raw PCM byte verbatim.

Source waveform (drag to move the playhead)
64 sample waveform | 1.3ms of audio
24-bit | 64 colors with red, green, and blue channels separated above | 1 sample per pixel
64 sample waveform as a 8×8px image
Source waveform encoded into image

Stegaprint — prototype

Stegaprint encodes payloads that survive JPEG compression. Payload bytes are written into the low-frequency DCT coefficients of each 8×8 block. The header is a visible border of black and white blocks, since JPEG has no alpha channel to carry it.

The image below is encoded, passed through canvas.toBlob("image/jpeg") at the selected quality, decoded, and read back. The border carries the 40-byte header. The interior carries the payload. Capacity is about 19 KB per megapixel, roughly 160× less than STGC.

Stegaprint.encode({ source: HTMLImageElement | HTMLCanvasElement; entries: PrintEntry[]; modulate?: "qim" | "pair"; keymap?: KeymapName; traversal?: TraversalName; ecc?: "none" | "light" | "full"; repeat?: number | "auto"; width?: number; height?: number; }): HTMLCanvasElement

const encoded = Stegaprint.encode({
  source,
  entries: [{
    type: Stegaprint.EntryType.Text,
    name: "message.txt",
    data: new TextEncoder().encode(""),
  }],
  modulate: "",
  keymap: "",
  traversal: "",
  ecc: "",
});
encoded
through JPEG

Stegaprint.jpegRoundTrip(canvas: HTMLCanvasElement, quality?: number, passes?: number): Promise<{ canvas: HTMLCanvasElement; bytes: number[] }>

const { canvas } = await Stegaprint.jpegRoundTrip(
  encoded,
   / 100,
  ,
);
const { entries, header, registered } = Stegaprint.decode({ source: canvas });
header

Audio through a JPEG

The same example.mp3 as the Stegassette demo above, resampled and encoded as an audio entry, then passed through canvas.toBlob("image/jpeg") and decoded back. What plays is what came out of the JPEG.

Capacity sets the length. One second of 8-bit 8 kHz mono costs 8 KB and the canvas grows with the clip, so 30 seconds is a question of what fidelity you spend it at: 4 kHz 4-bit fits in 3.4 megapixels, 8 kHz 8-bit needs 13.7. Past 12 megapixels this page declines to encode and says so rather than freezing — the arithmetic, not a limit of the format.

Stegaprint.buildAudioEntry({ channels: Float32Array[]; sampleRate: number; bitsPerSample?: 4 | 8 | 16 | 24; name?: string; }): PrintEntry

const channels = await loadAudioBuffersFromAudioUrl({
  url: "./example.mp3",
  audioContext,
  channels: 1,
  sampleRate: ,
});
const encoded = Stegaprint.encode({
  source,
  entries: [Stegaprint.buildAudioEntry({
    channels: [channels[0].slice(0,  * )],
    sampleRate: ,
    bitsPerSample: ,
  })],
  ecc: "",
});
const { canvas } = await Stegaprint.jpegRoundTrip(encoded,  / 100);
const { entries } = Stegaprint.decode({ source: canvas });
const audio = Stegaprint.parseAudioEntry(entries[0]);
encoding…

StegaAnimator

Animate a steganographic image. The source below is the encoded canvas from the first example.

new StegaAnimator({ resolution: number; source: HTMLImageElement | HTMLCanvasElement; fadeAmount?: number; rotationMode?: "2d" | "3d"; shape?: "circle" | "square" | "implicit"; })

const animator = new StegaAnimator({
  source,
  resolution: ,
  fadeAmount: ,
  rotationMode: "",
  shape: ""
});
document.body.appendChild(animator.canvas);
await animator.animate({
  from: { rotation: Math.PI, scale: 0.0, x: 0.5, y: 0.5, },
  to: { rotation: Math.PI * 4, scale: 0.5, x: 0.5, y: 0.5, },
  rate: 0.005,
});
const killLoop = animator.animationLoop([
  {
    from: { rotation: 0, scale: 0.5, x: 0.5, y: 0.5, },
    to: { rotation: Math.PI * 1, scale: 0.6, x: 0.5, y: 0.5, },
    rate: ,
  },
  {
    from: { rotation: Math.PI * 1, scale: 0.6, x: 0.5, y: 0.5, },
    to: { rotation: Math.PI * 2, scale: 0.5, x: 0.5, y: 0.5, },
    rate: ,
  },
]);
killLoop();

Helpers

Loading, playback, and file I/O around the codec. None of them are required to encode or decode — they are the plumbing every example on this page ends up needing.

loadImageFromImageUrl

Load an image from a url string

async loadImageFromImageUrl({ url: string }): Promise<HTMLImageElement>

const source = await loadImageFromImageUrl({
  url: "./example.jpg"
});

loadAudioBuffersFromAudioUrl

Load, downmix and resample an audio file into planar Float32 channels

async loadAudioBuffersFromAudioUrl({ url: string; audioContext: AudioContext; channels: 1 | 2; sampleRate?: number; }): Promise<Float32Array[]>

const audioContext = new AudioContext();
const audioBuffers = await loadAudioBuffersFromAudioUrl({
  url: "./example.mp3",
  audioContext,
  channels: 2,
  sampleRate: audioContext.sampleRate,
});

playDecodedAudioBuffers

Play decoded audio buffers

async playDecodedAudioBuffers({ audioBuffers: Float32Array[]; audioContext: AudioContext; sampleRate?: number; }): Promise<AudioBufferSourceNode>

const source = await playDecodedAudioBuffers({
  audioBuffers,
  audioContext,
  sampleRate: audioContext.sampleRate,
});
source.stop();

createDropReader

Turn an HTML element into a file drop area

createDropReader({ element: HTMLElement; onSuccess: (element: HTMLImageElement | HTMLAudioElement) => void; onFailure?: (message: string) => void; onDragEnter?: () => void; onDragLeave?: () => void; onDrop?: () => void; types?: (AudioType | ImageType)[]; }): void

const element = document.body;
createDropReader({
  element,
  onSuccess: (image) => element.appendChild(image),
  onFailure: (message) => console.error(message),
  onDragEnter: () => element.classList.add("droppable"),
  onDragLeave: () => element.classList.remove("droppable"),
  onDrop: () => element.classList.remove("droppable"),
  types: ["image/*"]
});

createFileReader

Turn an HTML input into a file input

createFileReader({ element: HTMLInputElement; onSuccess: (element: HTMLImageElement | HTMLAudioElement) => void; onBinarySuccess?: (result: { data: Uint8Array; mimeType: string; fileName: string }) => void; onFailure?: (message: string) => void; types?: (AudioType | ImageType | VideoType | "*/*")[]; }): void

const element = document.createElement("input");
createFileReader({
  element,
  onSuccess: (image) => document.body.appendChild(image),
  onFailure: (message) => console.error(message),
  types: ["image/*"]
});

// Or for binary data (any file type)
createFileReader({
  element,
  onBinarySuccess: ({ data, mimeType, fileName }) => {
    console.log(`Loaded ${fileName} (${mimeType}): ${data.length} bytes`);
  },
  types: ["*/*"]
});

readFileAsBytes

Read a file as binary bytes

readFileAsBytes({ file: File }): Promise<{ data: Uint8Array; mimeType: string; fileName: string }>

const input = document.createElement("input");
input.type = "file";
input.onchange = async () => {
  const file = input.files[0];
  const { data, mimeType, fileName } = await readFileAsBytes({ file });
  console.log(`Read ${fileName} (${mimeType}): ${data.length} bytes`);
};

bytesToBlobUrl

Convert binary data to a Blob URL

bytesToBlobUrl({ data: Uint8Array; mimeType: string }): string

const url = bytesToBlobUrl({ data: myBytes, mimeType: "image/png" });
const img = document.createElement("img");
img.src = url;
document.body.appendChild(img);

// Remember to revoke when done
URL.revokeObjectURL(url);

downloadBytes

Download binary data as a file

downloadBytes({ data: Uint8Array; mimeType: string; fileName: string }): void

downloadBytes({
  data: myBytes,
  mimeType: "application/pdf",
  fileName: "document.pdf"
});