feat: implemented transcript editor#96
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds an in-platform transcript editing experience for videos, refactors the video info modal to show transcript management alongside file details, and tightens transcript upload validation in both the videos page and the legacy video settings modal.
Changes:
- Adds a new transcript editor modal with cue editing, preview syncing, validation, and autosave.
- Reworks the video info flow so transcript management lives in the info modal sidebar/content instead of the old tabbed sidebar component.
- Adds SRT validation and searchable language pickers for transcript upload/replace flows in both video-management surfaces.
Reviewed changes
Copilot reviewed 36 out of 36 changed files in this pull request and generated 20 comments.
Show a summary per file
| File | Description |
|---|---|
src/files-and-videos/videos-page/transcript-editor/TranscriptEditorModal.jsx |
New transcript editor modal, loading, autosave, cue editing, and video preview sync. |
src/files-and-videos/videos-page/transcript-editor/TranscriptEditor.scss |
Styling for the transcript editor modal and cue UI. |
src/files-and-videos/videos-page/transcript-editor/TranscriptCueBlock.jsx |
Editable cue block with text, timestamps, seek, delete, and insert actions. |
src/files-and-videos/videos-page/transcript-editor/srtUtils.js |
New SRT parsing, serialization, validation, and file checks. |
src/files-and-videos/videos-page/transcript-editor/messages.js |
i18n strings for the transcript editor. |
src/files-and-videos/videos-page/transcript-editor/index.js |
Export for the transcript editor modal. |
src/files-and-videos/videos-page/info-sidebar/VideoInfoModalSidebar.jsx |
Removes the old tabbed video info sidebar. |
src/files-and-videos/videos-page/info-sidebar/TranscriptTab.jsx |
Refactors transcript management UI, adds form mode, editor modal launch, and toast handling. |
src/files-and-videos/videos-page/info-sidebar/TranscriptForm.jsx |
New form for adding a transcript with validation and upload states. |
src/files-and-videos/videos-page/info-sidebar/transcript-item/TranscriptMenu.scss |
Expanded sidebar/transcript form/menu styling. |
src/files-and-videos/videos-page/info-sidebar/transcript-item/TranscriptMenu.jsx |
Switches transcript actions to a dropdown and adds Edit. |
src/files-and-videos/videos-page/info-sidebar/transcript-item/Transcript.jsx |
Updates transcript row UI, confirmation modal, and upload validation. |
src/files-and-videos/videos-page/info-sidebar/transcript-item/messages.js |
Adds transcript edit menu label. |
src/files-and-videos/videos-page/info-sidebar/transcript-item/LanguageSelect.scss |
Styling for searchable language selection. |
src/files-and-videos/videos-page/info-sidebar/transcript-item/LanguageSelect.jsx |
Adds searchable language picker UI for transcript selection. |
src/files-and-videos/videos-page/info-sidebar/messages.js |
New sidebar/form/toast copy and updated transcript title text. |
src/files-and-videos/videos-page/info-sidebar/InfoTab.jsx |
Extends info tab content to optionally show usage metrics. |
src/files-and-videos/videos-page/info-sidebar/index.js |
Changes default export to TranscriptTab. |
src/files-and-videos/videos-page/index.js |
Removes VideoInfoModalSidebar from public exports. |
src/files-and-videos/videos-page/data/slice.js |
Deduplicates stored video IDs in reducers. |
src/files-and-videos/videos-page/CourseVideosTable.tsx |
Replaces old sidebar injection with custom info-modal content for details/transcripts. |
src/files-and-videos/index.scss |
Wires transcript editor styles into the files-and-videos bundle. |
src/files-and-videos/generic/table-components/table-custom-columns/TranscriptColumn.jsx |
Makes transcript count clickable when file info can be opened. |
src/files-and-videos/generic/table-components/table-custom-columns/MoreInfoColumn.jsx |
Closes menu after actions and updates video info label. |
src/files-and-videos/generic/messages.js |
Adds video-specific info menu text. |
src/files-and-videos/generic/InfoModal.jsx |
Refactors modal content injection from sidebar callback to render-content model. |
src/files-and-videos/generic/FileTable.jsx |
Adds context-driven cell wrappers, unique-file filtering, and new info modal content API. |
src/files-and-videos/generic/FileMenu.jsx |
Updates video info label in file card menu. |
src/files-and-videos/files-page/CourseFilesTable.tsx |
Adapts files page to the new info-modal content API. |
src/editors/containers/VideoEditor/components/VideoSettingsModal/index.scss |
Adds searchable language-menu styling for legacy transcript widget. |
src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/TranscriptActionMenu.test.tsx |
Updates test setup for new transcript validation callback signature. |
src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/TranscriptActionMenu.jsx |
Adds SRT validation before replacing a transcript. |
src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/Transcript.jsx |
Adds transcript upload validation error alerts in the legacy widget. |
src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/messages.js |
Adds new validation error messages. |
src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelector.jsx |
Adds searchable language selection and SRT validation before upload. |
src/data/api.ts |
Adds the transcriptEditor waffle flag default. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| useEffect(() => { | ||
| if (!enabled) { return undefined; } | ||
| if (content === lastSavedRef.current) { return undefined; } | ||
| if (timerRef.current) { clearTimeout(timerRef.current); } | ||
| timerRef.current = setTimeout(() => { flush(content); }, debounceMs); | ||
| return () => { | ||
| if (timerRef.current) { clearTimeout(timerRef.current); } | ||
| }; |
| onEmptyFail: () => setLocalError('empty'), | ||
| onSizeFail: onFileTooLarge, |
| validateSrtFile(file, { | ||
| onEmptyFail: onEmptyFile, | ||
| onSizeFail, | ||
| onInvalidFail: onInvalidFile, | ||
| onValid: (f) => handleTranscript({ file: f, language, newLanguage }, 'upload'), |
| <input | ||
| type="text" | ||
| className="language-select__search-input" | ||
| placeholder="Search..." | ||
| value={searchQuery} | ||
| onChange={(e) => setSearchQuery(e.target.value)} | ||
| ref={searchInputRef} |
| <input | ||
| type="text" | ||
| className="language-selector-search__input" | ||
| placeholder="Search..." | ||
| value={searchQuery} | ||
| onChange={(e) => setSearchQuery(e.target.value)} | ||
| ref={searchInputRef} |
| } catch (err) { | ||
| if (inFlightRef.current === requestId) { | ||
| setStatus('error'); | ||
| setError( | ||
| err?.response?.data?.detail | ||
| || err?.response?.data?.error | ||
| || err?.message | ||
| || 'Save failed', | ||
| ); |
| export { | ||
| TranscriptSettings, | ||
| UploadModal, | ||
| VideoThumbnail, |
| import TranscriptTab from './TranscriptTab'; | ||
|
|
||
| export default VideoInfoModalSidebar; | ||
| export default TranscriptTab; |
| placeholder="Search..." | ||
| value={searchQuery} | ||
| onChange={(e) => setSearchQuery(e.target.value)} | ||
| ref={searchInputRef} | ||
| /> | ||
| </div> | ||
| </div> | ||
| <Menu className="language-select__list"> | ||
| <div> | ||
| {filteredEntries.length === 0 && ( | ||
| <div className="language-select__no-results">No results</div> |
| placeholder="Search..." | ||
| value={searchQuery} | ||
| onChange={(e) => setSearchQuery(e.target.value)} | ||
| ref={searchInputRef} | ||
| /> | ||
| </div> | ||
| </div> | ||
| <div className="language-selector-list"> | ||
| {filteredLanguages.length === 0 && ( | ||
| <div className="language-selector-no-results">No results</div> |
266f7ed to
7fc1616
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 35 out of 35 changed files in this pull request and generated 8 comments.
Comments suppressed due to low confidence (1)
src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/TranscriptActionMenu.test.tsx:62
- This test now calls
replaceFileCallbackwith a plain object (mockEvent) that doesn't havesize/Filesemantics. SincereplaceFileCallbacknow delegates tovalidateSrtFile(which checksfile.sizeand usesFileReader),thunkActions.video.replaceTranscriptwill never be called and the assertions will fail. Update the test to use a realFile(or mockvalidateSrtFile/FileReaderto invokeonValid) and assert the async dispatch accordingly.
test('it dispatches the correct thunk', () => {
const cb = componentModule.hooks.replaceFileCallback({
dispatch: mockDispatch,
language: lang1Code,
onEmptyFail: jest.fn(),
onSizeFail: jest.fn(),
onInvalidFail: jest.fn(),
});
cb(mockEvent);
expect(thunkActions.video.replaceTranscript).toHaveBeenCalledWith(result);
expect(mockDispatch).toHaveBeenCalledWith({ replaceTranscript: result });
});
| useEffect(() => { | ||
| if ( | ||
| prevTranscriptStatusRef.current === RequestStatus.IN_PROGRESS | ||
| && transcriptStatus === RequestStatus.SUCCESSFUL | ||
| ) { | ||
| if (lastActionRef.current === 'delete') { | ||
| showToast(intl.formatMessage(messages.transcriptDeletedToast)); | ||
| } else { | ||
| showToast(intl.formatMessage(messages.transcriptUploadedToast)); | ||
| setIsAddingNew(false); | ||
| } |
| case 'delete': | ||
| if (isEmpty(language)) { | ||
| const updatedSelection = previousSelection; | ||
| updatedSelection.shift(); | ||
| setPreviousSelection(updatedSelection); | ||
| } else { |
| } | ||
| if (file) { | ||
| return ( | ||
| <Stack direction="horizontal" className="new-transcript-form__file-row d-flex align-items-centergap-2 py-2 px-1 small"> |
| {loadStatus === 'loading' && ( | ||
| <Stack direction="horizontal" className="align-items-center text-muted"> | ||
| <Spinner animation="border" size="sm" screenReaderText="" className="mr-2" /> | ||
| {intl.formatMessage(messages.loading)} | ||
| </Stack> |
| setError( | ||
| err?.response?.data?.detail | ||
| || err?.response?.data?.error | ||
| || err?.message | ||
| || 'Save failed', | ||
| ); |
| const { status: saveStatus, error: saveError } = useTranscriptAutoSave({ | ||
| videoId, | ||
| language, | ||
| content: serialized, | ||
| apiUrl: transcriptUploadHandlerUrl, | ||
| filename: `${videoFilename || videoId}-${language}.srt`, | ||
| enabled: isOpen && hasEdited && loadStatus === 'loaded' && !hasValidationErrors, | ||
| }); |
| const input = fileInput({ | ||
| onAddFile: module.hooks.replaceFileCallback({ | ||
| language, | ||
| dispatch: useDispatch(), | ||
| onEmptyFail, | ||
| onSizeFail, | ||
| onInvalidFail, |
| export function parseSrt(srt) { | ||
| if (!srt || typeof srt !== 'string') { | ||
| return []; | ||
| } | ||
| const blocks = srt.replace(/\r\n/g, '\n').trim().split(/\n\s*\n/); | ||
| const cues = []; | ||
| blocks.forEach((block, i) => { | ||
| const lines = block.split('\n').filter((l) => l !== undefined); | ||
| if (lines.length < 2) { | ||
| return; | ||
| } | ||
| let cursor = 0; | ||
| let index = i + 1; | ||
| if (!TIMESTAMP_RE.test(lines[0])) { | ||
| const parsed = parseInt(lines[0].trim(), 10); | ||
| if (!Number.isNaN(parsed)) { | ||
| index = parsed; | ||
| } | ||
| cursor = 1; | ||
| } | ||
| const tsLine = lines[cursor]; | ||
| const m = tsLine && tsLine.match(TIMESTAMP_RE); | ||
| if (!m) { | ||
| return; | ||
| } | ||
| const [, h1, m1, s1, ms1, h2, m2, s2, ms2] = m; | ||
| const startMs = (((+h1 * 60) + +m1) * 60 + +s1) * 1000 + +ms1; | ||
| const endMs = (((+h2 * 60) + +m2) * 60 + +s2) * 1000 + +ms2; | ||
| const text = lines.slice(cursor + 1).join('\n'); | ||
| cues.push({ | ||
| index, | ||
| startMs, | ||
| endMs, | ||
| startText: `${h1}:${m1}:${s1},${ms1}`, | ||
| endText: `${h2}:${m2}:${s2},${ms2}`, | ||
| text, | ||
| }); | ||
| }); | ||
| return cues; | ||
| } | ||
|
|
||
| export function msToSrtTime(ms) { | ||
| const totalSec = Math.floor((ms || 0) / 1000); | ||
| const h = Math.floor(totalSec / 3600); | ||
| const m = Math.floor((totalSec % 3600) / 60); | ||
| const s = totalSec % 60; | ||
| const millis = Math.max(0, (ms || 0) - totalSec * 1000); | ||
| return `${pad2(h)}:${pad2(m)}:${pad2(s)},${pad3(millis)}`; | ||
| } | ||
|
|
||
| export function serializeSrt(cues) { | ||
| return cues | ||
| .map((cue, i) => { | ||
| const start = cue.startText || msToSrtTime(cue.startMs); | ||
| const end = cue.endText || msToSrtTime(cue.endMs); | ||
| return `${i + 1}\n${start} --> ${end}\n${cue.text}`; | ||
| }) | ||
| .join('\n\n') | ||
| .concat('\n'); | ||
| } |
| // Reset when a fresh transcript is loaded. | ||
| useEffect(() => { | ||
| setRevealCount(INITIAL_REVEAL); | ||
| }, [cues]); |
| const start = () => { | ||
| if (cancelled) { return; } | ||
| let track = Array.from(video.textTracks) | ||
| .find((t) => t.kind === 'subtitles' && t.language === language); | ||
| if (!track) { | ||
| track = video.addTextTrack('subtitles', languageName || language, language); | ||
| textTrackRef.current = track; |
| <Toast | ||
| className={classNames( | ||
| `processing-notification processing-notification--${variant}`, | ||
| { 'processing-notification-hide-close-button': !close }, | ||
| )} | ||
| show={isShow} | ||
| aria-hidden={isShow} | ||
| action={action && { ...action }} | ||
| onClose={close || (() => {})} |
| duration: PropTypes.number.isRequired, | ||
| dateAdded: PropTypes.string.isRequired, | ||
| fileSize: PropTypes.number.isRequired, |
| aria-label={intl.formatMessage(messages.cueAriaLabel, { timestamp })} | ||
| aria-invalid={errors && errors.length > 0 ? 'true' : 'false'} | ||
| rows={1} | ||
| controlClassName={`transcript-cue-block__textarea${errors && errors.length ? ' transcript-cue-block__textarea--invalid' : ''}`} | ||
| spellCheck |
| languageSearchLabel: { | ||
| id: 'course-authoriong.video-uploads.file-info.transcripts.languageSearchLabel', | ||
| defaultMessage: 'Search languages', | ||
| description: 'Accessible label for the transcript language search input.', | ||
| }, | ||
| languageSearchPlaceholder: { | ||
| id: 'course-authoriong.video-uploads.file-info.transcripts.languageSearchPlaceholder', | ||
| defaultMessage: 'Search languages', |
|
Close PR because as a all change include in #97 |
No description provided.