Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions public/libraries/webcam-recording/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"$schema": "https://raw.githubusercontent.com/revisit-studies/study/v2.4.1/src/parser/LibraryConfigSchema.json",
"description": "This is a library for getting webcam recording permissions and starting webcam recording.",
"components": {
"webcamRecordingPermission": {
"description": "Get permission to start webcam recording",
"type": "react-component",
"path": "libraries/webcam-recording/assets/WebcamRecording.tsx",
"nextButtonLocation": "belowStimulus",
"nextButtonText": "Continue",
"recordAudio": false,
"response": [{
"hidden": true,
"type": "reactive",
"id": "webcamRecordingPermission",
"prompt": "Webcam recording enabled"
}]
}
},
"sequences": {}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@

# Screen Recording

# Screen And Webcam Recording


This is a demo of the library `screen-recording`.

This is a required library for screen recording. It provides a component that request user permission for screen and microphone permissions. Then, it starts screen capture until the end of the study.
This library provides a component that can request screen, webcam, and microphone permissions together. In this example, screen and webcam capture are both enabled and continue until the end of the study.


## Available Components
Expand All @@ -16,4 +15,3 @@ This is a required library for screen recording. It provides a component that re

None


Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@

# Demo Page

Any stimulus that follows the `screenRecordingPermission` page can be recorded.
Any stimulus that follows the `screenRecordingPermission` page can capture both screen and webcam video.

Explore the webpage in the iframe below, then proceed to the next page.

<iframe src="https://revisit.dev/" height="600px" style="border: 2px solid #000" >

7 changes: 4 additions & 3 deletions public/library-screen-recording/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"The reVISit Team"
],
"date": "2026-02-23",
"description": "Example study using the screen-recording library.",
"description": "Example study using the screen-recording library with screen and webcam capture.",
"organizations": [
"University of Utah",
"WPI"
Expand All @@ -18,7 +18,8 @@
"logoPath": "revisitAssets/revisitLogoSquare.svg",
"withProgressBar": true,
"withSidebar": false,
"recordScreen": true
"recordScreen": true,
"recordWebcam": true
},
"importedLibraries": [
"screen-recording"
Expand All @@ -43,4 +44,4 @@
"demo-recording"
]
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Webcam Recording

This is a demo of the library `webcam-recording`.

This library provides a component that requests webcam permissions without screen capture. In this example, webcam recording is enabled for the study and remains active until the end.

## Available Components

- webcamRecordingPermission

## Available Sequences

None
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Demo Page

Any stimulus that follows the `webcamRecordingPermission` page can capture webcam video.

Proceed to the next page after confirming that your webcam preview is active.
46 changes: 46 additions & 0 deletions public/library-webcam-recording/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"$schema": "https://raw.githubusercontent.com/revisit-studies/study/v2.4.1/src/parser/StudyConfigSchema.json",
"studyMetadata": {
"title": "Webcam Recording",
"version": "1.0.0",
"authors": [
"The reVISit Team"
],
"date": "2026-03-18",
"description": "Example study using the webcam-recording library.",
"organizations": [
"University of Utah",
"WPI"
]
},
"uiConfig": {
"contactEmail": "",
"logoPath": "revisitAssets/revisitLogoSquare.svg",
"withProgressBar": true,
"withSidebar": false,
"recordWebcam": true
},
"importedLibraries": [
"webcam-recording"
],
"components": {
"introduction": {
"type": "markdown",
"path": "library-webcam-recording/assets/webcam-recording-introduction.md",
"response": []
},
"demo-recording": {
"type": "markdown",
"path": "library-webcam-recording/assets/webcam-recording-page.md",
"response": []
}
},
"sequence": {
"order": "fixed",
"components": [
"introduction",
"$webcam-recording.components.webcamRecordingPermission",
"demo-recording"
]
}
}
3 changes: 2 additions & 1 deletion src/analysis/individualStudy/StudyAnalysisTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export function StudyAnalysisTabs({ globalConfig }: { globalConfig: GlobalConfig
const [selectedConditions, setSelectedConditions] = useState<string[]>(['ALL']);
const [availableConditions, setAvailableConditions] = useState<{ value: string; label: string }[]>([]);

const { hasAudioRecording, hasScreenRecording } = useStudyRecordings(studyConfig);
const { hasAudioRecording, hasScreenRecording, hasWebcamRecording } = useStudyRecordings(studyConfig);

const { storageEngine } = useStorageEngine();
const navigate = useNavigate();
Expand Down Expand Up @@ -315,6 +315,7 @@ export function StudyAnalysisTabs({ globalConfig }: { globalConfig: GlobalConfig
gap="10px"
hasAudio={hasAudioRecording}
hasScreenRecording={hasScreenRecording}
hasWebcamRecording={hasWebcamRecording}
/>
)}
</Flex>
Expand Down
40 changes: 37 additions & 3 deletions src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
import * as d3 from 'd3';

import {
IconArrowLeft, IconArrowRight, IconDeviceDesktopDown, IconInfoCircle, IconMusicDown, IconPalette, IconPlayerPauseFilled, IconPlayerPlayFilled, IconRestore,
IconArrowLeft, IconArrowRight, IconCamera, IconDeviceDesktopDown, IconInfoCircle, IconMusicDown, IconPalette, IconPlayerPauseFilled, IconPlayerPlayFilled, IconRestore,
} from '@tabler/icons-react';
import { useAsync } from '../../../store/hooks/useAsync';
import { useAuth } from '../../../store/hooks/useAuth';
Expand All @@ -28,7 +28,11 @@ import { TagSelector } from './tags/TagSelector';
import { encryptIndex } from '../../../utils/encryptDecryptIndex';
import { parseTrialOrder } from '../../../utils/parseTrialOrder';
import { PREFIX } from '../../../utils/Prefix';
import { handleTaskAudio, handleTaskScreenRecording } from '../../../utils/handleDownloadFiles';
import {
handleTaskAudio,
handleTaskScreenRecording,
handleTaskWebcamRecording,
} from '../../../utils/handleDownloadFiles';
import { ParticipantRejectModal } from '../ParticipantRejectModal';
import { StorageEngine } from '../../../storage/engines/types';
import { useReplayContext } from '../../../store/hooks/useReplay';
Expand Down Expand Up @@ -94,11 +98,14 @@ export function ThinkAloudFooter({

const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [screenRecordingUrl, setScreenRecordingUrl] = useState<string | null>(null);
const [webcamRecordingUrl, setWebcamRecordingUrl] = useState<string | null>(null);

useEffect(() => {
async function fetchAssetsUrl() {
if (!storageEngine || !participantId || !currentTrial) {
setAudioUrl(null);
setScreenRecordingUrl(null);
setWebcamRecordingUrl(null);
return;
}

Expand All @@ -115,10 +122,17 @@ export function ThinkAloudFooter({
} catch {
setScreenRecordingUrl(null);
}

try {
const url = await storageEngine.getWebcamRecording(currentTrial, participantId);
setWebcamRecordingUrl(url);
} catch {
setWebcamRecordingUrl(null);
}
}

fetchAssetsUrl();
}, [storageEngine, participantId, currentTrial]);
}, [storageEngine, participantId, currentTrial, screenRecordingUrl]);

const handleDownloadAudio = useCallback(async () => {
if (!storageEngine || !participantId || !currentTrial) {
Expand Down Expand Up @@ -146,6 +160,19 @@ export function ThinkAloudFooter({
});
}, [storageEngine, participantId, currentTrial, screenRecordingUrl]);

const handleDownloadWebcamRecording = useCallback(async () => {
if (!storageEngine || !participantId || !currentTrial) {
return;
}

await handleTaskWebcamRecording({
storageEngine,
participantId,
identifier: currentTrial,
webcamRecordingUrl,
});
}, [storageEngine, participantId, currentTrial, webcamRecordingUrl]);

const [transcriptLines, setTranscriptLines] = useState<TranscriptLinesWithTimes[] | null>(null);

const { value: participantTags, execute: pullParticipantTags } = useAsync(getParticipantTags, [auth.user.user?.email || 'temp', participantId, studyId, storageEngine]);
Expand Down Expand Up @@ -579,6 +606,13 @@ export function ThinkAloudFooter({
</ActionIcon>
</Tooltip>
)}
{webcamRecordingUrl && (
<Tooltip label="Download webcam recording">
<ActionIcon variant="light" size={30} onClick={handleDownloadWebcamRecording}>
<IconCamera />
</ActionIcon>
</Tooltip>
)}
<ParticipantRejectModal selectedParticipants={[]} footer />
</Group>
{provenanceLegendEntries.size > 1 && (
Expand Down
9 changes: 7 additions & 2 deletions src/components/ConfigSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {
Anchor, AppShell, Badge, Button, Card, Container, CopyButton, Divider, Flex, Image, MultiSelect, Skeleton, rem, Tabs, Text, Tooltip,
} from '@mantine/core';
import {
IconBan, IconBrandFirebase, IconBrandSupabase, IconChartHistogram, IconCheck, IconCopy, IconDatabase, IconDeviceDesktop, IconExternalLink, IconGraph, IconGraphOff, IconListCheck, IconMicrophone, IconSchema, IconSchemaOff,
IconBan, IconBrandFirebase, IconBrandSupabase, IconCamera, IconChartHistogram, IconCheck, IconCopy, IconDatabase, IconDeviceDesktop, IconExternalLink, IconGraph, IconGraphOff, IconListCheck, IconMicrophone, IconSchema, IconSchemaOff,
} from '@tabler/icons-react';
import { useEffect, useMemo, useState } from 'react';
import { Timestamp } from 'firebase/firestore';
Expand Down Expand Up @@ -73,7 +73,7 @@ function StudyCard({
}
return 'Data Collection Disabled';
}, [modes, studyStatusAndTiming]);
const { hasAudioRecording, hasScreenRecording } = useStudyRecordings(config);
const { hasAudioRecording, hasScreenRecording, hasWebcamRecording } = useStudyRecordings(config);
const {
isBrowserAllowed,
isDeviceAllowed,
Expand Down Expand Up @@ -198,6 +198,11 @@ function StudyCard({
<IconDeviceDesktop size={16} color="orange" />
</Tooltip>
)}
{hasWebcamRecording && (
<Tooltip label="Webcam recording enabled" withinPortal position="bottom">
<IconCamera size={16} color="orange" />
</Tooltip>
)}
{modes?.developmentModeEnabled
? <Tooltip label="Development mode enabled" withinPortal position="bottom"><IconSchema size={16} color="green" /></Tooltip>
: <Tooltip label="Development mode disabled" withinPortal position="bottom"><IconSchemaOff size={16} color="red" /></Tooltip>}
Expand Down
3 changes: 2 additions & 1 deletion src/components/StepRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export function StepRenderer() {
const { isRejected: isScreenRecordingUserRejected } = screenRecording;

const analysisHasScreenRecording = useStoreSelector((state) => state.analysisHasScreenRecording);
const analysisHasWebcamRecording = useStoreSelector((state) => state.analysisHasWebcamRecording);
const analysisCanPlayScreenRecording = useStoreSelector((state) => state.analysisCanPlayScreenRecording);

// Attach event listeners
Expand Down Expand Up @@ -135,7 +136,7 @@ export function StepRenderer() {
const { developmentModeEnabled, dataCollectionEnabled } = useMemo(() => modes, [modes]);

// No default value for withSidebar since it's a required field in uiConfig
const sidebarOpen = useMemo(() => (((analysisHasScreenRecording && analysisCanPlayScreenRecording) || currentComponent === 'end') ? false : (componentConfig.withSidebar ?? studyConfig.uiConfig.withSidebar)), [analysisHasScreenRecording, analysisCanPlayScreenRecording, currentComponent, componentConfig.withSidebar, studyConfig.uiConfig.withSidebar]);
const sidebarOpen = useMemo(() => ((((analysisHasScreenRecording || analysisHasWebcamRecording) && analysisCanPlayScreenRecording) || currentComponent === 'end') ? false : (componentConfig.withSidebar ?? studyConfig.uiConfig.withSidebar)), [analysisHasScreenRecording, analysisHasWebcamRecording, analysisCanPlayScreenRecording, currentComponent, componentConfig.withSidebar, studyConfig.uiConfig.withSidebar]);
const sidebarWidth = useMemo(() => componentConfig?.sidebarWidth ?? studyConfig.uiConfig.sidebarWidth ?? 300, [componentConfig, studyConfig]);
const showTitleBar = useMemo(() => componentConfig.showTitleBar ?? studyConfig.uiConfig.showTitleBar ?? true, [componentConfig, studyConfig]);

Expand Down
8 changes: 5 additions & 3 deletions src/components/audioAnalysis/AudioProvenanceVis.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,12 +282,14 @@ export function AudioProvenanceVis({
throw new Error('Participant ID is required to load audio');
}

const [audioUrl, screenUrl] = await Promise.all([
const [audioUrl, screenUrl, webcamUrl] = await Promise.all([
safe(storageEngine.getAudio(taskName, participantId)),
safe(storageEngine.getScreenRecording(taskName, participantId)),
safe(storageEngine.getWebcamRecording(taskName, participantId)),
]);

const url = screenUrl ?? audioUrl ?? null;
const url = screenUrl ?? audioUrl ?? webcamUrl ?? null;
const hasAudioSource = !!(screenUrl ?? audioUrl);

if (!url) {
setAnalysisHasAudio(false);
Expand All @@ -303,7 +305,7 @@ export function AudioProvenanceVis({
updateReplayRef();

setWaveSurferWidth(waveSurfer.getWidth());
setAnalysisHasAudio(true);
setAnalysisHasAudio(hasAudioSource);
waveSurfer.seekTo(0);
waveSurfer.on('redrawcomplete', () => setWaveSurferWidth(waveSurfer.getWidth()));
} catch (error: unknown) {
Expand Down
Loading
Loading