Skip to content

feat: add audio description upload and playback for video blocks#223

Open
djoseph-apphelix wants to merge 8 commits intorelease-ulmofrom
djoseph/TNL2-577
Open

feat: add audio description upload and playback for video blocks#223
djoseph-apphelix wants to merge 8 commits intorelease-ulmofrom
djoseph/TNL2-577

Conversation

@djoseph-apphelix
Copy link
Copy Markdown
Member

@djoseph-apphelix djoseph-apphelix commented Apr 8, 2026

Description

Adds audio description (AD) support to video blocks: course authors can upload an audio description track for a video in Studio, and learners hear it mixed with video playback in the
LMS.

Audio description files are often large (hundreds of MB), so uploads follow the same direct-to-S3 pre-signed URL pattern already used for video uploads — bytes never traverse the
Django worker.

Waffle flag gating

  • New per-course CourseWaffleFlag: contentstore.enable_audio_description_upload (default off)
  • When the flag is off for a course:
    • studio_audio_description handler returns 404 for every HTTP method
    • Studio video editor's audio description widget is hidden (frontend reads the flag via the existing CourseWaffleFlagsSerializer endpoint)
  • Playback is intentionally NOT gated — any AD file already attached to a video block keeps playing in the LMS even with the flag off. This lets operators safely disable uploads
    without breaking existing content.

Impacted user roles

  • Course authors: new audio description widget in the Studio video editor (hidden until the flag is enabled for the course)
  • Learners: hear audio description tracks replaced with video playback on blocks that have one
  • Operators: new per-course waffle flag to gate the upload UI + handler. No new S3 bucket — AD files are stored in the existing VIDEO_UPLOAD_PIPELINE.VEM_S3_BUCKET (same bucket as
    video uploads), namespaced under the audio_descriptions/ key prefix.

Jira ticket

TNL2-577

Testing instructions

Automated tests

# CMS-side: handler gating (8 tests) + waffle-flag serializer (1 AD-specific test)
pytest cms/djangoapps/contentstore/tests/test_video_audio_description_handler.py \
       cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_waffle_flags.py

# LMS-side: playback URL regression (not gated by the flag — 2 tests)
pytest lms/djangoapps/courseware/tests/test_video_handlers.py::TestVideoBlockAudioDescriptionPlayback

All 13 tests should pass (8 handler + 3 waffle-flag-file tests + 2 LMS playback tests; 11 of these are AD-specific).

Manual end-to-end (devstack)

1. No bucket provisioning step — AD files reuse the existing video upload bucket (edx-video-uploads in devstack). CORS on that bucket already allows PUT from the Studio origin because
video uploads depend on it.
2. Enable the per-course waffle flag for your test course via Django admin: http://localhost:18010/admin/waffle_utils/waffleflagcourseoverridemodel/add/ → waffle_flag:
contentstore.enable_audio_description_upload, course_id: <your course key>, override_choice: on, enabled: ✓.
3. In the course-authoring MFE, open a video block. The new "Audio description" widget should appear in the right-hand panel.
4. Upload a small .mp3 / .m4a / .wav / .aac file. Expect a progress bar and a "ready" state when complete. (.ogg / .flac support is staged in commit ccc55328d4 but depends on a follow-up
 edx-val release; leave those out of acceptance testing until the edx-val bump lands.)
5. View the video block in the LMS. The audio description track should play replaced with the video.
6. Delete the AD via the widget. The LMS playback should stop mixing the track.

Flag-off regression

1. Flip the course override to enabled: ✗ (or delete the override row).
2. Hard-reload Studio (Cmd+Shift+R) — the audio description widget should disappear.
3. curl the handler — it should return 404:
curl -b "sessionid=..." -i http://localhost:18010/xblock/<usage_key>/handler/studio_audio_description
4. Any previously uploaded AD file should still play in the LMS (playback is intentionally not gated).

Other information

- Configuration: AD files reuse the existing VIDEO_UPLOAD_PIPELINE.VEM_S3_BUCKET (same bucket as video uploads), namespaced under VIDEO_AUDIO_DESCRIPTION_SETTINGS.S3_KEY_PREFIX
(audio_descriptions/). No new bucket is required; CORS on the VEM bucket must already allow PUT from the Studio origin for video uploads to work, and AD uploads inherit that.
- Waffle flag: keep the flag off at first deployment; enable per-course via WaffleFlagCourseOverrideModel after verifying the VEM bucket's CORS config.
- edx-val dependency: AD metadata (filename, format, S3 key, size, status) lives in a new VideoAudioDescription model + API in edx-val. That release must land first; kernel.in here needs
 to be bumped to pick it up before this PR can merge.
- Companion frontend PR: this PR handles only the edx-platform (backend + LMS player) side. The Studio video editor widget ships in a separate PR against frontend-app-course-authoring.
- Migration safety: no database migrations are introduced by this PR. The only new persistent state is the audio_description x-field on VideoBlock (stored in modulestore, not SQL) and
edx-val metadata (which lives in edx-val's own schema and is handled by its existing migrations).
- Accessibility: audio description is an accessibility feature for learners with visual impairments; enabling this flag is itself an accessibility improvement.

Copilot AI review requested due to automatic review settings April 8, 2026 06:19
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds audio description (AD) support for video blocks: authors can upload an AD track in Studio (direct-to-S3), and learners can enable AD playback in the LMS.

Changes:

  • Introduces an audio_description XBlock field plus a Studio handler (studio_audio_description) gated by a new waffle flag for direct-to-S3 upload/complete/delete/get.
  • Adds LMS-side pre-signed AD download URL generation and exposes it in video metadata / view data; adds a new JS module + CSS + template placeholder to play AD alongside the video player UI.
  • Adds new settings, waffle-flag serialization, and unit tests for the handler gate and playback URL helper.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
xmodule/video_block/video_xfields.py Adds audio_description field to persist the AD filename on the block.
xmodule/video_block/video_handlers.py Adds Studio XBlock handler for AD upload flow (presigned URL broker) gated by a waffle flag.
xmodule/video_block/video_block.py Injects AD URL into video metadata and adds LMS helper to mint pre-signed GET URLs.
xmodule/video_block/audio_description_urls.py New LMS-safe helper to generate pre-signed GET URLs for AD files.
xmodule/static/css-builtin-blocks/VideoBlockDisplay.css Styles the new AD toggle control and hides the AD <audio> element.
xmodule/js/src/video/10_main.js Loads the new VideoAudioDescription module in the player module chain.
xmodule/js/src/video/09_video_audio_description.js New player module to toggle AD playback and keep it in sync with video events.
xmodule/js/src/video/01_initialize.js Adds config conversion for audioDescriptionActive (reading from in-memory storage).
lms/templates/video.html Adds an optional <audio> placeholder element for AD playback.
lms/envs/common.py Adds VIDEO_AUDIO_DESCRIPTION_SETTINGS (limits + presign expirations) for LMS.
lms/djangoapps/courseware/tests/test_video_handlers.py Adds tests for _get_audio_description_url playback helper behavior.
cms/envs/common.py Adds VIDEO_AUDIO_DESCRIPTION_SETTINGS for CMS upload flow.
cms/djangoapps/contentstore/video_storage_handlers.py Adds endpoint-aware get_s3_client() + localstack URL rewrite for existing video uploads.
cms/djangoapps/contentstore/toggles.py Adds contentstore.enable_audio_description_upload waffle flag.
cms/djangoapps/contentstore/tests/test_video_audio_description_handler.py Adds tests for the new Studio handler’s gate + dispatch behavior.
cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_waffle_flags.py Extends waffle-flags API tests to include the new flag.
cms/djangoapps/contentstore/rest_api/v1/views/course_waffle_flags.py Updates API docs/example payload to mention the new flag.
cms/djangoapps/contentstore/rest_api/v1/serializers/course_waffle_flags.py Exposes the new flag in the waffle-flags serializer response.
cms/djangoapps/contentstore/audio_description_storage_handlers.py New CMS storage helpers for AD presigned PUT + complete/delete + download URL delegation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +490 to +495
// Remove the individually bound seek, speedchange, volumechange,
// and timeupdate listeners.
this.state.el.off('seek');
this.state.el.off('speedchange');
this.state.el.off('volumechange');
this.state.el.off('timeupdate');
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

destroy() calls this.state.el.off('seek')/off('timeupdate')/off('volumechange')/off('speedchange') without a handler or namespace, which removes all listeners for those events (e.g., VideoEventsPlugin listens to seek). This will break other video modules after AD is initialized. Bind these listeners with a namespace (e.g., seek.audioDescription) or keep function references and unbind only your handlers.

Suggested change
// Remove the individually bound seek, speedchange, volumechange,
// and timeupdate listeners.
this.state.el.off('seek');
this.state.el.off('speedchange');
this.state.el.off('volumechange');
this.state.el.off('timeupdate');
// Remove only the individually bound Audio Description listeners.
// These shared player events must be namespaced when bound so that
// teardown does not remove handlers installed by other video modules.
this.state.el.off('seek.audioDescription');
this.state.el.off('speedchange.audioDescription');
this.state.el.off('volumechange.audioDescription');
this.state.el.off('timeupdate.audioDescription');

Copilot uses AI. Check for mistakes.
Comment on lines +508 to +510
if (this.audioEl && !this.state.el.find('#audio-description-' + this.state.id).length) {
this.audioEl.remove();
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The audio element cleanup logic is inverted: if (this.audioEl && !this.state.el.find('#audio-description-' + this.state.id).length) { this.audioEl.remove(); } will never remove the element because find(...) will always succeed once appended. Track whether this module created the element (vs adopted server-rendered) and remove only when created, otherwise leave it.

Suggested change
if (this.audioEl && !this.state.el.find('#audio-description-' + this.state.id).length) {
this.audioEl.remove();
}
if (this.audioEl && this.createdAudioEl) {
this.audioEl.remove();
}
this.createdAudioEl = false;

Copilot uses AI. Check for mistakes.
Comment on lines +620 to +639
* Proxy to SaveStatePlugin.saveState when available, so the save goes through
* the standard debounce/batching logic. Falls through to a direct handler
* URL call when the plugin is not present (e.g. unit tests).
*
* @private
*/
_saveAdState: function() {
if (this.state.videoSaveStatePlugin) {
// Preferred path: delegate to the plugin that already manages saveStateUrl.
this.state.videoSaveStatePlugin.saveState(true, {
audio_description_active: this.isActive
});
} else if (this.state.config.saveStateUrl) {
// Fallback: direct XHR to the handler URL (e.g. in tests without the plugin).
$.ajax({
url: this.state.config.saveStateUrl, // /handler/save_user_state
type: 'POST',
data: {audio_description_active: this.isActive}
});
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_saveAdState() posts audio_description_active to saveStateUrl, but the server-side VideoStudentViewHandlers.handle_ajax currently only accepts a fixed key allowlist and does not include audio_description_active (so the preference won’t persist). Either add a Scope.user_state field + accept/convert audio_description_active on the backend, or remove the server-persistence path and rely on a supported mechanism.

Suggested change
* Proxy to SaveStatePlugin.saveState when available, so the save goes through
* the standard debounce/batching logic. Falls through to a direct handler
* URL call when the plugin is not present (e.g. unit tests).
*
* @private
*/
_saveAdState: function() {
if (this.state.videoSaveStatePlugin) {
// Preferred path: delegate to the plugin that already manages saveStateUrl.
this.state.videoSaveStatePlugin.saveState(true, {
audio_description_active: this.isActive
});
} else if (this.state.config.saveStateUrl) {
// Fallback: direct XHR to the handler URL (e.g. in tests without the plugin).
$.ajax({
url: this.state.config.saveStateUrl, // /handler/save_user_state
type: 'POST',
data: {audio_description_active: this.isActive}
});
}
* Audio description state must not be posted to the server-side save-state
* handler because `audio_description_active` is not a supported field there.
*
* Persistence for this preference should rely on the supported local storage
* path used elsewhere in this module.
*
* @private
*/
_saveAdState: function() {
// Intentionally do not call SaveStatePlugin.saveState() or POST directly to
// saveStateUrl. The backend save handler does not accept
// `audio_description_active`, so attempting to send it would not persist.

Copilot uses AI. Check for mistakes.
Comment on lines +161 to +163
' aria-label="{disabledLabel}"',
' title="{disabledTitle}"',
' type="button">',
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The disabled-state button is styled as disabled but remains a functional <button> with no disabled/aria-disabled semantics. For accessibility, set disabled and/or aria-disabled="true" when no AD source exists (and consider removing it from the tab order) so assistive tech doesn’t present it as an actionable toggle.

Suggested change
' aria-label="{disabledLabel}"',
' title="{disabledTitle}"',
' type="button">',
' aria-disabled="true"',
' aria-label="{disabledLabel}"',
' title="{disabledTitle}"',
' type="button"',
' disabled',
' tabindex="-1">',

Copilot uses AI. Check for mistakes.
Comment on lines +744 to +750
if action == 'complete':
result = complete_audio_description_upload(
edx_video_id=body.get('edx_video_id') or self.edx_video_id,
s3_key=body.get('s3_key'),
)
# pylint: disable=attribute-defined-outside-init
self.audio_description = result['file_name']
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the complete action, the handler accepts edx_video_id from the request body (body.get('edx_video_id') or self.edx_video_id) and then stores the resulting filename on the current block. This allows a client to attach an AD record for a different edx_video_id than the block’s, leading to incorrect associations. Enforce that the completed edx_video_id matches self.edx_video_id (and require self.edx_video_id to exist) or return 400.

Copilot uses AI. Check for mistakes.
Comment on lines +323 to +341
/**
* Start the AD track, synced to the current video position.
* Called on 'play' event and when toggle() enables the feature.
* Mutes the original video audio so only the AD track is heard.
*/
activate: function() {
var videoPlayer, currentTime, audioElement, self;

if (!this.isActive) {
return; // nothing to do if AD is not enabled
}

audioElement = this.audioEl[0]; // native HTMLAudioElement reference
self = this;

// Mute the original video audio so only the AD track is heard.
// Must mute FIRST so _savedVideoVolume captures the real level.
this._muteVideo();

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description says learners will hear AD “mixed with video playback”, but the implementation explicitly mutes the original video (_muteVideo) so only the AD track is audible. Please align the PR description and/or implementation (e.g., keep original audio and duck/mix instead of full mute) to avoid confusing behavior and documentation drift.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

@viv-helix viv-helix left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Could you please remove any unnecessary comments to keep the code clean?

Comment on lines +81 to +85
ENABLE_AUDIO_DESCRIPTION_UPLOAD = WaffleFlag(
f'{CONTENTSTORE_NAMESPACE}.enable_audio_description_upload',
__name__,
CONTENTSTORE_LOG_PREFIX,
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use CourseWaffelFlag instead of WaffleFlag?

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated 8 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +40 to +47
% if audio_description_enabled:
<audio
id="audio-description-${id}"
class="video-audio-description"
aria-hidden="true"
preload="none"
></audio>
% endif
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

audio_description_enabled is not provided in the template context (it’s not set in VideoBlock.get_html), so this conditional will always evaluate false and the <audio> placeholder never renders even when audioDescriptionUrl is present. Either pass audio_description_enabled=bool(metadata.get('audioDescriptionUrl')) from get_html, or remove this template block and rely on the JS module to create the <audio> element.

Suggested change
% if audio_description_enabled:
<audio
id="audio-description-${id}"
class="video-audio-description"
aria-hidden="true"
preload="none"
></audio>
% endif
<audio
id="audio-description-${id}"
class="video-audio-description"
aria-hidden="true"
preload="none"
></audio>

Copilot uses AI. Check for mistakes.
Comment on lines +338 to +346
// Mute the original video audio so only the AD track is heard.
// Must mute FIRST so _savedVideoVolume captures the real level.
this._muteVideo();

// Use the saved pre-mute volume for AD audio level, because
// volumeControl.getVolume() returns 0 after muting. This
// prevents the AD audio from going silent on subsequent
// play events (pause→play cycle).
var adVolume = (typeof this._savedVideoVolume === 'number')
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation mutes the main video audio (_muteVideo()), so learners hear the AD track instead of the original audio. The PR description says the AD is “mixed with video playback”; if mixing is the intent, avoid muting and instead play the AD track concurrently (possibly with independent volume controls) or clarify the intended behavior in the PR description/help text.

Copilot uses AI. Check for mistakes.
Comment on lines +745 to +746
result = complete_audio_description_upload(
edx_video_id=body.get('edx_video_id') or self.edx_video_id,
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the complete action, the handler accepts edx_video_id from the request body and falls back to self.edx_video_id. This allows a client to target a different VAL record than the current block’s edx_video_id. Consider enforcing that the request’s edx_video_id (if provided) matches self.edx_video_id, or ignore the body field entirely and always use self.edx_video_id.

Suggested change
result = complete_audio_description_upload(
edx_video_id=body.get('edx_video_id') or self.edx_video_id,
request_edx_video_id = body.get('edx_video_id')
if request_edx_video_id and request_edx_video_id != self.edx_video_id:
return Response(json={'error': 'edx_video_id does not match this video block'}, status=400)
result = complete_audio_description_upload(
edx_video_id=self.edx_video_id,

Copilot uses AI. Check for mistakes.
Comment on lines +195 to +200
bucket = _get_bucket_name()
s3_client = boto3.client('s3')
try:
head = s3_client.head_object(Bucket=bucket, Key=s3_key)
except Exception as exc:
log.exception(
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

complete_audio_description_upload uses _get_bucket_name() but does not validate that it returned a non-empty bucket name before calling head_object. If the bucket is misconfigured, this will surface as a low-level boto error rather than a clear AudioDescriptionUploadError. Add the same explicit “bucket not configured” check used in generate_audio_description_upload_url().

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings April 9, 2026 07:05
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated 6 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +158 to +167
: [
'<button class="control audio-description-toggle is-disabled"',
' aria-pressed="false"',
' aria-label="{disabledLabel}"',
' title="{disabledTitle}"',
' type="button">',
'<span class="icon fa fa-audio-description" aria-hidden="true"></span>',
'<span class="sr">{disabledLabel}</span>',
'</button>'
];
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The “disabled” variant of the toggle button is styled as disabled but is still focusable and does not set disabled or aria-disabled="true". For accessibility, expose the disabled state semantically (and consider removing it from the tab order if it can’t be activated).

Copilot uses AI. Check for mistakes.
Comment on lines +338 to +342
// Mute the original video audio so only the AD track is heard.
// Must mute FIRST so _savedVideoVolume captures the real level.
this._muteVideo();

// Use the saved pre-mute volume for AD audio level, because
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation explicitly mutes the original video audio so only the AD track is heard, but the PR description says the AD is “mixed with video playback”. If the intended behavior is mixing, this logic (and the x-field help text) should be adjusted; if replacement is intended, the PR description should be updated for accuracy.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings April 9, 2026 13:56
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +317 to +321
// Persist preference using storage.setItem + saveState.
// This mirrors SaveStatePlugin.onSpeedChange — do NOT use raw $.ajax.
this.state.storage.setItem('audio_description_active', this.isActive);
this._saveAdState();
},
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The feature persists audio_description_active via saveState, but the backend save_user_state handler does not currently accept/store that key and the block metadata doesn’t expose an initial audioDescriptionActive value. As a result, the toggle preference won’t persist across page loads. Add an XBlock field (preferences/user_state), include audioDescriptionActive in the player metadata, and update the save_user_state accepted keys/conversions to persist it.

Copilot uses AI. Check for mistakes.
Comment on lines +403 to 410
},
audioDescriptionActive: function(value) {
var stored = storage.getItem('audio_description_active');
if (_.isUndefined(stored)) {
return value === true || value === 'true';
}
return stored;
}
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

audioDescriptionActive is now parsed in the JS config conversions, but the backend metadata passed to the player doesn’t set this value (only audioDescriptionUrl). Without wiring an initial value from an XBlock field, the client will always initialize the toggle as off on page load (even if the learner previously enabled it). Consider adding audioDescriptionActive to the metadata alongside the URL and persisting it via save_user_state.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings April 10, 2026 06:07
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copilot AI review requested due to automatic review settings April 13, 2026 11:02
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review is ineligible. To be eligible to request a review, you need a paid Copilot license, or your organization must enable Copilot code review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants