diff --git a/xapi_db_load/constants.py b/xapi_db_load/constants.py index 0e5d36c..fb1f25f 100644 --- a/xapi_db_load/constants.py +++ b/xapi_db_load/constants.py @@ -25,6 +25,17 @@ # Default length (in seconds) used for synthetic video events. DEFAULT_VIDEO_LENGTH_SECONDS = 195.0 +# Fraction of generated videos configured to play only a clip of the source +# video, using a start and end time. The video player reports positions against +# the full source video but the length of only the clip. +VIDEO_CLIP_FRACTION = 0.25 + +# Bounds (in seconds) for the start time of generated video clips, and the +# shortest clip generated. +VIDEO_CLIP_MIN_START_SECONDS = 10 +VIDEO_CLIP_MAX_START_SECONDS = 60 +VIDEO_CLIP_MIN_LENGTH_SECONDS = 60 + # Lengths used when truncating UUIDs into short, human-readable identifiers. UUID_SHORT_LENGTH = 8 COURSE_ID_SHORT_LENGTH = 6 diff --git a/xapi_db_load/course_configs.py b/xapi_db_load/course_configs.py index 391e6de..50a157d 100644 --- a/xapi_db_load/course_configs.py +++ b/xapi_db_load/course_configs.py @@ -15,11 +15,17 @@ Dict, List, NamedTuple, + Tuple, ) from xapi_db_load.constants import ( DEFAULT_LMS_URL, + DEFAULT_VIDEO_LENGTH_SECONDS, UUID_SHORT_LENGTH, + VIDEO_CLIP_FRACTION, + VIDEO_CLIP_MAX_START_SECONDS, + VIDEO_CLIP_MIN_LENGTH_SECONDS, + VIDEO_CLIP_MIN_START_SECONDS, ) @@ -84,6 +90,7 @@ def __init__(self): self.vertical_ids: List[str] = [] self.problem_ids: List[str] = [] self.video_ids: List[str] = [] + self.video_clips: Dict[str, Tuple[float, float]] = {} self.forum_post_ids: List[str] = [] self.actors: list[EnrolledActor] = [] self.all_tags: list = [] @@ -172,6 +179,12 @@ def configure(self) -> None: for _ in range(self.course_config["videos"]) ] + self.video_clips = { + v: self._generate_random_video_clip() + for v in self.video_ids + if random.random() < VIDEO_CLIP_FRACTION + } + self.forum_post_ids = [ self._generate_random_forum_post_id() for _ in range(self.course_config["forum_posts"]) @@ -244,6 +257,30 @@ def get_video_id(self) -> str: """ return choice(self.video_ids) + def get_video_clip(self, video_id: str) -> Tuple[float, float]: + """ + Return the (start, end) times of the clip a video plays, in seconds. + + Both are 0.0 if the video plays the full source video. + """ + return self.video_clips.get(video_id, (0.0, 0.0)) + + @staticmethod + def _generate_random_video_clip() -> Tuple[float, float]: + """ + Return random (start, end) times of a clip of the source video, in seconds. + """ + start = float( + randrange(VIDEO_CLIP_MIN_START_SECONDS, VIDEO_CLIP_MAX_START_SECONDS) + ) + end = float( + randrange( + int(start) + VIDEO_CLIP_MIN_LENGTH_SECONDS, + int(DEFAULT_VIDEO_LENGTH_SECONDS), + ) + ) + return start, end + def _generate_random_block_type_id(self, block_type: str) -> str: block_uuid = str(uuid.uuid4())[:UUID_SHORT_LENGTH] return f"{self.lms_url}/xblock/block-v1:{self.course_id}+type@{block_type}+block@{block_uuid}" @@ -331,7 +368,12 @@ def serialize_block_data_for_event_sink(self) -> List[Dict]: # Get all of our blocks in order for v in self.video_ids: - blocks.append(self._serialize_block("video", v, cnt)) + block = self._serialize_block("video", v, cnt) + start, end = self.get_video_clip(v) + block["xblock_data_json"].update( + {"video_start_time": start, "video_end_time": end} + ) + blocks.append(block) cnt += 1 for p in self.problem_ids: blocks.append(self._serialize_block("problem", p, cnt)) diff --git a/xapi_db_load/tests/test_xapi_events.py b/xapi_db_load/tests/test_xapi_events.py index d1e4e9e..6e0ece9 100644 --- a/xapi_db_load/tests/test_xapi_events.py +++ b/xapi_db_load/tests/test_xapi_events.py @@ -15,6 +15,8 @@ import pytest +from xapi_db_load.constants import DEFAULT_VIDEO_LENGTH_SECONDS +from xapi_db_load.course_configs import RandomCourse from xapi_db_load.generate_load_async import EventGenerator from xapi_db_load.xapi.xapi_forum import PostCreated from xapi_db_load.xapi.xapi_grade import ( @@ -158,3 +160,45 @@ def test_event_respects_configured_lms_url(event_generator, event_class): f"{event_class.__name__} generated homePage={home_page!r}, " f"expected {TEST_LMS_URL!r}" ) + + +VIDEO_TIME_EXT = "https://w3id.org/xapi/video/extensions/time" +VIDEO_LENGTH_EXT = "https://w3id.org/xapi/video/extensions/length" + + +@pytest.mark.parametrize("clip", [(30.0, 150.0), None], ids=["clipped", "full"]) +def test_video_clip_times(event_generator, monkeypatch, clip): + """ + Clipped videos report the clip length but positions in the source video. + + The course block data for the video carries the clip times. + """ + course = event_generator.courses[0] + video_id = course.video_ids[0] + monkeypatch.setattr(course, "video_clips", {video_id: clip} if clip else {}) + monkeypatch.setattr(event_generator, "get_course", lambda: course) + monkeypatch.setattr(course, "get_video_id", lambda: video_id) + + start, end = clip or (0.0, DEFAULT_VIDEO_LENGTH_SECONDS) + + for _ in range(20): + statement = json.loads(PlayedVideo(event_generator).get_data()["event"]) + assert statement["context"]["extensions"][VIDEO_LENGTH_EXT] == end - start + assert start <= statement["result"]["extensions"][VIDEO_TIME_EXT] < end + + location = video_id.split("/xblock/")[-1] + block = next( + b + for b in course.serialize_block_data_for_event_sink() + if b["location"] == location + ) + xblock_data = json.loads(block["xblock_data_json"]) + assert xblock_data["video_start_time"] == (clip[0] if clip else 0.0) + assert xblock_data["video_end_time"] == (clip[1] if clip else 0.0) + + +def test_generated_video_clips_are_valid(): + """Generated clips start within bounds and end within the source video.""" + for _ in range(100): + start, end = RandomCourse._generate_random_video_clip() # pylint: disable=protected-access + assert 0 < start < end <= DEFAULT_VIDEO_LENGTH_SECONDS diff --git a/xapi_db_load/xapi/xapi_video.py b/xapi_db_load/xapi/xapi_video.py index 1ef5aba..a963394 100644 --- a/xapi_db_load/xapi/xapi_video.py +++ b/xapi_db_load/xapi/xapi_video.py @@ -58,16 +58,20 @@ def get_randomized_event( """ Given the inputs, return an xAPI statement. """ - video_length = DEFAULT_VIDEO_LENGTH_SECONDS - max_offset = int(DEFAULT_VIDEO_LENGTH_SECONDS) + # Videos that play a clip of the source video report the length of + # the clip, but positions against the full source video. + clip_start, clip_end = course.get_video_clip(video_id) + min_offset = int(clip_start) + max_offset = int(clip_end or DEFAULT_VIDEO_LENGTH_SECONDS) + video_length = float(max_offset - min_offset) video_event_time = video_event_time_to = video_event_time_from = None if self.has_event_time: - video_event_time = float(randrange(0, max_offset)) + video_event_time = float(randrange(min_offset, max_offset)) if self.has_time_from_to: - video_event_time_from = float(randrange(0, max_offset)) - video_event_time_to = float(randrange(0, max_offset)) + video_event_time_from = float(randrange(min_offset, max_offset)) + video_event_time_to = float(randrange(min_offset, max_offset)) event = { "id": event_id,