From 2a4149f583df4efa6afcdaa856abe2993368e95d Mon Sep 17 00:00:00 2001 From: Sara Burns Date: Thu, 8 Oct 2026 13:35:32 -0400 Subject: [PATCH] feat: generate videos that play a clip of the source video About a quarter of generated videos now have a start and end time, like videos configured in Studio to play only part of the source video. As with the real video player, their events report the length of the clip but positions in the full source video, and their course block data includes video_start_time and video_end_time. Co-Authored-By: Claude Opus 5.5 --- xapi_db_load/constants.py | 11 +++++++ xapi_db_load/course_configs.py | 44 +++++++++++++++++++++++++- xapi_db_load/tests/test_xapi_events.py | 44 ++++++++++++++++++++++++++ xapi_db_load/xapi/xapi_video.py | 14 +++++--- 4 files changed, 107 insertions(+), 6 deletions(-) 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,