2828import shutil
2929import signal
3030import tempfile
31+ from io import BytesIO
3132
3233import jsonpatch
3334import requests
5758
5859from cds .modules .flows .models import FlowTaskMetadata
5960from cds .modules .flows .models import FlowTaskStatus as FlowTaskStatus
60-
61+ from cds . modules . records . api import CDSVideosFilesIterator
6162from ..ffmpeg import ff_frames , ff_probe_all
6263from ..opencast .api import OpenCast
6364from ..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
9761015class TranscodeVideoTask (AVCTask ):
0 commit comments