diff --git a/cds/modules/flows/tasks.py b/cds/modules/flows/tasks.py index 8dc0068d5..f9df6918a 100644 --- a/cds/modules/flows/tasks.py +++ b/cds/modules/flows/tasks.py @@ -28,6 +28,7 @@ import shutil import signal import tempfile +from io import BytesIO import jsonpatch import requests @@ -57,7 +58,7 @@ from cds.modules.flows.models import FlowTaskMetadata from cds.modules.flows.models import FlowTaskStatus as FlowTaskStatus - +from cds.modules.records.api import CDSVideosFilesIterator from ..ffmpeg import ff_frames, ff_probe_all from ..opencast.api import OpenCast from ..opencast.error import RequestError @@ -746,8 +747,13 @@ class ExtractChapterFramesTask(AVCTask): name = "file_video_extract_chapter_frames" @staticmethod - def clean(version_id, *args, **kwargs): - """Delete generated chapter frame ObjectVersion slaves.""" + def clean(version_id, valid_chapter_seconds=None, *args, **kwargs): + """Delete generated chapter frame ObjectVersion slaves. + + - If valid_chapter_seconds is given, keep them. + - If not, remove all chapter frames. + """ + valid_chapter_seconds = valid_chapter_seconds or [] # remove all objects version "slave" with type "frame" that are chapter frames tag_alias_1 = aliased(ObjectVersionTag) tag_alias_2 = aliased(ObjectVersionTag) @@ -764,8 +770,18 @@ def clean(version_id, *args, **kwargs): ) for slave in slaves: + ts_val = next(t.value for t in slave.tags if t.key == "timestamp") + if ts_val in valid_chapter_seconds: + continue dispose_object_version(slave) + # If no valid chapter seconds, remove the chapters.vtt file + if not valid_chapter_seconds: + master_obj = ObjectVersion.query.get(version_id) + vtt_objs = ObjectVersion.get_versions(master_obj.bucket_id, "chapters.vtt") + for vtt_obj in vtt_objs: + dispose_object_version(vtt_obj) + def run(self, *args, **kwargs): """Extract frames only at chapter timestamps from video description. @@ -808,10 +824,6 @@ def run(self, *args, **kwargs): # Parse chapters from description chapters = parse_video_chapters(description) - - if not chapters: - self.log("No chapters found in description - task completed") - return {"chapter_frames_extracted": 0, "status": "no_chapters"} self.log("Found {0} chapters in description".format(len(chapters))) @@ -822,7 +834,7 @@ def run(self, *args, **kwargs): raise ValueError("Video duration is 0 - cannot extract frames") # Check which timestamps already have frames - existing_timestamps = self._get_existing_frame_timestamps() + existing_timestamps = self._get_existing_chapter_frame_timestamps(deposit_video) def progress_updater(current_chapter): """Progress reporter.""" @@ -835,7 +847,7 @@ def progress_updater(current_chapter): ) self.log(meta["message"]) - frames = self._create_chapter_frames( + frames, chapter_seconds = self._create_chapter_frames( chapters=chapters, duration=duration, object_=self.object_version, @@ -844,6 +856,12 @@ def progress_updater(current_chapter): progress_updater=progress_updater, ) + # Clean unused chapters + self.clean(version_id=self.object_version_id, valid_chapter_seconds=chapter_seconds) + + # Create or update WebVTT file for chapters + self._build_chapter_vtt(chapters, duration) + # Sync deposit and record files sync_records_with_deposit_files(self.deposit_id) @@ -865,32 +883,17 @@ def progress_updater(current_chapter): self.log("Finished task {0}".format(kwargs["task_id"])) return "Created {0} chapter frames.".format(total_frames) - def _get_existing_frame_timestamps(self): - """Get set of existing frame timestamps to avoid duplicates.""" - tag_alias_1 = aliased(ObjectVersionTag) - tag_alias_2 = aliased(ObjectVersionTag) - tag_alias_3 = aliased(ObjectVersionTag) + def _get_existing_chapter_frame_timestamps(self, deposit): + """Get timestamps of existing chapter frames.""" + master_file = CDSVideosFilesIterator.get_master_video_file(deposit) + frames = CDSVideosFilesIterator.get_video_frames(master_file) - existing = ( - ObjectVersion.query.join(tag_alias_1, ObjectVersion.tags) - .join(tag_alias_2, ObjectVersion.tags) - .join(tag_alias_3, ObjectVersion.tags) - .filter(tag_alias_1.key == "master", tag_alias_1.value == self.object_version_id) - .filter(tag_alias_2.key == "context_type", tag_alias_2.value == "frame") - .filter(tag_alias_3.key == "timestamp") - .all() - ) - - existing_timestamps = set() - for obj in existing: - for tag in obj.tags: - if tag.key == "timestamp": - try: - existing_timestamps.add(float(tag.value)) - except ValueError: - continue - - return existing_timestamps + existing = set() + for f in frames: + tags = f.get("tags", {}) + if tags.get("is_chapter_frame") == "true": + existing.add(float(tags.get("timestamp"))) + return existing @classmethod def _create_chapter_frames( @@ -904,6 +907,7 @@ def _create_chapter_frames( ): """Create frames for chapters that don't already exist at those timestamps.""" created_frames = [] + valid_chapter_seconds = [] current_chapter = 0 with move_file_into_local(object_, delete=True) as url: @@ -920,6 +924,10 @@ def _create_chapter_frames( if chapter_seconds > duration: continue + # For 0:00 chapters, use a small offset to avoid extraction issues + chapter_seconds = max(chapter_seconds, 0.1) if chapter_seconds == 0 else chapter_seconds + valid_chapter_seconds.append(to_string(chapter_seconds)) + # Skip if frame already exists at this timestamp (with some tolerance) timestamp_exists = any( abs(existing_ts - chapter_seconds) < 0.1 @@ -931,9 +939,6 @@ def _create_chapter_frames( frame_filename = "chapter-{0}.jpg".format(int(chapter_seconds)) frame_path = os.path.join(output_dir, frame_filename) - # For 0:00 chapters, use a small offset to avoid extraction issues - chapter_seconds = max(chapter_seconds, 0.1) if chapter_seconds == 0 else chapter_seconds - try: # Extract single frame at chapter timestamp using ff_frames ff_frames( @@ -970,7 +975,41 @@ def _create_chapter_frames( ) continue - return created_frames + return created_frames, valid_chapter_seconds + + def _build_chapter_vtt(self, chapters, duration): + """Build WebVTT content string from chapters list.""" + if not chapters: + return + vtt = "WEBVTT\n\n" + for i, c in enumerate(sorted(chapters, key=lambda x: x["seconds"])): + start = c["seconds"] + end = chapters[i+1]["seconds"] if i+1 < len(chapters) else duration + start_str = "{:02}:{:02}:{:02}.000".format( + int(start // 3600), + int((start % 3600) // 60), + int(start % 60) + ) + end_str = "{:02}:{:02}:{:02}.000".format( + int(end // 3600), + int((end % 3600) // 60), + int(end % 60) + ) + vtt += f"{i+1}\n{start_str} --> {end_str}\n{c['title']}\n\n" + + vtt_bytes = vtt.encode("utf-8") + vtt_key = "chapters.vtt" + + obj = ObjectVersion.create( + bucket=self.object_version.bucket, + key=vtt_key, + stream=BytesIO(vtt_bytes), + size=len(vtt_bytes), + ) + ObjectVersionTag.create(obj, "media_type", "chapters") + ObjectVersionTag.create(obj, "context_type", "chapters") + ObjectVersionTag.create(obj, "content_type", "vtt") + self.log("Created chapters.vtt") class TranscodeVideoTask(AVCTask): diff --git a/cds/modules/previewer/api.py b/cds/modules/previewer/api.py index d226f2b7e..8c94f3b4b 100644 --- a/cds/modules/previewer/api.py +++ b/cds/modules/previewer/api.py @@ -128,6 +128,18 @@ def vr(self): """Get video's VR flag.""" return self.record.get("vr") + @property + def chapters_uri(self): + """Get the chapters.vtt file link if available.""" + try: + return [ + f["links"]["self"] + for f in self.record["_files"] + if f.get("context_type") == "chapters" and f.get("content_type") == "vtt" + ][0] + except IndexError: + return None + class CDSPreviewDepositFile(PreviewFile): """Preview deposit files implementation.""" diff --git a/cds/modules/previewer/extensions/video.py b/cds/modules/previewer/extensions/video.py index 3f01305fb..6bbc754e7 100644 --- a/cds/modules/previewer/extensions/video.py +++ b/cds/modules/previewer/extensions/video.py @@ -65,12 +65,6 @@ def preview(self, file, embed_config=None): else "" ) - description = record.get('description', '') - if description: - record['chapters'] = parse_video_chapters(description) - else: - record['chapters'] = [] - return render_template( self.template, file=file, diff --git a/cds/modules/previewer/templates/cds_previewer/macros/player.html b/cds/modules/previewer/templates/cds_previewer/macros/player.html index 8144c430f..bf45ca7fe 100644 --- a/cds/modules/previewer/templates/cds_previewer/macros/player.html +++ b/cds/modules/previewer/templates/cds_previewer/macros/player.html @@ -54,7 +54,7 @@ window.top.player = player; - // --- Chapters helpers --- + // --- helpers --- function durationToSeconds(durationStr) { if (!durationStr) return null; const parts = durationStr.split(':').map(Number); // [HH, MM, SS] or [MM, SS] @@ -66,55 +66,8 @@ } return null; } - function formatVttTime(total) { - const h = Math.floor(total / 3600); - const m = Math.floor((total % 3600) / 60); - const s = Math.floor(total % 60); - return String(h).padStart(2,'0') + ':' + String(m).padStart(2,'0') + ':' + String(s).padStart(2,'0') + '.000'; - } - function buildChaptersVttFromArray(chaptersArr, videoDurationStr) { - if (!chaptersArr || !chaptersArr.length) return null; - - chaptersArr.sort((a,b) => a.seconds - b.seconds); - const videoDuration = durationToSeconds(videoDurationStr); - if (videoDuration !== null && videoDuration <= 0) return null; - let vtt = 'WEBVTT\n\n'; - for (let i = 0; i < chaptersArr.length; i++) { - const c = chaptersArr[i]; - const start = c.seconds; - const end = (i + 1 < chaptersArr.length) - ? chaptersArr[i+1].seconds - : (videoDuration || start + 1); - - vtt += `${i+1}\n${formatVttTime(start)} --> ${formatVttTime(end)}\n${c.title}\n\n`; - } - return vtt; - } - // Build textTracks - var textTracksArr = []; - // Chapters - var chaptersArr = {{ (record.chapters if record and record.chapters else []) | tojson }}; - var videoDurationStr = {{ (record.duration if record and record.duration else "") | tojson }}; - var vttStr = buildChaptersVttFromArray(chaptersArr, videoDurationStr); - if (vttStr) { - var chapUrl = URL.createObjectURL(new Blob([vttStr], { type: 'text/vtt' })); - textTracksArr.push({ kind: 'chapters', src: chapUrl, label: 'Chapters' }); - } - {% if not embed_config.subtitlesOff %} - textTracksArr.push({ kind: 'metadata', src: '{{ obj.thumbnails_uri }}', label: 'thumbnails', default: true }); - {% for uri, lang in obj.subtitles %} - textTracksArr.push({ - kind: 'subtitles', - src: '{{ uri }}', - label: '{{ lang }}', - srclang: '{{ lang }}' - {% if embed_config.subtitles and embed_config.subtitles == lang %}, default: true{% endif %} - }); - {% endfor %} - {% endif %} - - // Set source with textTracks + // Preload player.source = { sources: [ { @@ -128,11 +81,42 @@ src: '{{ obj.uri }}', type: 'video/mp4' {% endif %} - } + }, + ], + textTracks: [ + { + kind: 'metadata', + src: '{{ obj.thumbnails_uri }}', + label: 'thumbnails', + default: true, + }, + + // Add chapters.vtt if available + {% if obj.chapters_uri %} + { + kind: 'chapters', + src: '{{ obj.chapters_uri }}', + label: 'Chapters', + }, + {% endif %} + + // Add subtitles + {% if not embed_config.subtitlesOff %} + {% for uri, lang in obj.subtitles %} + { + kind: 'subtitles', + src: '{{ uri }}', + label: '{{ lang }}', + srclang: '{{ lang }}', + {% if embed_config.subtitles and embed_config.subtitles == lang %} + default: true, + {% endif %} + }, + {% endfor %} + {% endif %} ], - textTracks: textTracksArr, poster: '{{ obj.poster_uri }}', - {% if obj.vr %} + {% if obj.vr %} vr: { 360: true, }, diff --git a/cds/modules/records/static/templates/cds_records/video/detail.html b/cds/modules/records/static/templates/cds_records/video/detail.html index 17bcaf145..d079dade0 100644 --- a/cds/modules/records/static/templates/cds_records/video/detail.html +++ b/cds/modules/records/static/templates/cds_records/video/detail.html @@ -26,7 +26,7 @@