Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Substitute WritableStreamDefaultWriter for FileWriter #35

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
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
48 changes: 42 additions & 6 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,17 @@
This is a JavaScript-based WebM video encoder based on the ideas from [Whammy][]. It allows you to turn a series of
Canvas frames into a WebM video.

This implementation allows you to create very large video files (exceeding the size of available memory), because when
running in a privileged context like a Chrome extension or Electron app, it can stream chunks immediately to a file on disk
using [Chrome's FileWriter][] while the video is being constructed, instead of needing to buffer the entire video in
memory before saving can begin. Video sizes in excess of 4GB can be written. The implementation currently tops out at
This implementation allows you to create very large video files (exceeding the size of available memory), because it can stream chunks immediately to a file on disk using [`WritableStreamDefaultWriter`][] of [File System Access][] [`FileSystemWritableFileStream`][] while the video is being constructed, instead of needing to buffer the entire video in memory before saving can begin. Video sizes in excess of 4GB can be written. The implementation currently tops out at
32GB, but this could be extended.

When a FileWriter is not available, it can instead buffer the video in memory as a series of Blobs which are eventually
When a `WritableStreamDefaultWriter` is not available, it can instead buffer the video in memory as a series of Blobs which are eventually
returned to the calling code as one composite Blob. This Blob can be displayed in a <video> element, transmitted
to a server, or used for some other purpose. Note that some browsers size limits on Blobs, particularly mobile
browsers, check out the [Blob size limits][].

[Chrome's FileWriter]: https://developer.chrome.com/apps/fileSystem
[`WritableStreamDefaultWriter`]: https://streams.spec.whatwg.org/#writablestreamdefaultwriter
[`FileSystemWritableFileStream`]: https://wicg.github.io/file-system-access/#api-filesystemwritablefilestream
[File System Access]: https://wicg.github.io/file-system-access/
[Whammy]: https://github.com/antimatter15/whammy
[Blob size limits]: https://github.com/eligrey/FileSaver.js/

Expand Down Expand Up @@ -92,6 +91,43 @@ videoWriter.complete().then(function(webMBlob) {
The video encoder can use Node.js file APIs to write the video to disk when running under Electron. There is an example
in `test/electron`. Run `npm install` in that directory to fetch required libraries, then `npm start` to launch Electron.

## Usage (File System Access)

```js
document.querySelector('p').onclick = async () => {
const fileHandle = await showSaveFilePicker({
suggestedName: 'webm-writer-filesystem-access.webm',
startIn: 'videos',
id: 'webm-writer',
types: [
{
description: 'WebM files',
accept: {
'video/webm': ['.webm'],
},
},
],
excludeAcceptAllOption: true,
});
const writable = await fileHandle.createWritable();
const fileWriter = await writable.getWriter();
const videoWriter = new WebMWriter({
quality: 0.95, // WebM image quality from 0.0 (worst) to 1.0 (best)
fileWriter, // WritableStreamDefaultWriter in order to stream to a file instead of buffering to memory (optional)
fd: null, // Node.js file handle to write to instead of buffering to memory (optional)
// You must supply one of:
frameDuration: null, // Duration of frames in milliseconds
frameRate: 30, // Number of frames per second
});
// ...
videoWriter.addFrame(frame[i], frameDuration);
// ...
await videoWriter.complete();
await fileWriter.close();
await fileWriter.closed;
};
```

## Transparent WebM support

Transparent WebM files are supported, check out the example in test/transparent. However, because I'm re-using Chrome's
Expand Down
32 changes: 22 additions & 10 deletions src/BlobBuffer.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
* Allows a series of Blob-convertible objects (ArrayBuffer, Blob, String, etc) to be added to a buffer. Seeking and
* overwriting of blobs is allowed.
*
* You can supply a FileWriter, in which case the BlobBuffer is just used as temporary storage before it writes it
* through to the disk.
* You can supply a WritableStreamDefaultWriter from FileSystemWritableFileStream
* (https://wicg.github.io/file-system-access/#api-filesystemwritablefilestream)
* in which case the BlobBuffer is just used as temporary storage before it writes it through to the disk.
*
* By Nicholas Sherlock
*
Expand All @@ -20,7 +21,9 @@
fileWriter = null,
fd = null;

if (destination && destination.constructor.name === "FileWriter") {
if (destination &&
typeof FileSystemFileHandle !== 'undefined' &&
destination instanceof WritableStreamDefaultWriter) {
fileWriter = destination;
} else if (fs && destination) {
fd = destination;
Expand Down Expand Up @@ -140,11 +143,20 @@
});
});
} else if (fileWriter) {
return new Promise(function (resolve, reject) {
fileWriter.onwriteend = resolve;

fileWriter.seek(newEntry.offset);
fileWriter.write(new Blob([newEntry.data]));
return new Promise(async function (resolve, reject) {
try {
if (newEntry.offset === 0) {
await fileWriter.ready;
}
await fileWriter.write({
type: 'write',
position: newEntry.offset,
data: new Blob([newEntry.data]),
});
resolve();
} catch (err) {
reject(err);
}
});
} else if (!isAppend) {
// We might be modifying a write that was already buffered in memory.
Expand Down Expand Up @@ -190,10 +202,10 @@
/**
* Finish all writes to the buffer, returning a promise that signals when that is complete.
*
* If a FileWriter was not provided, the promise is resolved with a Blob that represents the completed BlobBuffer
* If a WritableStreamDefaultWriter was not provided, the promise is resolved with a Blob that represents the completed BlobBuffer
* contents. You can optionally pass in a mimeType to be used for this blob.
*
* If a FileWriter was provided, the promise is resolved with null as the first argument.
* If a WritableStreamDefaultWriter was provided, the promise is resolved with null as the first argument.
*/
this.complete = function (mimeType) {
if (fd || fileWriter) {
Expand Down
170 changes: 170 additions & 0 deletions test/file-system-access/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<!DOCTYPE html>
<html>
<head>
<script src="webm-writer.js"></script>
</head>

<body>
<p>Stream</p>
<video controls autoplay></video>
<canvas></canvas>
<script>
let width;
let height;
let ctx;
let controller;
let done;
const canvas = document.querySelector('canvas');

const video = document.querySelector('video');
let videoWriter, fileHandle, writable, fileWriter;
const frames = [];
video.onloadedmetadata = (e) => {
console.log(e.target.duration);
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
console.log(canvas.width, canvas.height);
if (!ctx) {
ctx = canvas.getContext('2d');
ctx.globalComposite = 'copy';
}
};
video.onplay = async (e) => {
let frameRate = 0;
frames.push([
{
duration: video.currentTime,
frameRate: 0,
width: video.videoWidth,
height: video.videoHeight,
},
]);
const processData = async ({ done }) =>
done
? await reader.closed
: (await new Promise((resolve) =>
setTimeout(
resolve,
1000 /
30 /* adjust number of frames per second written to file */
)
),
ctx.drawImage(video, 0, 0),
frames[frames.length - 1].push(canvas.toDataURL('image/webp')),
processData(await reader.read()));

const rs = new ReadableStream({
start(c) {
return (controller = c);
},
pull(_) {
controller.enqueue(null);
},
});
const reader = rs.getReader();
done = processData(await reader.read());
};
document.querySelector('p').onclick = async () => {
fileHandle = await showSaveFilePicker({
suggestedName: 'webm-writer-filesystem-access.webm',
startIn: 'videos',
id: 'webm-writer',
types: [
{
description: 'WebM files',
accept: {
'video/webm': ['.webm'],
},
},
],
excludeAcceptAllOption: true,
});
writable = await fileHandle.createWritable();
fileWriter = await writable.getWriter();
videoWriter = new WebMWriter({
quality: 0.95, // WebM image quality from 0.0 (worst) to 1.0 (best)
fileWriter, // FileWriter in order to stream to a file instead of buffering to memory (optional)
fd: null, // Node.js file handle to write to instead of buffering to memory (optional)
// You must supply one of:
frameDuration: null, // Duration of frames in milliseconds
frameRate: 30, // Number of frames per second
// add support for variable resolution, variable frame duration, data URL representation of WebP input
variableResolution: true, // frameRate is not used for variable resolution
});
const urls = Promise.all(
[
{
from: 0,
to: 4,
src:
'https://upload.wikimedia.org/wikipedia/commons/a/a4/Xacti-AC8EX-Sample_video-001.ogv',
},
{
from: 10,
to: 20,
src:
'https://mirrors.creativecommons.org/movingimages/webm/ScienceCommonsJesseDylan_240p.webm#t=10,20',
},
].map(async ({ from, to, src }) => {
try {
const request = await fetch(src);
const blob = await request.blob();
const blobURL = URL.createObjectURL(blob);
const url = new URL(src);
console.log(url.hash);
return blobURL + (url.hash || `#t=${from},${to}`);
} catch (e) {
throw e;
}
})
);
let media = await urls;
for (const blobURL of media) {
await new Promise(async (resolve) => {
video.addEventListener(
'pause',
(e) => {
controller.close();
const currentFrames = frames[frames.length - 1];
const [frame] = currentFrames;
frame.duration = video.currentTime - frame.duration;
frame.frameRate =
(frame.duration * 60) / currentFrames.length - 1;
done.then(resolve);
},
{
once: true,
}
);
video.src = blobURL;
});
}
console.log(frames);
for (const frame of frames) {
const [{ duration, frameRate, width, height }] = frame;
console.log(frameRate);
const framesLength = frame.length;
const frameDuration = Math.ceil((duration * 1000) / framesLength);
for (let i = 1; i < framesLength; i++) {
videoWriter.addFrame(frame[i], frameDuration, width, height);
}
}
await videoWriter.complete();
await fileWriter.close();
await fileWriter.closed;
video.remove();
canvas.remove();
frames.length = 0;
const videoStream = document.createElement('video');
videoStream.onloadedmetadata = (_) => console.log(videoStream.duration);
videoStream.onended = (_) =>
console.log(videoStream.duration, videoStream.currentTime);
videoStream.controls = videoStream.autoplay = true;
document.body.appendChild(videoStream);
const file = await fileHandle.getFile();
console.log(fileHandle, fileWriter, file);
videoStream.src = URL.createObjectURL(file);
};
</script>
</body>
</html>
Loading