Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 77 additions & 38 deletions cds/modules/flows/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import shutil
import signal
import tempfile
from io import BytesIO

import jsonpatch
import requests
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

we need to eventually dispose also the chapters.vtt if we don't keep any chapter...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added here

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.

Expand Down Expand Up @@ -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)))

Expand All @@ -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."""
Expand All @@ -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,
Expand All @@ -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)

Expand All @@ -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(
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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):
Expand Down
12 changes: 12 additions & 0 deletions cds/modules/previewer/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
6 changes: 0 additions & 6 deletions cds/modules/previewer/extensions/video.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
88 changes: 36 additions & 52 deletions cds/modules/previewer/templates/cds_previewer/macros/player.html
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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: [
{
Expand All @@ -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,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
<div class="cds-detail-title cds-detail-video-title">
<h3 class="bt bw-1 pt-10 mb-0">
<i class="fa fa-video-camera"></i> In this video
<div class="pull-right"><i class="fa fa-close transcription-close" ng-click="toggleInThisVideo()"></i></div>
<div class="pull-right"><i class="fa fa-close transcription-close" ng-click="closeInThisVideoSection()"></i></div>
</h3>
</div>
<!-- Tabs -->
Expand Down Expand Up @@ -312,7 +312,7 @@ <h4>{{translation.title.title}}</h4>
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;">
<h3><strong>Chapters</strong></h3>
<button class="btn btn-sm btn-default transcription-button" ng-click="toggleInThisVideo('chapters')">
{{ showInThisVideoSection ? 'Show Less' : 'View All' }}
View All
</button>
</div>

Expand Down Expand Up @@ -369,7 +369,7 @@ <h3><strong>Transcriptions</strong></h3>
<div class="transcription-button-wrapper pt-10">
<button class="btn btn-sm btn-default transcription-button"
ng-click="toggleInThisVideo('transcript')">
{{ showInThisVideoSection ? 'Hide Transcriptions' : 'Show Transcriptions' }}
Show Transcriptions
</button>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,12 @@ function cdsRecordController($scope, $sce, $http, $timeout, $filter) {
$scope.seekTo(timecode);
};

$scope.closeInThisVideoSection = function () {
$scope.showInThisVideoSection = false;
};

$scope.toggleInThisVideo = function (tab) {
$scope.showInThisVideoSection = !$scope.showInThisVideoSection;
$scope.showInThisVideoSection = true;
$scope.activeTab = tab;

// Jump to Transcriptions section
Expand Down
Loading