Skip to content

Commit 1113178

Browse files
authored
Merge pull request #1075 from joshunrau/video
add support for importing mp4 videos in instruments
2 parents ed0e7bc + 514293c commit 1113178

File tree

11 files changed

+164
-34
lines changed

11 files changed

+164
-34
lines changed

apps/playground/src/components/Editor/Editor.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,8 @@ export const Editor = () => {
150150
'image/webp': ['.webp'],
151151
'text/css': ['.css'],
152152
'text/html': ['.html'],
153-
'text/plain': ['.js', '.jsx', '.ts', '.tsx']
153+
'text/plain': ['.js', '.jsx', '.ts', '.tsx'],
154+
'video/mp4': ['.mp4']
154155
}}
155156
isOpen={isFileUploadDialogOpen}
156157
setIsOpen={setIsFileUploadDialogOpen}

apps/playground/src/components/Editor/EditorFileIcon.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,14 @@ export const EditorFileIcon = ({ filename }: EditorFileIconProps) => {
9292
/>
9393
</svg>
9494
))
95+
.with('.mp4', () => (
96+
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
97+
<path
98+
d="m24 6 2 6h-4l-2-6h-3l2 6h-4l-2-6h-3l2 6H8L6 6H5a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h22a3 3 0 0 0 3-3V6Z"
99+
fill="#ff9800"
100+
/>
101+
</svg>
102+
))
95103
.with('.html', () => (
96104
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
97105
<path

apps/playground/src/components/Header/UploadButton/UploadButton.tsx

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -20,36 +20,46 @@ export const UploadButton = () => {
2020
const instruments = useAppStore((store) => store.instruments);
2121

2222
const handleSubmit = async (files: File[]) => {
23-
const zip = new JSZip() as JSZip & { comment?: unknown };
24-
await zip.loadAsync(files[0]!);
25-
let label: string;
2623
try {
27-
const comment = JSON.parse(String(zip.comment)) as unknown;
28-
if (isPlainObject(comment) && typeof comment.label === 'string') {
29-
label = comment.label;
30-
} else {
24+
const zip = new JSZip() as JSZip & { comment?: unknown };
25+
await zip.loadAsync(files[0]!);
26+
let label: string;
27+
try {
28+
const comment = JSON.parse(String(zip.comment)) as unknown;
29+
if (isPlainObject(comment) && typeof comment.label === 'string') {
30+
label = comment.label;
31+
} else {
32+
label = 'Unlabeled';
33+
}
34+
} catch {
3135
label = 'Unlabeled';
3236
}
33-
} catch {
34-
label = 'Unlabeled';
35-
}
36-
let suffixNumber = 1;
37-
let uniqueLabel = label;
38-
while (instruments.find((instrument) => instrument.label === uniqueLabel)) {
39-
uniqueLabel = `${label} (${suffixNumber})`;
40-
suffixNumber++;
37+
let suffixNumber = 1;
38+
let uniqueLabel = label;
39+
while (instruments.find((instrument) => instrument.label === uniqueLabel)) {
40+
uniqueLabel = `${label} (${suffixNumber})`;
41+
suffixNumber++;
42+
}
43+
const item: InstrumentRepository = {
44+
category: 'Saved',
45+
files: await loadEditorFilesFromZip(zip),
46+
id: crypto.randomUUID(),
47+
kind: null,
48+
label: uniqueLabel
49+
};
50+
addInstrument(item);
51+
setSelectedInstrument(item.id);
52+
addNotification({ type: 'success' });
53+
} catch (err) {
54+
console.error(err);
55+
addNotification({
56+
message: 'Please refer to browser console for details',
57+
title: 'Upload Failed',
58+
type: 'error'
59+
});
60+
} finally {
61+
setIsDialogOpen(false);
4162
}
42-
const item: InstrumentRepository = {
43-
category: 'Saved',
44-
files: await loadEditorFilesFromZip(zip),
45-
id: crypto.randomUUID(),
46-
kind: null,
47-
label: uniqueLabel
48-
};
49-
addInstrument(item);
50-
setSelectedInstrument(item.id);
51-
setIsDialogOpen(false);
52-
addNotification({ type: 'success' });
5363
};
5464

5565
return (
Binary file not shown.
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { defineInstrument } from '/runtime/v1/@opendatacapture/runtime-core';
2+
import { useEffect, useState } from '/runtime/v1/[email protected]';
3+
import { createRoot } from '/runtime/v1/[email protected]/client.js';
4+
import { z } from '/runtime/v1/[email protected]';
5+
6+
import catVideo from './cat-video.mp4';
7+
8+
const Task: React.FC<{ done: (data: { success: boolean }) => void }> = ({ done }) => {
9+
const [secondsRemaining, setSecondsRemaining] = useState(15);
10+
const [value, setValue] = useState('');
11+
12+
useEffect(() => {
13+
const interval = setInterval(() => {
14+
setSecondsRemaining((value) => value && value - 1);
15+
}, 1000);
16+
return () => clearInterval(interval);
17+
}, []);
18+
19+
useEffect(() => {
20+
if (!secondsRemaining) {
21+
done({ success: false });
22+
}
23+
}, [done, secondsRemaining]);
24+
25+
useEffect(() => {
26+
if (value.toLowerCase() === 'cat') {
27+
done({ success: true });
28+
}
29+
}, [value]);
30+
31+
return (
32+
<div
33+
style={{
34+
alignItems: 'center',
35+
display: 'flex',
36+
flexDirection: 'column',
37+
gap: '20px',
38+
justifyContent: 'center',
39+
minHeight: '80vh'
40+
}}
41+
>
42+
<h3>Which animal is in the video?</h3>
43+
<div>
44+
<label htmlFor="response">Response: </label>
45+
<input id="response" name="response" value={value} onChange={(event) => setValue(event.target.value)} />
46+
</div>
47+
<div>
48+
<span>Time Remaining: {secondsRemaining}</span>
49+
</div>
50+
<video controls muted>
51+
<source src={catVideo} type="video/mp4" />
52+
</video>
53+
</div>
54+
);
55+
};
56+
57+
export default defineInstrument({
58+
clientDetails: {
59+
estimatedDuration: 1,
60+
instructions: ['Please watch the video and then answer the question.']
61+
},
62+
content: {
63+
render(done) {
64+
const rootElement = document.createElement('div');
65+
document.body.appendChild(rootElement);
66+
const root = createRoot(rootElement);
67+
root.render(<Task done={done} />);
68+
}
69+
},
70+
details: {
71+
authors: ['Joshua Unrau'],
72+
description: 'This test assesses whether a person knows what a cat is',
73+
license: 'Apache-2.0',
74+
title: 'Breakout Task'
75+
},
76+
internal: {
77+
edition: 1,
78+
name: 'CAT_VIDEO_TASK'
79+
},
80+
kind: 'INTERACTIVE',
81+
language: 'en',
82+
measures: {
83+
success: {
84+
kind: 'const',
85+
ref: 'success'
86+
}
87+
},
88+
tags: ['Interactive', 'React'],
89+
validationSchema: z.object({
90+
success: z.boolean()
91+
})
92+
});

apps/playground/src/instruments/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const textFiles: { [key: string]: string } = import.meta.glob('./**/*.{css,js,js
1313
query: '?raw'
1414
});
1515

16-
const binaryFiles: { [key: string]: string } = import.meta.glob('./**/*.{jpg,jpeg,png,webp}', {
16+
const binaryFiles: { [key: string]: string } = import.meta.glob('./**/*.{jpg,jpeg,png,webp,mp4}', {
1717
eager: true,
1818
import: 'default',
1919
query: '?url'

apps/playground/src/utils/file.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export function inferFileType(filename: string): FileType | null {
1111
.with('.css', () => 'css' as const)
1212
.with(P.union('.js', '.jsx'), () => 'javascript' as const)
1313
.with(P.union('.ts', '.tsx'), () => 'typescript' as const)
14-
.with(P.union('.jpeg', '.jpg', '.png', '.webp'), () => 'asset' as const)
14+
.with(P.union('.jpeg', '.jpg', '.png', '.webp', '.mp4'), () => 'asset' as const)
1515
.with(P.union('.html', '.svg'), () => 'html' as const)
1616
.with(null, () => null)
1717
.exhaustive();
@@ -30,7 +30,7 @@ export function editorFileToInput(file: EditorFile): BundlerInput {
3030
}
3131

3232
export function isBase64EncodedFileType(filename: string) {
33-
return ['.jpeg', '.jpg', '.png', '.webp'].includes(extractInputFileExtension(filename)!);
33+
return ['.jpeg', '.jpg', '.mp4', '.png', '.webp'].includes(extractInputFileExtension(filename)!);
3434
}
3535

3636
export function resolveIndexFile(files: EditorFile[]) {

apps/playground/src/utils/load.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,17 +50,30 @@ export async function loadEditorFilesFromNative(files: File[]) {
5050

5151
export async function loadEditorFilesFromZip(zip: JSZip) {
5252
const editorFiles: EditorFile[] = [];
53+
54+
const dirs = zip.filter((_, file) => file.dir);
55+
56+
if (dirs.length > 1) {
57+
throw new Error(`Archive contains more than one directory: ${dirs.map(({ name }) => name).join(', ')}`);
58+
}
59+
60+
const basename = dirs.length ? dirs[0]!.name : '';
61+
5362
for (const file of Object.values(zip.files)) {
63+
if (file.dir) {
64+
continue;
65+
}
66+
const filename = file.name.slice(basename.length, file.name.length);
5467
let content: string;
55-
const isBase64 = isBase64EncodedFileType(file.name);
68+
const isBase64 = isBase64EncodedFileType(filename);
5669
if (isBase64) {
5770
content = await file.async('base64');
5871
} else {
5972
content = await file.async('string');
6073
}
6174
editorFiles.push({
6275
content,
63-
name: file.name
76+
name: filename
6477
});
6578
}
6679
return editorFiles;

packages/instrument-bundler/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export type BundlerInputFileExtension =
55
| '.jpg'
66
| '.js'
77
| '.jsx'
8+
| '.mp4'
89
| '.png'
910
| '.svg'
1011
| '.ts'

packages/instrument-bundler/src/utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { BundlerInputFileExtension } from './types.js';
66
import type { Loader } from './vendor/esbuild.js';
77

88
export function extractInputFileExtension(filename: string) {
9-
const ext = /\.(css|html|jpeg|jpg|js|jsx|png|svg|ts|tsx|webp)$/i.exec(filename)?.[0];
9+
const ext = /\.(css|html|jpeg|jpg|js|jsx|mp4|png|svg|ts|tsx|webp)$/i.exec(filename)?.[0];
1010
return (ext ?? null) as BundlerInputFileExtension | null;
1111
}
1212

@@ -18,7 +18,7 @@ export function inferLoader(filename: string): Loader {
1818
.with('.jsx', () => 'jsx' as const)
1919
.with('.ts', () => 'ts' as const)
2020
.with('.tsx', () => 'tsx' as const)
21-
.with(P.union('.jpeg', '.jpg', '.png', '.svg', '.webp'), () => 'dataurl' as const)
21+
.with(P.union('.jpeg', '.jpg', '.png', '.svg', '.webp', '.mp4'), () => 'dataurl' as const)
2222
.with(null, () => {
2323
throw new InstrumentBundlerError(`Cannot infer loader due to unexpected extension for filename: ${filename}`);
2424
})

0 commit comments

Comments
 (0)