Skip to content
Closed
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
11 changes: 11 additions & 0 deletions xapi_db_load/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 43 additions & 1 deletion xapi_db_load/course_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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))
Expand Down
44 changes: 44 additions & 0 deletions xapi_db_load/tests/test_xapi_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
14 changes: 9 additions & 5 deletions xapi_db_load/xapi/xapi_video.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading