Skip to content
Open
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
5 changes: 5 additions & 0 deletions constants/data.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@

export const
_1KB = 1024,
_1MB = _1KB * _1KB,
_1GB = _1KB * _1MB;
62 changes: 35 additions & 27 deletions src/components/generators/StoryboardComponent.jsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -151,21 +146,24 @@ export const StoryboardComponent = ({
onDrop={drop}
>
<div className={styles.buttons}>
<button className={styles.button} onClick={e => {
<button className={styles.button} onClick={async e => {
e.preventDefault();
e.stopPropagation();

const uint8Array = storyboard.export();
// const firstBytes = uint8Array.slice(0, 4);
// const firstBytesString = textDecoder.decode(firstBytes);
// console.log('export decoded', {firstBytesString});
const blob = new Blob([
zineMagicBytes,
uint8Array,
], {
type: 'application/octet-stream',
});
downloadFile(blob, 'storyboard.zine');
// Export the storyboard to a blob.
const
uint8Array = storyboard.export(),

blob = new Blob([
zineMagicBytes,
uint8Array,
], {
type: 'application/octet-stream',
});

// Compress and stream blob to file.
// TODO: Estimate compressed size so we can show progress.
await saveCompressedStream(blob.stream(), defaultFilename);
}}>
<img src='/images/download.svg' className={styles.img} />
</button>
Expand All @@ -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);
Expand Down Expand Up @@ -212,4 +220,4 @@ export const StoryboardComponent = ({
/>
</div>
)
};
};
22 changes: 22 additions & 0 deletions src/utils/compression.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@


/**
* Compress a blob and return a readable stream.
* @param {ReadableStream<any>} stream The stream to compress.
* @returns {ReadableStream<any>} 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<any>} stream The stream to decompress.
* @returns {ReadableStream<any>} The decompressed stream.
*/
export function decompressStream(stream) {
//noinspection JSUnresolvedFunction
return stream.pipeThrough(new DecompressionStream('gzip'));
}
5 changes: 5 additions & 0 deletions src/utils/file/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@

export { loadStream } from './loadStream.js';
export { pipeResponseToFile } from './pipeResponseToFile.js';
export { saveCompressedStream } from './saveCompressedStream.js';
export { saveStream } from './saveStream.js';
18 changes: 18 additions & 0 deletions src/utils/file/loadStream.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { UploadToast } from '../toasts/index.js'


/**
* Load a stream.
* @param {ReadableStream<any>} stream The stream to load.
* @param {string} name The name of the stream.
* @param {number} [size] The total size of the stream.
* @returns {ReadableStream<any>} The stream.
*/
export function loadStream(
stream = new ReadableStream(),
name = 'stream',
size,
) {
return stream
.pipeThrough(new UploadToast(name, size).pipe);
}
23 changes: 23 additions & 0 deletions src/utils/file/pipeResponseToFile.js
Original file line number Diff line number Diff line change
@@ -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<FileSystemFileHandle>} 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;
}
22 changes: 22 additions & 0 deletions src/utils/file/saveCompressedStream.js
Original file line number Diff line number Diff line change
@@ -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<any>} stream The stream to compress.
* @param {string} suggestedName The suggested name.
* @param {number} [size] The total size of the stream.
* @returns {Promise<FileSystemFileHandle>} The file handle.
*/
export function saveCompressedStream(stream, suggestedName, size) {
return saveStream(compressStream(stream), gzipTypes, suggestedName, size);
}
40 changes: 40 additions & 0 deletions src/utils/file/saveStream.js
Original file line number Diff line number Diff line change
@@ -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<any>} stream The blob to save.
* @param {Array<object>} types The file types.blob
* @param {string} suggestedName The suggested filename.
* @param {number} [size] The total size of the stream.
* @returns {Promise<FileSystemFileHandle>} 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;
}
3 changes: 2 additions & 1 deletion src/utils/http-utils.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@

export const getFormData = o => {
const formData = new FormData();
for (const k in o) {
Expand All @@ -16,4 +17,4 @@ export function downloadFile(file, filename) {
document.body.appendChild(tempLink);
tempLink.click();
document.body.removeChild(tempLink);
}
}
106 changes: 106 additions & 0 deletions src/utils/toasts/DownloadToast.js
Original file line number Diff line number Diff line change
@@ -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);
},
})
}
37 changes: 37 additions & 0 deletions src/utils/toasts/Toast.js
Original file line number Diff line number Diff line change
@@ -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(); }
}
Loading