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
7 changes: 7 additions & 0 deletions src/core/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ class p5 {
this._userNode = node;
this._curElement = null;
this._elements = [];
this._blobUrls = new Set();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to be on the p5 instance? Can it not be fully handled by p5.File itself? For cleaning up when the sketch is removed, use the remove lifecycle hook which is designed for this kind of use.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@limzykenneth Thanks for the feedback! I looked through the lifecycle code and I think I understand the direction now: move the Blob URL cleanup into p5.File and use the existing remove lifecycle hook, rather than keeping _blobUrls on the p5 instance.
The one thing I'm still unsure about is how you'd like the File to keep track of which p5 instance it belongs to. Right now _load() gets pInst explicitly for that, but from your comment on dom.js, it sounds like you'd prefer not to pass it through like this.
I could move the cleanup into a single remove hook, but I don't want to introduce another registry or ownership mechanism if there's already a pattern in p5.js that I'm missing.
Is there a particular approach you had in mind for handling the per-instance cleanup here?

this._glAttributes = null;
this._webgpuAttributes = null;
this._requestAnimId = 0;
Expand Down Expand Up @@ -395,6 +396,12 @@ class p5 {
await this._runLifecycleHook('remove');
}

// Revoke any tracked Blob URLs created by p5.File._load
for (const url of this._blobUrls) {
URL.revokeObjectURL(url);
}
this._blobUrls.clear();

// remove window bound properties and methods
if (this._isGlobal) {
for (const p in p5.prototype) {
Expand Down
3 changes: 2 additions & 1 deletion src/dom/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -1822,9 +1822,10 @@ function dom(p5, fn) {
fn.createFileInput = function (callback, multiple = false) {
// p5._validateParameters('createFileInput', arguments);

const pInst = this;
const handleFileSelect = function (event) {
for (const file of event.target.files) {
File._load(file, callback);
File._load(file, callback, pInst);
Comment on lines +1825 to +1828

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per the above, this should not be necessary.

}
};

Expand Down
2 changes: 1 addition & 1 deletion src/dom/p5.Element.js
Original file line number Diff line number Diff line change
Expand Up @@ -2069,7 +2069,7 @@ class Element {

// Load each one and trigger the callback
for (const f of files) {
File._load(f, callback);
File._load(f, callback, this._pInst);
}
},
this
Expand Down
53 changes: 51 additions & 2 deletions src/dom/p5.File.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,51 @@ class File {
this.name = file.name;
this.size = file.size;
this.data = undefined;
this._isBlobUrl = false;
}

/**
* Revokes the Blob URL associated with this file, if one was created.
*
* When video or audio files are loaded via
* <a href="#/p5/createFileInput">createFileInput()</a> or
* <a href="#/p5.Element/drop">myElement.drop()</a>, p5 creates a Blob URL
* pointing to the media in browser memory. Calling `revoke()` releases that
* resource immediately instead of waiting for the sketch to be removed.
*
* @method revoke
* @for p5.File
*
* @example
* // Load a video file and release its URL when replacing it.
* let video;
* let previousFile;
*
* function setup() {
* createCanvas(100, 100);
* createFileInput(handleFile);
* }
*
* function handleFile(file) {
* if (file.type === 'video') {
* if (video) {
* video.remove();
* previousFile.revoke();
* }
*
* video = createVideo(file.data);
* previousFile = file;
* }
* }
*/
revoke() {
if (this._isBlobUrl && this.data) {
URL.revokeObjectURL(this.data);
if (this._pInst && this._pInst._blobUrls) {
this._pInst._blobUrls.delete(this.data);
}
this._isBlobUrl = false;
}
Comment on lines +26 to +67

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It needs to be clearer when and why a user might want to revoke a URL object manually like this and what the consequence of this would be.

}

static _createLoader(theFile, callback) {
Expand All @@ -42,16 +87,20 @@ class File {
return reader;
}

static _load(f, callback) {
static _load(f, callback, pInst) {
// Text or data?
// This should likely be improved
if (/^text\//.test(f.type) || f.type === 'application/json') {
File._createLoader(f, callback).readAsText(f);
} else if (!/^(video|audio)\//.test(f.type)) {
File._createLoader(f, callback).readAsDataURL(f);
} else {
const file = new File(f);
const file = new File(f, pInst);
file.data = URL.createObjectURL(f);
file._isBlobUrl = true;
if (pInst && pInst._blobUrls) {
pInst._blobUrls.add(file.data);
}
callback(file);
}
}
Expand Down
136 changes: 133 additions & 3 deletions test/unit/dom/dom.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { testSketchWithPromise } from '../../js/p5_helpers';

import { mockP5, mockP5Prototype } from '../../js/mocks';
import p5 from '../../../src/app.js';
import dom from '../../../src/dom/dom';
import file, { File as P5File } from '../../../src/dom/p5.File';
import { Element } from '../../../src/dom/p5.Element';
import creatingReading from '../../../src/color/creating_reading';
import p5Color from '../../../src/color/p5.Color';

suite('DOM', function () {
beforeAll(() => {
dom(mockP5, mockP5Prototype);
file(mockP5, mockP5Prototype);
creatingReading(mockP5, mockP5Prototype);
p5Color(mockP5, mockP5Prototype, {});
});
Expand Down Expand Up @@ -1497,9 +1500,136 @@ suite('DOM', function () {

// p5.MediaElement.prototype._onTimeUpdate

// p5.File
suite('p5.File and Blob URL lifecycle', function () {
let myp5;

// p5.File._createLoader
afterEach(async function () {
if (myp5) {
await myp5.remove();
myp5 = null;
}
document.body.innerHTML = '';
});

test('Blob URLs created by _load() for audio/video are tracked on the p5 instance', function () {
myp5 = new p5(function () {});
const videoBlob = new Blob(['dummy video content'], { type: 'video/mp4' });
const videoFile = new File([videoBlob], 'test.mp4', { type: 'video/mp4' });

let loadedFile;
P5File._load(
videoFile,
f => {
loadedFile = f;
},
myp5
);

assert.instanceOf(loadedFile, P5File);
assert.match(loadedFile.data, /^blob:/);
assert.isTrue(myp5._blobUrls.has(loadedFile.data));
assert.equal(myp5._blobUrls.size, 1);
});

test('p5.remove() revokes tracked Blob URLs and clears _blobUrls', async function () {
myp5 = new p5(function () {});
const audioBlob = new Blob(['dummy audio content'], { type: 'audio/wav' });
const audioFile = new File([audioBlob], 'test.wav', { type: 'audio/wav' });

let loadedFile;
P5File._load(
audioFile,
f => {
loadedFile = f;
},
myp5
);

const blobUrl = loadedFile.data;
assert.isTrue(myp5._blobUrls.has(blobUrl));

const revokeSpy = vi.spyOn(URL, 'revokeObjectURL');
await myp5.remove();

expect(revokeSpy).toHaveBeenCalledWith(blobUrl);
assert.equal(myp5._blobUrls.size, 0);
revokeSpy.mockRestore();
});

test('file.revoke() revokes the URL, removes it from pInst._blobUrls, and is idempotent', async function () {
myp5 = new p5(function () {});
const videoBlob = new Blob(['dummy video content'], { type: 'video/mp4' });
const videoFile = new File([videoBlob], 'test.mp4', { type: 'video/mp4' });

let loadedFile;
P5File._load(
videoFile,
f => {
loadedFile = f;
},
myp5
);

const blobUrl = loadedFile.data;
assert.isTrue(myp5._blobUrls.has(blobUrl));

// p5.File._load
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL');
loadedFile.revoke();

expect(revokeSpy).toHaveBeenCalledTimes(1);
expect(revokeSpy).toHaveBeenCalledWith(blobUrl);
assert.isFalse(myp5._blobUrls.has(blobUrl));

// Calling revoke() a second time is an idempotent no-op
loadedFile.revoke();
expect(revokeSpy).toHaveBeenCalledTimes(1);

// Subsequent p5.remove() will not double revoke
revokeSpy.mockClear();
await myp5.remove();
expect(revokeSpy).not.toHaveBeenCalledWith(blobUrl);

revokeSpy.mockRestore();
});

test('createFileInput passes pInst so loaded media files are tracked', function () {
myp5 = new p5(function () {});
let loadedFile;
const fileInput = myp5.createFileInput(f => {
loadedFile = f;
});

const videoBlob = new Blob(['video data'], { type: 'video/mp4' });
const testFile = new File([videoBlob], 'input.mp4', { type: 'video/mp4' });

const dt = new DataTransfer();
dt.items.add(testFile);
fileInput.elt.files = dt.files;
fileInput.elt.dispatchEvent(new Event('change'));

assert.isDefined(loadedFile);
assert.match(loadedFile.data, /^blob:/);
assert.isTrue(myp5._blobUrls.has(loadedFile.data));
});

test('element.drop() passes pInst so dropped media files are tracked', function () {
myp5 = new p5(function () {});
let loadedFile;
const dropZone = myp5.createDiv();
dropZone.drop(f => {
loadedFile = f;
});

const videoBlob = new Blob(['video data'], { type: 'video/mp4' });
const testFile = new File([videoBlob], 'drop.mp4', { type: 'video/mp4' });

const event = new Event('drop');
event.dataTransfer = { files: [testFile] };
dropZone.elt.dispatchEvent(event);

assert.isDefined(loadedFile);
assert.match(loadedFile.data, /^blob:/);
assert.isTrue(myp5._blobUrls.has(loadedFile.data));
});
});
});