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} >
- @@ -175,7 +173,17 @@ export const StoryboardComponent = ({ const file = e.target.files[0]; if (file) { (async () => { - const arrayBuffer = await file.arrayBuffer(); + const + // Decompress stream. + // TODO: Estimate compressed size so we can show progress. + stream = loadStream( + decompressStream(file.stream()), + file.name + ), + + // Get array buffer. + arrayBuffer = await new Response( stream ).arrayBuffer(); + // check magic bytes const firstBytes = new Uint8Array(arrayBuffer, 0, 4); const firstBytesString = textDecoder.decode(firstBytes); @@ -212,4 +220,4 @@ export const StoryboardComponent = ({ />
) -}; \ 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} types The file types.blob + * @param {string} suggestedName The suggested filename. + * @param {number} [size] The total size of the stream. + * @returns {Promise} The file handle. + */ +export async function saveStream( + stream = new ReadableStream(), + types = defaultTypes, + suggestedName = `file${getFirstExtension(types)}`, + size, +) { + // Prepare a response and get a file handle. + //noinspection JSUnresolvedFunction + const handle = await window.showSaveFilePicker({ types, suggestedName }); + + await pipeResponseToFile(new Response(stream), handle, size); + return handle; +} diff --git a/src/utils/http-utils.js b/src/utils/http-utils.js index 0a369a82..bfd9be93 100644 --- a/src/utils/http-utils.js +++ b/src/utils/http-utils.js @@ -1,3 +1,4 @@ + export const getFormData = o => { const formData = new FormData(); for (const k in o) { @@ -16,4 +17,4 @@ export function downloadFile(file, filename) { document.body.appendChild(tempLink); tempLink.click(); document.body.removeChild(tempLink); -} \ No newline at end of file +} diff --git a/src/utils/toasts/DownloadToast.js b/src/utils/toasts/DownloadToast.js new file mode 100644 index 00000000..8ce839f1 --- /dev/null +++ b/src/utils/toasts/DownloadToast.js @@ -0,0 +1,106 @@ +import { _1MB } from '../../../constants/data.js'; + +import { + backgroundColor, + failColor, + successColor, + toastDuration +} from './constants.js'; + +import { Toast } from './Toast.js'; + + +const + /** + * Notify the user and remove the toast if the download fails. + */ + handleError = (ctx, e) => { + // Notify the user. + ctx.toast.textContent = + `Download failed for ${ctx.name}: ${e.message}`; + + // Change bg color and remove after a delay. + ctx.toast.style.background = failColor; + setTimeout(() => ctx.remove(), toastDuration); + }; + + +/** + * A toast which tracks the progress of a streamed download. + */ +export class DownloadToast extends Toast { + currentSize = 0; + name = ''; + loaded = 0; + + pipe = createPipe( this ) + size; + + /** + * Create a new download toast. + * @param {string} [name] The stream's name. + * @param {number} [size] The stream's total size. + */ + constructor( name= '', size ) { + super(); + + // Set name and size. + this.name = name; + if (size) this.size = (size / _1MB).toFixed(2); + + // Set initial text. + this.toast.textContent = `Downloading ${this.name}... (0MB)`; + } +} + + +const createPipe = ctx => { + return new TransformStream({ + /** + * Update the toast as the download progresses. + * @param {Uint8Array} chunk The chunk of data. + * @param {TransformStreamDefaultController} controller The controller. + */ + transform: (chunk, controller) => { + // TransformStream.transform doesn't catch errors by default. + try { + // Update metrics. + ctx.loaded += chunk.length; + ctx.currentSize = (ctx.loaded / _1MB ).toFixed(2); + + // Show current progress. + if ( ctx.size || ctx.size === 0 ) { + // Update text. + ctx.toast.textContent = + `Downloading ${ctx.name}... ` + + `(${ctx.currentSize}/${ctx.size} MB)`; + + // Update progress bar. + ctx.toast.style.background = + `linear-gradient(to right, ` + + `${successColor} ${ctx.currentSize / ctx.size * 100}%, ` + + `${backgroundColor} ${ctx.currentSize / ctx.size * 100}%)`; + } else { + ctx.toast.textContent = + `Downloading ${ctx.name}... (${ctx.currentSize} MB)`; + } + + // Continue. + controller.enqueue(chunk); + } catch (e) { handleError(ctx, e); } + }, + + /** + * Remove the toast when the download is complete. + */ + flush: () => { + // Notify the user. + ctx.toast.textContent = + `Downloaded ${ctx.name}. (${ctx.currentSize} MB)`; + + // Change bg color and remove after a delay. + ctx.toast.style.background = successColor; + setTimeout(() => ctx.remove(), toastDuration); + }, + }) +} diff --git a/src/utils/toasts/Toast.js b/src/utils/toasts/Toast.js new file mode 100644 index 00000000..c299a564 --- /dev/null +++ b/src/utils/toasts/Toast.js @@ -0,0 +1,37 @@ +import { backgroundColor } from './constants.js'; + + +const toastStyle = + 'position: fixed;' + + 'bottom: 0;' + + 'left: 0;' + + 'right: 0;' + + `background: ${backgroundColor};` + + 'color: #fff;' + + 'padding: 16px;' + + 'text-align: center;'; + + +/** + * Creates a toast element for displaying messages to the user. + */ +export class Toast { + // Toast element + toast = null; + + /** + * Create a new toast. + */ + constructor() { + // Create, format and append a toast element. + this.toast = document.createElement('div'); + this.toast.style = toastStyle; + + document.body.appendChild(this.toast); + } + + /** + * Remove the toast. + */ + remove() { this.toast.remove(); } +} diff --git a/src/utils/toasts/UploadToast.js b/src/utils/toasts/UploadToast.js new file mode 100644 index 00000000..ff4d541b --- /dev/null +++ b/src/utils/toasts/UploadToast.js @@ -0,0 +1,104 @@ +import { _1MB } from '../../../constants/data.js'; + +import { + backgroundColor, + failColor, + successColor, + toastDuration +} from './constants.js'; + +import { Toast } from './Toast.js'; + + +const + /** + * Notify the user and remove the toast if the download fails. + */ + handleError = (ctx, e) => { + // Notify the user. + ctx.toast.textContent = + `Upload failed for ${ctx.name}: ${e.message}`; + + // Change bg color and remove after a delay. + ctx.toast.style.background = failColor; + setTimeout(() => ctx.remove(), toastDuration); + }; + +/** + * A toast which tracks the progress of a streamed download. + */ +export class UploadToast extends Toast { + currentSize = 0; + name = ''; + loaded = 0; + + pipe = createPipe( this ) + size; + + /** + * Create a new download toast. + * @param {string} name The stream's name. + * @param {number} [size] The stream's total size. + */ + constructor( name = '', size) { + super(); + + // Set name and size. + this.name = name; + if (size) this.size = (size / _1MB).toFixed(2); + + // Set initial text. + this.toast.textContent = `Uploading ${this.name}... (0MB)`; + } +} + + +const createPipe = ctx => { + return new TransformStream({ + /** + * Update the toast as the download progresses. + * @param {Uint8Array} chunk The chunk of data. + * @param {TransformStreamDefaultController} controller The controller. + */ + transform: (chunk, controller) => { + // TransformStream.transform doesn't catch errors by default. + try { + // Update metrics. + ctx.loaded += chunk.length; + ctx.currentSize = (ctx.loaded / _1MB ).toFixed(2); + + // Show current progress. + if ( ctx.size || ctx.size === 0 ) { + ctx.toast.textContent = + `Uploading ${ctx.name}... ` + + `(${ctx.currentSize}/${ctx.size} MB)`; + + // Update progress bar. + ctx.toast.style.background = + `linear-gradient(to right, ` + + `${successColor} ${ctx.currentSize / ctx.size * 100}%, ` + + `${backgroundColor} ${ctx.currentSize / ctx.size * 100}%)`; + } else { + ctx.toast.textContent = + `Uploading ${ctx.name}... (${ctx.currentSize} MB)`; + } + + // Continue. + controller.enqueue(chunk); + } catch (e) { handleError(ctx, e); } + }, + + /** + * Remove the toast when the download is complete. + */ + flush: () => { + // Notify the user. + ctx.toast.textContent = + `Uploaded ${ctx.name}. (${ctx.currentSize} MB)`; + + // Change bg color and remove after a delay. + ctx.toast.style.background = successColor; + setTimeout(() => ctx.remove(), toastDuration); + }, + }) +} diff --git a/src/utils/toasts/constants.js b/src/utils/toasts/constants.js new file mode 100644 index 00000000..ccbba67b --- /dev/null +++ b/src/utils/toasts/constants.js @@ -0,0 +1,6 @@ + +export const + backgroundColor = '#333', + failColor = 'firebrick', + successColor = 'forestgreen', + toastDuration = 5000; diff --git a/src/utils/toasts/index.js b/src/utils/toasts/index.js new file mode 100644 index 00000000..6a2b0453 --- /dev/null +++ b/src/utils/toasts/index.js @@ -0,0 +1,4 @@ + +export { DownloadToast } from './DownloadToast.js'; +export { Toast } from './Toast.js'; +export { UploadToast } from './UploadToast.js'; diff --git a/styles/Storyboard3DRenderer.module.css b/styles/Storyboard3DRenderer.module.css index 16d7c67d..a3cdcaeb 100644 --- a/styles/Storyboard3DRenderer.module.css +++ b/styles/Storyboard3DRenderer.module.css @@ -39,6 +39,7 @@ .storyboard3DRenderer .layers { display: flex; + overflow-x: auto; } .storyboard3DRenderer .layers .layer { width: 150px; @@ -56,4 +57,4 @@ } .storyboard3DRenderer .layers .layer.selected { cursor: default; -} \ No newline at end of file +}