diff --git a/packages/jspsych/src/JsPsych.ts b/packages/jspsych/src/JsPsych.ts index 1a622dd3..482baac1 100644 --- a/packages/jspsych/src/JsPsych.ts +++ b/packages/jspsych/src/JsPsych.ts @@ -170,13 +170,6 @@ export class JsPsych { document.documentElement.setAttribute("jspsych", "present"); - // Register preloading for the plugins referenced in the timeline - for (const [pluginName, parameters] of this.timeline.extractPreloadParameters()) { - for (const [parameter, type] of Object.entries(parameters)) { - this.pluginAPI.registerPreload(pluginName, parameter, type); - } - } - this.startExperiment(); await this.finished; } diff --git a/packages/jspsych/src/TimelineNode.ts b/packages/jspsych/src/TimelineNode.ts index 6660f721..90321738 100644 --- a/packages/jspsych/src/TimelineNode.ts +++ b/packages/jspsych/src/TimelineNode.ts @@ -1,5 +1,4 @@ import { JsPsych } from "./JsPsych"; -import { ParameterType } from "./modules/plugins"; import { repeat, sampleWithReplacement, @@ -534,54 +533,4 @@ export class TimelineNode { ); } } - - /** - * Extracts a map that, for each of the timeline's (nested) plugins, maps the plugin name to an - * object that maps media parameters to their corresponding `preload` type, if not prevented by a - * `preload: false` flag in a parameter's description. - */ - extractPreloadParameters() { - type PreloadType = "audio" | "image" | "video"; - const preloadMap = new Map>(); - - /** Maps parameter types to their corresponding preload type */ - const parameterTypeMap = new Map([ - [ParameterType.AUDIO, "audio"], - [ParameterType.IMAGE, "image"], - [ParameterType.VIDEO, "video"], - ]); - - function recurseTimeline(node: TimelineNode) { - const isTimeline = typeof node.timeline_parameters !== "undefined"; - - if (isTimeline) { - for (const childNode of node.timeline_parameters.timeline) { - recurseTimeline(childNode); - } - } else if (node.trial_parameters.type.info) { - // node is a trial with type.info set - - // Get the plugin name and parameters object from the info object - const { name: pluginName, parameters } = node.trial_parameters.type.info; - - if (!preloadMap.has(pluginName)) { - preloadMap.set( - pluginName, - Object.fromEntries( - Object.entries(parameters) - // Filter out parameter entries with media types and a non-false `preload` option - .filter( - ([_name, { type, preload }]) => parameterTypeMap.has(type) && (preload ?? true) - ) - // Map each entry's value to its preload type - .map(([name, { type }]) => [name, parameterTypeMap.get(type)]) - ) - ); - } - } - } - - recurseTimeline(this); - return preloadMap; - } } diff --git a/packages/jspsych/src/modules/plugin-api/MediaAPI.ts b/packages/jspsych/src/modules/plugin-api/MediaAPI.ts index 47310bff..99889e5e 100644 --- a/packages/jspsych/src/modules/plugin-api/MediaAPI.ts +++ b/packages/jspsych/src/modules/plugin-api/MediaAPI.ts @@ -1,7 +1,15 @@ +import { ParameterType } from "../../modules/plugins"; import { flatten, unique } from "../utils"; +const preloadParameterTypes = [ + ParameterType.AUDIO, + ParameterType.IMAGE, + ParameterType.VIDEO, +]; +type PreloadType = typeof preloadParameterTypes[number]; + export class MediaAPI { - constructor(private useWebaudio: boolean, private webaudioContext: AudioContext) {} + constructor(private useWebaudio: boolean, private webaudioContext?: AudioContext) {} // video // private video_buffers = {}; @@ -52,8 +60,6 @@ export class MediaAPI { } // preloading stimuli // - - private preloads = []; private preload_requests = []; private img_cache = {}; @@ -238,95 +244,71 @@ export class MediaAPI { } } - registerPreload(plugin_name, parameter, media_type) { - if (["audio", "image", "video"].indexOf(media_type) === -1) { - console.error( - "Invalid media_type parameter for jsPsych.pluginAPI.registerPreload. Please check the plugin file." - ); - } - - var preload = { - plugin: plugin_name, - parameter: parameter, - media_type: media_type, - }; - - this.preloads.push(preload); - } + private preloadMap = new Map>(); getAutoPreloadList(timeline_description: any[]) { - function getTrialsOfTypeFromTimelineDescription(td, target_type, inherited_type?) { - var trials = []; + /** Map each preload parameter type to a set of paths to be preloaded */ + const preloadPaths = Object.fromEntries( + preloadParameterTypes.map((type) => [type, new Set()]) + ); - for (var i = 0; i < td.length; i++) { - var node = td[i]; - if (Array.isArray(node.timeline)) { - if (typeof node.type !== "undefined") { - inherited_type = node.type; - } - trials = trials.concat( - getTrialsOfTypeFromTimelineDescription(node.timeline, target_type, inherited_type) + const traverseTimeline = (node, inheritedTrialType?) => { + const isTimeline = typeof node.timeline !== "undefined"; + + if (isTimeline) { + for (const childNode of node.timeline) { + traverseTimeline(childNode, node.type ?? inheritedTrialType); + } + } else if ((node.type ?? inheritedTrialType)?.info) { + // node is a trial with type.info set + + // Get the plugin name and parameters object from the info object + const { name: pluginName, parameters } = (node.type ?? inheritedTrialType).info; + + // Extract parameters to be preloaded and their types from parameter info if this has not + // yet been done for `pluginName` + if (!this.preloadMap.has(pluginName)) { + this.preloadMap.set( + pluginName, + Object.fromEntries( + Object.entries(parameters) + // Filter out parameter entries with media types and a non-false `preload` option + .filter( + ([_name, { type, preload }]) => + preloadParameterTypes.includes(type) && (preload ?? true) + ) + // Map each entry's value to its parameter type + .map(([name, { type }]) => [name, type]) + ) ); - } else { - if (typeof node.type !== "undefined" && node.type.info.name == target_type) { - trials.push(node); - } - if (typeof node.type == "undefined" && inherited_type.info.name == target_type) { - trials.push(Object.assign({}, { type: target_type }, node)); + } + + // Add preload paths from this trial + for (const [parameterName, parameterType] of Object.entries( + this.preloadMap.get(pluginName) + )) { + const parameterValue = node[parameterName]; + const elements = preloadPaths[parameterType]; + + if (typeof parameterValue === "string") { + elements.add(parameterValue); + } else if (Array.isArray(parameterValue)) { + for (const element of flatten(parameterValue)) { + if (typeof element === "string") { + elements.add(element); + } + } } } } + }; - return trials; - } - - // list of items to preload - var images = []; - var audio = []; - var video = []; - - // construct list - for (var i = 0; i < this.preloads.length; i++) { - var type = this.preloads[i].plugin; - var param = this.preloads[i].parameter; - var media = this.preloads[i].media_type; - - var trials = getTrialsOfTypeFromTimelineDescription(timeline_description, type); - for (var j = 0; j < trials.length; j++) { - if (typeof trials[j][param] == "undefined") { - console.warn("jsPsych failed to auto preload one or more files:"); - console.warn("no parameter called " + param + " in plugin " + type); - } else if (typeof trials[j][param] !== "function") { - if (media === "image") { - images = images.concat(flatten([trials[j][param]])); - } else if (media === "audio") { - audio = audio.concat(flatten([trials[j][param]])); - } else if (media === "video") { - video = video.concat(flatten([trials[j][param]])); - } - } - } - } - - images = unique(flatten(images)); - audio = unique(flatten(audio)); - video = unique(flatten(video)); - - // remove any nulls false values - images = images.filter(function (x) { - return x != false && x != null; - }); - audio = audio.filter(function (x) { - return x != false && x != null; - }); - video = video.filter(function (x) { - return x != false && x != null; - }); + traverseTimeline({ timeline: timeline_description }); return { - images, - audio, - video, + images: [...preloadPaths[ParameterType.IMAGE]], + audio: [...preloadPaths[ParameterType.AUDIO]], + video: [...preloadPaths[ParameterType.VIDEO]], }; } diff --git a/packages/jspsych/tests/core/timelines.test.ts b/packages/jspsych/tests/core/timelines.test.ts index 915b63b9..55c6776b 100644 --- a/packages/jspsych/tests/core/timelines.test.ts +++ b/packages/jspsych/tests/core/timelines.test.ts @@ -1,7 +1,6 @@ import htmlKeyboardResponse from "@jspsych/plugin-html-keyboard-response"; -import { ParameterType, initJsPsych } from "../../src"; -import { TimelineNode } from "../../src/TimelineNode"; +import { initJsPsych } from "../../src"; import { pressKey, startTimeline } from "../utils"; describe("loop function", () => { @@ -509,89 +508,3 @@ describe("add node to end of timeline", () => { pressKey("a"); }); }); - -describe("TimelineNode", () => { - const createTimelineNode = (parameters) => new TimelineNode(initJsPsych(), parameters); - - describe("extractPreloadParameters", () => { - it("works for a single trial", () => { - const preloadMap = createTimelineNode({ - type: { - info: { - name: "my-plugin", - parameters: { - one: { type: ParameterType.IMAGE }, - two: { type: ParameterType.VIDEO }, - three: { type: ParameterType.AUDIO }, - four: { type: ParameterType.IMAGE, preload: true }, - five: { type: ParameterType.IMAGE, preload: false }, - six: { - type: ParameterType.STRING, - // This is illegal! But it should still not be added - preload: true, - }, - seven: {}, - }, - }, - }, - }).extractPreloadParameters(); - - expect(preloadMap.get("my-plugin")).toEqual({ - one: "image", - two: "video", - three: "audio", - four: "image", - }); - }); - - it("works for a nested timeline", () => { - const preloadMap = createTimelineNode({ - timeline: [ - { - type: { - info: { - name: "plugin1", - parameters: { one: { type: ParameterType.STRING } }, - }, - }, - }, - { - type: { - info: { - name: "plugin2", - parameters: { one: { type: ParameterType.AUDIO } }, - }, - }, - }, - { - timeline: [ - { - type: { - info: { - name: "plugin3", - parameters: { - one: { type: ParameterType.VIDEO }, - two: { type: ParameterType.IMAGE }, - }, - }, - }, - }, - ], - }, - ], - }).extractPreloadParameters(); - - expect(preloadMap.get("plugin1")).toEqual({}); - expect(preloadMap.get("plugin2")).toEqual({ one: "audio" }); - expect(preloadMap.get("plugin3")).toEqual({ one: "video", two: "image" }); - }); - - it("ignores trials with a function type", () => { - const preloadMap = createTimelineNode({ - type: () => ({ info: { name: "my-dynamic-trial-type" } }), - }).extractPreloadParameters(); - - expect(preloadMap.size).toEqual(0); - }); - }); -}); diff --git a/packages/jspsych/tests/pluginAPI/preloads.test.ts b/packages/jspsych/tests/pluginAPI/preloads.test.ts index 4d34fb19..0151f0fe 100644 --- a/packages/jspsych/tests/pluginAPI/preloads.test.ts +++ b/packages/jspsych/tests/pluginAPI/preloads.test.ts @@ -1,32 +1,115 @@ -// import imageKeyboardResponse from "@jspsych/plugin-image-keyboard-response"; +import { ParameterType } from "../../src"; +import { MediaAPI } from "../../src/modules/plugin-api/MediaAPI"; -import { initJsPsych } from "../../src"; - -describe("getAutoPreloadList", () => { - test.skip("gets whole timeline when no argument provided", async () => { - const timeline = [ +describe("getAutoPreloadList()", () => { + it("works for a single trial", () => { + const preloads = new MediaAPI(false).getAutoPreloadList([ { - // @ts-ignore TODO enable this test once the plugin is a class - type: imageKeyboardResponse, - stimulus: "img/foo.png", - render_on_canvas: false, + type: { + info: { + name: "my-plugin", + parameters: { + one: { type: ParameterType.IMAGE }, + two: { type: ParameterType.VIDEO }, + three: { type: ParameterType.AUDIO }, + four: { type: ParameterType.IMAGE, preload: true }, + five: { type: ParameterType.IMAGE, preload: false }, + six: { + type: ParameterType.STRING, + // This is illegal! But it should still not do anything + preload: true, + }, + seven: {}, + }, + }, + }, + one: "i1", + two: ["v1", ["v2", "v3"]], // arrays should be flattened + // three is undefined, should not be a problem + four: [null, false, 10], // non-string values should be ignored + five: "i2", // ignored: preload=false + six: "six", // ignored: ParameterType.STRING }, - ]; + ]); - const jsPsych = initJsPsych(); - expect(jsPsych.pluginAPI.getAutoPreloadList(timeline).images).toBe("img/foo.png"); + expect(preloads).toEqual({ + images: ["i1"], + audio: [], + video: ["v1", "v2", "v3"], + }); }); - test.skip("works with images", async () => { - const timeline = [ + it("works for a nested timeline", () => { + const preloads = new MediaAPI(false).getAutoPreloadList([ { - // @ts-ignore TODO enable this test once the plugin is a class - type: imageKeyboardResponse, - stimulus: "img/foo.png", + timeline: [ + { + type: { + info: { + name: "plugin1", + parameters: { one: { type: ParameterType.STRING } }, + }, + }, + one: "plugin1-one", + }, + { + type: { + info: { + name: "plugin2", + parameters: { one: { type: ParameterType.AUDIO } }, + }, + }, + one: "plugin2-one", + }, + { + type: { + info: { + name: "plugin3", + parameters: { + one: { type: ParameterType.VIDEO }, + two: { type: ParameterType.IMAGE }, + }, + }, + }, + timeline: [ + { one: "plugin3-one", two: "plugin3-two" }, + { + type: { + info: { + name: "plugin4", + parameters: { + one: { type: ParameterType.VIDEO }, + two: { type: ParameterType.STRING }, + }, + }, + }, + one: "plugin4-one", + two: "plugin4-two", + }, + ], + }, + ], }, - ]; + ]); - const jsPsych = initJsPsych(); - expect(jsPsych.pluginAPI.getAutoPreloadList(timeline).images[0]).toBe("img/foo.png"); + expect(preloads).toEqual({ + images: ["plugin3-two"], + audio: ["plugin2-one"], + video: ["plugin3-one", "plugin4-one"], + }); + }); + + it("ignores trials with a function type", () => { + const preloads = new MediaAPI(false).getAutoPreloadList([ + { + type: () => ({ info: { name: "my-dynamic-trial-type" } }), + }, + ]); + + expect(preloads).toEqual({ + images: [], + audio: [], + video: [], + }); }); });