Skip to content

Commit 0ddbd81

Browse files
committed
Add movie stimulus support for html tasks
1 parent 87c9132 commit 0ddbd81

6 files changed

Lines changed: 224 additions & 6 deletions

File tree

public/task-manifest.json

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"schema_version": 1,
3-
"generated_at": "2026-03-12T01:35:22.537Z",
3+
"generated_at": "2026-03-17T13:39:13.112Z",
44
"org": "TaskBeacon",
55
"runner_repo": "psyflow-web",
66
"runner_url": "https://taskbeacon.github.io/psyflow-web/",
@@ -33,7 +33,52 @@
3333
"default_branch": "main",
3434
"download_url": "https://github.com/TaskBeacon/H000002-bart/archive/refs/heads/main.zip",
3535
"run_url": "https://taskbeacon.github.io/psyflow-web/?task=H000002-bart",
36-
"last_updated": "2026-03-11T10:56:16+08:00"
36+
"last_updated": "2026-03-12T09:35:46+08:00"
37+
},
38+
{
39+
"directory": "H000003-emodot",
40+
"id": "H000003",
41+
"slug": "emodot",
42+
"title": "Emotional Dot-Probe Task (EmoDot)",
43+
"acquisition": "behavior",
44+
"maturity": "prototype",
45+
"release_tag": "0.1.0",
46+
"short_description": "HTML/browser preview of the Emotional Dot-Probe Task built with psyflow-web. The within-trial procedure, face-pair condition semantics, timing, seeded face s...",
47+
"repo_url": "https://github.com/TaskBeacon/H000003-emodot",
48+
"default_branch": "main",
49+
"download_url": "https://github.com/TaskBeacon/H000003-emodot/archive/refs/heads/main.zip",
50+
"run_url": "https://taskbeacon.github.io/psyflow-web/?task=H000003-emodot",
51+
"last_updated": "2026-03-12T22:26:18+08:00"
52+
},
53+
{
54+
"directory": "H000004-flanker",
55+
"id": "H000004",
56+
"slug": "flanker",
57+
"title": "Flanker Task",
58+
"acquisition": "behavior",
59+
"maturity": "prototype",
60+
"release_tag": "0.1.0",
61+
"short_description": "HTML/browser preview of the Flanker task built with psyflow-web. The within-trial procedure, condition semantics, response rules, timing model, and instructi...",
62+
"repo_url": "https://github.com/TaskBeacon/H000004-flanker",
63+
"default_branch": "main",
64+
"download_url": "https://github.com/TaskBeacon/H000004-flanker/archive/refs/heads/main.zip",
65+
"run_url": "https://taskbeacon.github.io/psyflow-web/?task=H000004-flanker",
66+
"last_updated": "2026-03-17T21:27:23+08:00"
67+
},
68+
{
69+
"directory": "H000005-go-nogo",
70+
"id": "H000005",
71+
"slug": "go-nogo",
72+
"title": "Go/No-Go Task",
73+
"acquisition": "behavior",
74+
"maturity": "prototype",
75+
"release_tag": "0.1.0",
76+
"short_description": "HTML/browser preview of the Go/No-Go task built with psyflow-web. The within-trial sequence, condition weighting semantics, response logic, feedback behavior...",
77+
"repo_url": "https://github.com/TaskBeacon/H000005-go-nogo",
78+
"default_branch": "main",
79+
"download_url": "https://github.com/TaskBeacon/H000005-go-nogo/archive/refs/heads/main.zip",
80+
"run_url": "https://taskbeacon.github.io/psyflow-web/?task=H000005-go-nogo",
81+
"last_updated": "2026-03-17T21:32:49+08:00"
3782
},
3883
{
3984
"directory": "H000006-mid",
@@ -50,6 +95,21 @@
5095
"run_url": "https://taskbeacon.github.io/psyflow-web/?task=H000006-mid",
5196
"last_updated": "2026-03-11T10:39:51+08:00"
5297
},
98+
{
99+
"directory": "H000007-movie",
100+
"id": "H000007",
101+
"slug": "movie",
102+
"title": "Movie Watching Task",
103+
"acquisition": "behavior",
104+
"maturity": "prototype",
105+
"release_tag": "0.1.0",
106+
"short_description": "HTML/browser preview of the Movie Watching task built with psyflow-web. The trial procedure and timing are aligned to local T000007-movie, including pre-movi...",
107+
"repo_url": "https://github.com/TaskBeacon/H000007-movie",
108+
"default_branch": "main",
109+
"download_url": "https://github.com/TaskBeacon/H000007-movie/archive/refs/heads/main.zip",
110+
"run_url": "https://taskbeacon.github.io/psyflow-web/?task=H000007-movie",
111+
"last_updated": "2026-03-17T13:38:38.627Z"
112+
},
53113
{
54114
"directory": "H000012-sst",
55115
"id": "H000012",

src/core/config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ function resolveStimSpecAssets(spec: StimSpec, moduleUrl?: string): StimSpec {
1616
image: new URL(spec.image, moduleUrl).href
1717
};
1818
}
19+
if (spec.type === "movie" && isRelativeAssetPath(spec.filename)) {
20+
return {
21+
...spec,
22+
filename: new URL(spec.filename, moduleUrl).href
23+
};
24+
}
1925
if (spec.type === "sound" && isRelativeAssetPath(spec.file)) {
2026
return {
2127
...spec,

src/core/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export type StimSpec =
3737
| PolygonStimSpec
3838
| ShapeStimSpec
3939
| ImageStimSpec
40+
| MovieStimSpec
4041
| SoundStimSpec
4142
| SpeechStimSpec;
4243

@@ -101,6 +102,17 @@ export interface ImageStimSpec extends BaseStimSpec {
101102
size?: [number, number];
102103
}
103104

105+
export interface MovieStimSpec extends BaseStimSpec {
106+
type: "movie";
107+
filename: string;
108+
size?: [number, number];
109+
controls?: boolean;
110+
muted?: boolean;
111+
loop?: boolean;
112+
autoplay?: boolean;
113+
volume?: number;
114+
}
115+
104116
export interface SoundStimSpec extends BaseStimSpec {
105117
type: "sound";
106118
file: string;

src/generated/taskManifest.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,57 @@ export const taskManifest: Record<string, RunnerTaskManifestEntry> = {
4747
default_branch: "main",
4848
download_url: "https://github.com/TaskBeacon/H000002-bart/archive/refs/heads/main.zip",
4949
run_url: "https://taskbeacon.github.io/psyflow-web/?task=H000002-bart",
50-
last_updated: "2026-03-11T10:56:16+08:00",
50+
last_updated: "2026-03-12T09:35:46+08:00",
5151
importTask: () => import("../../../H000002-bart/main")
5252
},
53+
"H000003-emodot": {
54+
directory: "H000003-emodot",
55+
id: "H000003",
56+
slug: "emodot",
57+
title: "Emotional Dot-Probe Task (EmoDot)",
58+
acquisition: "behavior",
59+
maturity: "prototype",
60+
release_tag: "0.1.0",
61+
short_description: "HTML/browser preview of the Emotional Dot-Probe Task built with psyflow-web. The within-trial procedure, face-pair condition semantics, timing, seeded face s...",
62+
repo_url: "https://github.com/TaskBeacon/H000003-emodot",
63+
default_branch: "main",
64+
download_url: "https://github.com/TaskBeacon/H000003-emodot/archive/refs/heads/main.zip",
65+
run_url: "https://taskbeacon.github.io/psyflow-web/?task=H000003-emodot",
66+
last_updated: "2026-03-12T22:26:18+08:00",
67+
importTask: () => import("../../../H000003-emodot/main")
68+
},
69+
"H000004-flanker": {
70+
directory: "H000004-flanker",
71+
id: "H000004",
72+
slug: "flanker",
73+
title: "Flanker Task",
74+
acquisition: "behavior",
75+
maturity: "prototype",
76+
release_tag: "0.1.0",
77+
short_description: "HTML/browser preview of the Flanker task built with psyflow-web. The within-trial procedure, condition semantics, response rules, timing model, and instructi...",
78+
repo_url: "https://github.com/TaskBeacon/H000004-flanker",
79+
default_branch: "main",
80+
download_url: "https://github.com/TaskBeacon/H000004-flanker/archive/refs/heads/main.zip",
81+
run_url: "https://taskbeacon.github.io/psyflow-web/?task=H000004-flanker",
82+
last_updated: "2026-03-17T21:27:23+08:00",
83+
importTask: () => import("../../../H000004-flanker/main")
84+
},
85+
"H000005-go-nogo": {
86+
directory: "H000005-go-nogo",
87+
id: "H000005",
88+
slug: "go-nogo",
89+
title: "Go/No-Go Task",
90+
acquisition: "behavior",
91+
maturity: "prototype",
92+
release_tag: "0.1.0",
93+
short_description: "HTML/browser preview of the Go/No-Go task built with psyflow-web. The within-trial sequence, condition weighting semantics, response logic, feedback behavior...",
94+
repo_url: "https://github.com/TaskBeacon/H000005-go-nogo",
95+
default_branch: "main",
96+
download_url: "https://github.com/TaskBeacon/H000005-go-nogo/archive/refs/heads/main.zip",
97+
run_url: "https://taskbeacon.github.io/psyflow-web/?task=H000005-go-nogo",
98+
last_updated: "2026-03-17T21:32:49+08:00",
99+
importTask: () => import("../../../H000005-go-nogo/main")
100+
},
53101
"H000006-mid": {
54102
directory: "H000006-mid",
55103
id: "H000006",
@@ -66,6 +114,22 @@ export const taskManifest: Record<string, RunnerTaskManifestEntry> = {
66114
last_updated: "2026-03-11T10:39:51+08:00",
67115
importTask: () => import("../../../H000006-mid/main")
68116
},
117+
"H000007-movie": {
118+
directory: "H000007-movie",
119+
id: "H000007",
120+
slug: "movie",
121+
title: "Movie Watching Task",
122+
acquisition: "behavior",
123+
maturity: "prototype",
124+
release_tag: "0.1.0",
125+
short_description: "HTML/browser preview of the Movie Watching task built with psyflow-web. The trial procedure and timing are aligned to local T000007-movie, including pre-movi...",
126+
repo_url: "https://github.com/TaskBeacon/H000007-movie",
127+
default_branch: "main",
128+
download_url: "https://github.com/TaskBeacon/H000007-movie/archive/refs/heads/main.zip",
129+
run_url: "https://taskbeacon.github.io/psyflow-web/?task=H000007-movie",
130+
last_updated: "2026-03-17T13:38:38.627Z",
131+
importTask: () => import("../../../H000007-movie/main")
132+
},
69133
"H000012-sst": {
70134
directory: "H000012-sst",
71135
id: "H000012",

src/jspsych/PsyflowStagePlugin.ts

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { ParameterType, type JsPsych, type JsPsychPlugin, type TrialType } from "jspsych";
22

3-
import type { CompiledStage, ResponseConfig, SoundStimSpec, SpeechStimSpec, StimSpec, TrialContextSpec } from "../core/types";
3+
import type {
4+
CompiledStage,
5+
ResponseConfig,
6+
SoundStimSpec,
7+
SpeechStimSpec,
8+
StimSpec,
9+
TrialContextSpec
10+
} from "../core/types";
411
import { playSoundStimuli } from "./audio";
512
import { PSYFLOW_ABORT_EVENT } from "./sessionEvents";
613

@@ -164,6 +171,11 @@ function ensureStyles(): void {
164171
object-fit: contain;
165172
display: block;
166173
}
174+
.psyflow-stage-movie {
175+
object-fit: contain;
176+
display: block;
177+
background: transparent;
178+
}
167179
`;
168180
document.head.appendChild(style);
169181
}
@@ -294,7 +306,7 @@ function applyBaseStimStyle(element: HTMLElement, spec: StimSpec, stageRoot: HTM
294306
}
295307
}
296308

297-
function renderStimulus(stageRoot: HTMLElement, spec: StimSpec): void {
309+
function renderStimulus(stageRoot: HTMLElement, spec: StimSpec, movieSink: HTMLVideoElement[]): void {
298310
switch (spec.type) {
299311
case "text": {
300312
const element = document.createElement("div");
@@ -404,6 +416,40 @@ function renderStimulus(stageRoot: HTMLElement, spec: StimSpec): void {
404416
stageRoot.appendChild(element);
405417
return;
406418
}
419+
case "movie": {
420+
const element = document.createElement("video");
421+
element.className = "psyflow-stage-stim psyflow-stage-movie";
422+
element.src = spec.filename;
423+
element.preload = "auto";
424+
element.playsInline = true;
425+
element.controls = spec.controls ?? false;
426+
element.muted = spec.muted ?? false;
427+
element.loop = spec.loop ?? false;
428+
if (typeof spec.volume === "number") {
429+
element.volume = Math.min(1, Math.max(0, spec.volume));
430+
}
431+
applyBaseStimStyle(element, spec, stageRoot);
432+
if (spec.size) {
433+
element.style.width = toLength(spec.size[0], spec.units, spec.size[0], stageRoot);
434+
element.style.height = toLength(spec.size[1], spec.units, spec.size[1], stageRoot);
435+
} else {
436+
element.style.maxWidth = "85vmin";
437+
element.style.maxHeight = "85vmin";
438+
element.style.width = "auto";
439+
element.style.height = "auto";
440+
}
441+
stageRoot.appendChild(element);
442+
movieSink.push(element);
443+
if (spec.autoplay !== false) {
444+
const playPromise = element.play();
445+
if (playPromise && typeof playPromise.catch === "function") {
446+
playPromise.catch(() => {
447+
// Autoplay can fail under browser policy; task timing continues regardless.
448+
});
449+
}
450+
}
451+
return;
452+
}
407453
case "sound": {
408454
return;
409455
}
@@ -521,8 +567,9 @@ export class PsyflowStagePlugin implements JsPsychPlugin<Info> {
521567
}
522568
display_element.appendChild(stageRoot);
523569
display_element.focus();
570+
const activeMovies: HTMLVideoElement[] = [];
524571
for (const stim of execution.stimuli) {
525-
renderStimulus(stageRoot, stim.spec);
572+
renderStimulus(stageRoot, stim.spec, activeMovies);
526573
}
527574
const stopSpeech = speakStimuli(execution.stimuli.map((stim: ResolvedStageStimulus) => stim.spec));
528575
const stopSounds = playSoundStimuli(
@@ -588,6 +635,12 @@ export class PsyflowStagePlugin implements JsPsychPlugin<Info> {
588635
keyboardListening = false;
589636
stopSpeech?.();
590637
stopSounds?.();
638+
for (const movie of activeMovies) {
639+
movie.pause();
640+
movie.currentTime = 0;
641+
movie.removeAttribute("src");
642+
movie.load();
643+
}
591644
window.removeEventListener("keydown", keydownListener, true);
592645
document.removeEventListener("keydown", keydownListener, true);
593646
display_element.removeEventListener("keydown", keydownListener, true);

tests/unit/config.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,27 @@ describe("parsePsyflowConfig", () => {
3838
expect(parsed.trigger_config.exp_onset).toBe(98);
3939
expect(parsed.controller_config.initial_duration).toBe(0.2);
4040
});
41+
42+
it("resolves relative movie and sound asset paths when moduleUrl is provided", () => {
43+
const parsed = parsePsyflowConfig(
44+
`
45+
stimuli:
46+
clip:
47+
type: movie
48+
filename: assets/demo.mp4
49+
tone:
50+
type: sound
51+
file: assets/tone.mp3
52+
`,
53+
"https://example.com/tasks/H000007/main.ts"
54+
);
55+
expect(parsed.stim_config.clip).toMatchObject({
56+
type: "movie",
57+
filename: "https://example.com/tasks/H000007/assets/demo.mp4"
58+
});
59+
expect(parsed.stim_config.tone).toMatchObject({
60+
type: "sound",
61+
file: "https://example.com/tasks/H000007/assets/tone.mp3"
62+
});
63+
});
4164
});

0 commit comments

Comments
 (0)