diff --git a/constants/data.js b/constants/data.js
new file mode 100644
index 00000000..52d69977
--- /dev/null
+++ b/constants/data.js
@@ -0,0 +1,5 @@
+
+export const
+ _1KB = 1024,
+ _1MB = _1KB * _1KB,
+ _1GB = _1KB * _1MB;
diff --git a/src/components/generators/StoryboardComponent.jsx b/src/components/generators/StoryboardComponent.jsx
index 74d75eb6..11c67ccf 100644
--- a/src/components/generators/StoryboardComponent.jsx
+++ b/src/components/generators/StoryboardComponent.jsx
@@ -1,23 +1,18 @@
-import {useState, useEffect} from 'react';
+import { useState, useEffect } from 'react';
import classnames from 'classnames';
-import {PlaceholderImg} from '../placeholders/PlaceholderImg.jsx';
-import {ArrayBufferRenderer} from '../renderers/ArrayBufferRenderer.jsx';
-// import {zbencode, zbdecode} from '../../zine/encoding.js';
-import {downloadFile} from '../../utils/http-utils.js';
+import { PlaceholderImg } from '../placeholders/PlaceholderImg.jsx';
+import { ArrayBufferRenderer } from '../renderers/ArrayBufferRenderer.jsx';
import styles from '../../../styles/Storyboard.module.css';
-import {
- zineMagicBytes,
-} from '../../zine/zine-format.js';
-import {
- mainImageKey,
-} from '../../zine/zine-data-specs.js';
+import { zineMagicBytes } from '../../zine/zine-format.js';
+import { mainImageKey } from '../../zine/zine-data-specs.js';
+import { decompressStream } from '../../utils/compression.js';
+import { loadStream, saveCompressedStream } from '../../utils/file/index.js'
-//
const textDecoder = new TextDecoder();
+const defaultFilename = 'storyboard.zine.gz';
-//
const StoryboardPanel = ({
storyboard,
@@ -151,21 +146,24 @@ export const StoryboardComponent = ({
onDrop={drop}
>
-
)
-};
\ No newline at end of file
+};
diff --git a/src/utils/compression.js b/src/utils/compression.js
new file mode 100644
index 00000000..159ca770
--- /dev/null
+++ b/src/utils/compression.js
@@ -0,0 +1,22 @@
+
+
+/**
+ * Compress a blob and return a readable stream.
+ * @param {ReadableStream} stream The stream to compress.
+ * @returns {ReadableStream} The compressed stream.
+ */
+export function compressStream(stream) {
+ //noinspection JSUnresolvedFunction
+ return stream.pipeThrough(new CompressionStream('gzip'));
+}
+
+
+/**
+ * Decompress a blob and return a readable stream.
+ * @param {ReadableStream} stream The stream to decompress.
+ * @returns {ReadableStream} The decompressed stream.
+ */
+export function decompressStream(stream) {
+ //noinspection JSUnresolvedFunction
+ return stream.pipeThrough(new DecompressionStream('gzip'));
+}
diff --git a/src/utils/file/index.js b/src/utils/file/index.js
new file mode 100644
index 00000000..b88904e8
--- /dev/null
+++ b/src/utils/file/index.js
@@ -0,0 +1,5 @@
+
+export { loadStream } from './loadStream.js';
+export { pipeResponseToFile } from './pipeResponseToFile.js';
+export { saveCompressedStream } from './saveCompressedStream.js';
+export { saveStream } from './saveStream.js';
diff --git a/src/utils/file/loadStream.js b/src/utils/file/loadStream.js
new file mode 100644
index 00000000..18b4b11f
--- /dev/null
+++ b/src/utils/file/loadStream.js
@@ -0,0 +1,18 @@
+import { UploadToast } from '../toasts/index.js'
+
+
+/**
+ * Load a stream.
+ * @param {ReadableStream} stream The stream to load.
+ * @param {string} name The name of the stream.
+ * @param {number} [size] The total size of the stream.
+ * @returns {ReadableStream} The stream.
+ */
+export function loadStream(
+ stream = new ReadableStream(),
+ name = 'stream',
+ size,
+) {
+ return stream
+ .pipeThrough(new UploadToast(name, size).pipe);
+}
diff --git a/src/utils/file/pipeResponseToFile.js b/src/utils/file/pipeResponseToFile.js
new file mode 100644
index 00000000..fa45077b
--- /dev/null
+++ b/src/utils/file/pipeResponseToFile.js
@@ -0,0 +1,23 @@
+import { DownloadToast } from '../toasts/index.js'
+
+
+/**
+ * Pipe a response to a file.
+ * @param {Response} response The response to pipe.
+ * @param {FileSystemFileHandle} handle The file handle.
+ * @param {number} [size] The total size of the response.
+ * @returns {Promise} The file handle.
+ */
+export async function pipeResponseToFile(response, handle, size) {
+ //noinspection JSUnresolvedFunction
+ const writable = await handle.createWritable();
+
+ // Notify the user that the download has begun.
+ const toast = new DownloadToast(handle.name, size);
+
+ await response.body
+ .pipeThrough(toast.pipe)
+ .pipeTo(writable)
+
+ return handle;
+}
diff --git a/src/utils/file/saveCompressedStream.js b/src/utils/file/saveCompressedStream.js
new file mode 100644
index 00000000..b8f49998
--- /dev/null
+++ b/src/utils/file/saveCompressedStream.js
@@ -0,0 +1,22 @@
+import { compressStream } from '../compression.js'
+import { saveStream } from './saveStream.js'
+
+
+const gzipTypes = [{
+ description: 'Gzip',
+ accept: {
+ 'application/gzip': ['.gz'],
+ }
+}]
+
+
+/**
+ * Compresses a stream and save it to a file.
+ * @param {ReadableStream} stream The stream to compress.
+ * @param {string} suggestedName The suggested name.
+ * @param {number} [size] The total size of the stream.
+ * @returns {Promise} The file handle.
+ */
+export function saveCompressedStream(stream, suggestedName, size) {
+ return saveStream(compressStream(stream), gzipTypes, suggestedName, size);
+}
diff --git a/src/utils/file/saveStream.js b/src/utils/file/saveStream.js
new file mode 100644
index 00000000..5cd49190
--- /dev/null
+++ b/src/utils/file/saveStream.js
@@ -0,0 +1,40 @@
+import { pipeResponseToFile } from './pipeResponseToFile.js';
+
+
+const defaultTypes = [{
+ description: 'File',
+ accept: {
+ 'application/octet-stream': ['.bin'],
+ }
+}];
+
+
+/**
+ * Get the first file extension from a types object.
+ */
+function getFirstExtension(types) {
+ return Object.values(types[0].accept)[0][0];
+}
+
+
+/**
+ * Save a stream to a file.
+ * @param {ReadableStream} stream The blob to save.
+ * @param {Array