diff --git a/src/core/main.js b/src/core/main.js
index 31126e659b..aec004af02 100644
--- a/src/core/main.js
+++ b/src/core/main.js
@@ -63,6 +63,7 @@ class p5 {
this._userNode = node;
this._curElement = null;
this._elements = [];
+ this._blobUrls = new Set();
this._glAttributes = null;
this._webgpuAttributes = null;
this._requestAnimId = 0;
@@ -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) {
diff --git a/src/dom/dom.js b/src/dom/dom.js
index 82276febbc..4262bb3142 100644
--- a/src/dom/dom.js
+++ b/src/dom/dom.js
@@ -1819,9 +1819,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);
}
};
diff --git a/src/dom/p5.Element.js b/src/dom/p5.Element.js
index 9db0c14def..0b57cc014c 100644
--- a/src/dom/p5.Element.js
+++ b/src/dom/p5.Element.js
@@ -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
diff --git a/src/dom/p5.File.js b/src/dom/p5.File.js
index eed9bdcc90..dfd50e7bd5 100644
--- a/src/dom/p5.File.js
+++ b/src/dom/p5.File.js
@@ -20,6 +20,46 @@ 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
+ * createFileInput() or
+ * myElement.drop(), 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 revoke its URL when finished.
+ * let video;
+ *
+ * function setup() {
+ * createCanvas(100, 100);
+ * createFileInput(handleFile);
+ * }
+ *
+ * function handleFile(file) {
+ * if (file.type === 'video') {
+ * video = createVideo(file.data);
+ * // Explicitly release the Blob URL when no longer needed:
+ * file.revoke();
+ * }
+ * }
+ */
+ 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;
+ }
}
static _createLoader(theFile, callback) {
@@ -42,7 +82,7 @@ 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') {
@@ -50,8 +90,12 @@ class File {
} 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);
}
}
diff --git a/test/js/mocks.js b/test/js/mocks.js
index db6fd86229..bef2c50db5 100644
--- a/test/js/mocks.js
+++ b/test/js/mocks.js
@@ -45,6 +45,7 @@ export const mockP5Prototype = {
id: 'myCanvasID'
},
_elements: [],
+ _blobUrls: new Set(),
_renderer: {
states: rendererStates
}
diff --git a/test/unit/dom/dom.js b/test/unit/dom/dom.js
index 4469096e84..7cf5c6c44e 100644
--- a/test/unit/dom/dom.js
+++ b/test/unit/dom/dom.js
@@ -1,7 +1,9 @@
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';
@@ -9,6 +11,7 @@ import p5Color from '../../../src/color/p5.Color';
suite('DOM', function () {
beforeAll(() => {
dom(mockP5, mockP5Prototype);
+ file(mockP5, mockP5Prototype);
creatingReading(mockP5, mockP5Prototype);
p5Color(mockP5, mockP5Prototype, {});
});
@@ -1472,9 +1475,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));
+ });
+ });
});