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)); + }); + }); });