Skip to content

Commit 2a8670b

Browse files
chapters: improve ExtractChapterFramesTask
1 parent 32931f4 commit 2a8670b

7 files changed

Lines changed: 247 additions & 106 deletions

File tree

cds/modules/flows/tasks.py

Lines changed: 77 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import shutil
2929
import signal
3030
import tempfile
31+
from io import BytesIO
3132

3233
import jsonpatch
3334
import requests
@@ -57,7 +58,7 @@
5758

5859
from cds.modules.flows.models import FlowTaskMetadata
5960
from cds.modules.flows.models import FlowTaskStatus as FlowTaskStatus
60-
61+
from cds.modules.records.api import CDSVideosFilesIterator
6162
from ..ffmpeg import ff_frames, ff_probe_all
6263
from ..opencast.api import OpenCast
6364
from ..opencast.error import RequestError
@@ -746,8 +747,13 @@ class ExtractChapterFramesTask(AVCTask):
746747
name = "file_video_extract_chapter_frames"
747748

748749
@staticmethod
749-
def clean(version_id, *args, **kwargs):
750-
"""Delete generated chapter frame ObjectVersion slaves."""
750+
def clean(version_id, valid_chapter_seconds=None, *args, **kwargs):
751+
"""Delete generated chapter frame ObjectVersion slaves.
752+
753+
- If valid_chapter_seconds is given, keep them.
754+
- If not, remove all chapter frames.
755+
"""
756+
valid_chapter_seconds = valid_chapter_seconds or []
751757
# remove all objects version "slave" with type "frame" that are chapter frames
752758
tag_alias_1 = aliased(ObjectVersionTag)
753759
tag_alias_2 = aliased(ObjectVersionTag)
@@ -764,8 +770,18 @@ def clean(version_id, *args, **kwargs):
764770
)
765771

766772
for slave in slaves:
773+
ts_val = next(t.value for t in slave.tags if t.key == "timestamp")
774+
if ts_val in valid_chapter_seconds:
775+
continue
767776
dispose_object_version(slave)
768777

778+
# If no valid chapter seconds, remove the chapters.vtt file
779+
if not valid_chapter_seconds:
780+
master_obj = ObjectVersion.query.get(version_id)
781+
vtt_objs = ObjectVersion.get_versions(master_obj.bucket_id, "chapters.vtt")
782+
for vtt_obj in vtt_objs:
783+
dispose_object_version(vtt_obj)
784+
769785
def run(self, *args, **kwargs):
770786
"""Extract frames only at chapter timestamps from video description.
771787
@@ -808,10 +824,6 @@ def run(self, *args, **kwargs):
808824

809825
# Parse chapters from description
810826
chapters = parse_video_chapters(description)
811-
812-
if not chapters:
813-
self.log("No chapters found in description - task completed")
814-
return {"chapter_frames_extracted": 0, "status": "no_chapters"}
815827

816828
self.log("Found {0} chapters in description".format(len(chapters)))
817829

@@ -822,7 +834,7 @@ def run(self, *args, **kwargs):
822834
raise ValueError("Video duration is 0 - cannot extract frames")
823835

824836
# Check which timestamps already have frames
825-
existing_timestamps = self._get_existing_frame_timestamps()
837+
existing_timestamps = self._get_existing_chapter_frame_timestamps(deposit_video)
826838

827839
def progress_updater(current_chapter):
828840
"""Progress reporter."""
@@ -835,7 +847,7 @@ def progress_updater(current_chapter):
835847
)
836848
self.log(meta["message"])
837849

838-
frames = self._create_chapter_frames(
850+
frames, chapter_seconds = self._create_chapter_frames(
839851
chapters=chapters,
840852
duration=duration,
841853
object_=self.object_version,
@@ -844,6 +856,12 @@ def progress_updater(current_chapter):
844856
progress_updater=progress_updater,
845857
)
846858

859+
# Clean unused chapters
860+
self.clean(version_id=self.object_version_id, valid_chapter_seconds=chapter_seconds)
861+
862+
# Create or update WebVTT file for chapters
863+
self._build_chapter_vtt(chapters, duration)
864+
847865
# Sync deposit and record files
848866
sync_records_with_deposit_files(self.deposit_id)
849867

@@ -865,32 +883,17 @@ def progress_updater(current_chapter):
865883
self.log("Finished task {0}".format(kwargs["task_id"]))
866884
return "Created {0} chapter frames.".format(total_frames)
867885

868-
def _get_existing_frame_timestamps(self):
869-
"""Get set of existing frame timestamps to avoid duplicates."""
870-
tag_alias_1 = aliased(ObjectVersionTag)
871-
tag_alias_2 = aliased(ObjectVersionTag)
872-
tag_alias_3 = aliased(ObjectVersionTag)
886+
def _get_existing_chapter_frame_timestamps(self, deposit):
887+
"""Get timestamps of existing chapter frames."""
888+
master_file = CDSVideosFilesIterator.get_master_video_file(deposit)
889+
frames = CDSVideosFilesIterator.get_video_frames(master_file)
873890

874-
existing = (
875-
ObjectVersion.query.join(tag_alias_1, ObjectVersion.tags)
876-
.join(tag_alias_2, ObjectVersion.tags)
877-
.join(tag_alias_3, ObjectVersion.tags)
878-
.filter(tag_alias_1.key == "master", tag_alias_1.value == self.object_version_id)
879-
.filter(tag_alias_2.key == "context_type", tag_alias_2.value == "frame")
880-
.filter(tag_alias_3.key == "timestamp")
881-
.all()
882-
)
883-
884-
existing_timestamps = set()
885-
for obj in existing:
886-
for tag in obj.tags:
887-
if tag.key == "timestamp":
888-
try:
889-
existing_timestamps.add(float(tag.value))
890-
except ValueError:
891-
continue
892-
893-
return existing_timestamps
891+
existing = set()
892+
for f in frames:
893+
tags = f.get("tags", {})
894+
if tags.get("is_chapter_frame") == "true":
895+
existing.add(float(tags.get("timestamp")))
896+
return existing
894897

895898
@classmethod
896899
def _create_chapter_frames(
@@ -904,6 +907,7 @@ def _create_chapter_frames(
904907
):
905908
"""Create frames for chapters that don't already exist at those timestamps."""
906909
created_frames = []
910+
valid_chapter_seconds = []
907911
current_chapter = 0
908912

909913
with move_file_into_local(object_, delete=True) as url:
@@ -920,6 +924,10 @@ def _create_chapter_frames(
920924
if chapter_seconds > duration:
921925
continue
922926

927+
# For 0:00 chapters, use a small offset to avoid extraction issues
928+
chapter_seconds = max(chapter_seconds, 0.1) if chapter_seconds == 0 else chapter_seconds
929+
valid_chapter_seconds.append(to_string(chapter_seconds))
930+
923931
# Skip if frame already exists at this timestamp (with some tolerance)
924932
timestamp_exists = any(
925933
abs(existing_ts - chapter_seconds) < 0.1
@@ -931,9 +939,6 @@ def _create_chapter_frames(
931939
frame_filename = "chapter-{0}.jpg".format(int(chapter_seconds))
932940
frame_path = os.path.join(output_dir, frame_filename)
933941

934-
# For 0:00 chapters, use a small offset to avoid extraction issues
935-
chapter_seconds = max(chapter_seconds, 0.1) if chapter_seconds == 0 else chapter_seconds
936-
937942
try:
938943
# Extract single frame at chapter timestamp using ff_frames
939944
ff_frames(
@@ -970,7 +975,41 @@ def _create_chapter_frames(
970975
)
971976
continue
972977

973-
return created_frames
978+
return created_frames, valid_chapter_seconds
979+
980+
def _build_chapter_vtt(self, chapters, duration):
981+
"""Build WebVTT content string from chapters list."""
982+
if not chapters:
983+
return
984+
vtt = "WEBVTT\n\n"
985+
for i, c in enumerate(sorted(chapters, key=lambda x: x["seconds"])):
986+
start = c["seconds"]
987+
end = chapters[i+1]["seconds"] if i+1 < len(chapters) else duration
988+
start_str = "{:02}:{:02}:{:02}.000".format(
989+
int(start // 3600),
990+
int((start % 3600) // 60),
991+
int(start % 60)
992+
)
993+
end_str = "{:02}:{:02}:{:02}.000".format(
994+
int(end // 3600),
995+
int((end % 3600) // 60),
996+
int(end % 60)
997+
)
998+
vtt += f"{i+1}\n{start_str} --> {end_str}\n{c['title']}\n\n"
999+
1000+
vtt_bytes = vtt.encode("utf-8")
1001+
vtt_key = "chapters.vtt"
1002+
1003+
obj = ObjectVersion.create(
1004+
bucket=self.object_version.bucket,
1005+
key=vtt_key,
1006+
stream=BytesIO(vtt_bytes),
1007+
size=len(vtt_bytes),
1008+
)
1009+
ObjectVersionTag.create(obj, "media_type", "chapters")
1010+
ObjectVersionTag.create(obj, "context_type", "chapters")
1011+
ObjectVersionTag.create(obj, "content_type", "vtt")
1012+
self.log("Created chapters.vtt")
9741013

9751014

9761015
class TranscodeVideoTask(AVCTask):

cds/modules/previewer/api.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,18 @@ def vr(self):
128128
"""Get video's VR flag."""
129129
return self.record.get("vr")
130130

131+
@property
132+
def chapters_uri(self):
133+
"""Get the chapters.vtt file link if available."""
134+
try:
135+
return [
136+
f["links"]["self"]
137+
for f in self.record["_files"]
138+
if f.get("context_type") == "chapters" and f.get("content_type") == "vtt"
139+
][0]
140+
except IndexError:
141+
return None
142+
131143

132144
class CDSPreviewDepositFile(PreviewFile):
133145
"""Preview deposit files implementation."""

cds/modules/previewer/extensions/video.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,6 @@ def preview(self, file, embed_config=None):
6565
else ""
6666
)
6767

68-
description = record.get('description', '')
69-
if description:
70-
record['chapters'] = parse_video_chapters(description)
71-
else:
72-
record['chapters'] = []
73-
7468
return render_template(
7569
self.template,
7670
file=file,

cds/modules/previewer/templates/cds_previewer/macros/player.html

Lines changed: 36 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454

5555
window.top.player = player;
5656

57-
// --- Chapters helpers ---
57+
// --- helpers ---
5858
function durationToSeconds(durationStr) {
5959
if (!durationStr) return null;
6060
const parts = durationStr.split(':').map(Number); // [HH, MM, SS] or [MM, SS]
@@ -66,55 +66,8 @@
6666
}
6767
return null;
6868
}
69-
function formatVttTime(total) {
70-
const h = Math.floor(total / 3600);
71-
const m = Math.floor((total % 3600) / 60);
72-
const s = Math.floor(total % 60);
73-
return String(h).padStart(2,'0') + ':' + String(m).padStart(2,'0') + ':' + String(s).padStart(2,'0') + '.000';
74-
}
75-
function buildChaptersVttFromArray(chaptersArr, videoDurationStr) {
76-
if (!chaptersArr || !chaptersArr.length) return null;
77-
78-
chaptersArr.sort((a,b) => a.seconds - b.seconds);
79-
const videoDuration = durationToSeconds(videoDurationStr);
80-
if (videoDuration !== null && videoDuration <= 0) return null;
81-
let vtt = 'WEBVTT\n\n';
82-
for (let i = 0; i < chaptersArr.length; i++) {
83-
const c = chaptersArr[i];
84-
const start = c.seconds;
85-
const end = (i + 1 < chaptersArr.length)
86-
? chaptersArr[i+1].seconds
87-
: (videoDuration || start + 1);
88-
89-
vtt += `${i+1}\n${formatVttTime(start)} --> ${formatVttTime(end)}\n${c.title}\n\n`;
90-
}
91-
return vtt;
92-
}
9369

94-
// Build textTracks
95-
var textTracksArr = [];
96-
// Chapters
97-
var chaptersArr = {{ (record.chapters if record and record.chapters else []) | tojson }};
98-
var videoDurationStr = {{ (record.duration if record and record.duration else "") | tojson }};
99-
var vttStr = buildChaptersVttFromArray(chaptersArr, videoDurationStr);
100-
if (vttStr) {
101-
var chapUrl = URL.createObjectURL(new Blob([vttStr], { type: 'text/vtt' }));
102-
textTracksArr.push({ kind: 'chapters', src: chapUrl, label: 'Chapters' });
103-
}
104-
{% if not embed_config.subtitlesOff %}
105-
textTracksArr.push({ kind: 'metadata', src: '{{ obj.thumbnails_uri }}', label: 'thumbnails', default: true });
106-
{% for uri, lang in obj.subtitles %}
107-
textTracksArr.push({
108-
kind: 'subtitles',
109-
src: '{{ uri }}',
110-
label: '{{ lang }}',
111-
srclang: '{{ lang }}'
112-
{% if embed_config.subtitles and embed_config.subtitles == lang %}, default: true{% endif %}
113-
});
114-
{% endfor %}
115-
{% endif %}
116-
117-
// Set source with textTracks
70+
// Preload
11871
player.source = {
11972
sources: [
12073
{
@@ -128,11 +81,42 @@
12881
src: '{{ obj.uri }}',
12982
type: 'video/mp4'
13083
{% endif %}
131-
}
84+
},
85+
],
86+
textTracks: [
87+
{
88+
kind: 'metadata',
89+
src: '{{ obj.thumbnails_uri }}',
90+
label: 'thumbnails',
91+
default: true,
92+
},
93+
94+
// Add chapters.vtt if available
95+
{% if obj.chapters_uri %}
96+
{
97+
kind: 'chapters',
98+
src: '{{ obj.chapters_uri }}',
99+
label: 'Chapters',
100+
},
101+
{% endif %}
102+
103+
// Add subtitles
104+
{% if not embed_config.subtitlesOff %}
105+
{% for uri, lang in obj.subtitles %}
106+
{
107+
kind: 'subtitles',
108+
src: '{{ uri }}',
109+
label: '{{ lang }}',
110+
srclang: '{{ lang }}',
111+
{% if embed_config.subtitles and embed_config.subtitles == lang %}
112+
default: true,
113+
{% endif %}
114+
},
115+
{% endfor %}
116+
{% endif %}
132117
],
133-
textTracks: textTracksArr,
134118
poster: '{{ obj.poster_uri }}',
135-
{% if obj.vr %}
119+
{% if obj.vr %}
136120
vr: {
137121
360: true,
138122
},

cds/modules/records/static/templates/cds_records/video/detail.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
<div class="cds-detail-title cds-detail-video-title">
2727
<h3 class="bt bw-1 pt-10 mb-0">
2828
<i class="fa fa-video-camera"></i> In this video
29-
<div class="pull-right"><i class="fa fa-close transcription-close" ng-click="toggleInThisVideo()"></i></div>
29+
<div class="pull-right"><i class="fa fa-close transcription-close" ng-click="closeInThisVideoSection()"></i></div>
3030
</h3>
3131
</div>
3232
<!-- Tabs -->
@@ -312,7 +312,7 @@ <h4>{{translation.title.title}}</h4>
312312
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;">
313313
<h3><strong>Chapters</strong></h3>
314314
<button class="btn btn-sm btn-default transcription-button" ng-click="toggleInThisVideo('chapters')">
315-
{{ showInThisVideoSection ? 'Show Less' : 'View All' }}
315+
View All
316316
</button>
317317
</div>
318318

@@ -369,7 +369,7 @@ <h3><strong>Transcriptions</strong></h3>
369369
<div class="transcription-button-wrapper pt-10">
370370
<button class="btn btn-sm btn-default transcription-button"
371371
ng-click="toggleInThisVideo('transcript')">
372-
{{ showInThisVideoSection ? 'Hide Transcriptions' : 'Show Transcriptions' }}
372+
Show Transcriptions
373373
</button>
374374
</div>
375375
</div>

cds/modules/theme/assets/bootstrap3/js/cds_records/cdsRecord.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,12 @@ function cdsRecordController($scope, $sce, $http, $timeout, $filter) {
117117
$scope.seekTo(timecode);
118118
};
119119

120+
$scope.closeInThisVideoSection = function () {
121+
$scope.showInThisVideoSection = false;
122+
};
123+
120124
$scope.toggleInThisVideo = function (tab) {
121-
$scope.showInThisVideoSection = !$scope.showInThisVideoSection;
125+
$scope.showInThisVideoSection = true;
122126
$scope.activeTab = tab;
123127

124128
// Jump to Transcriptions section

0 commit comments

Comments
 (0)