Skip to content

Commit f9b9894

Browse files
feature: chapter improvements
1 parent edd8d34 commit f9b9894

8 files changed

Lines changed: 65 additions & 147 deletions

File tree

cds/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1127,7 +1127,7 @@ def _parse_env_bool(var_name, default=None):
11271127
"'unsafe-inline'",
11281128
],
11291129
"img-src": ["'self'", "https://*.theoplayer.com", "data:"],
1130-
"connect-src": ["'self'", "https://*.theoplayer.com", "https://*.cern.ch"],
1130+
"connect-src": ["'self'", "https://*.theoplayer.com", "https://*.cern.ch", "blob:"],
11311131
"object-src": ["'self'"],
11321132
"media-src": ["'self'", "blob:"],
11331133
"frame-src": ["'self'", "https://*.theoplayer.com"],

cds/modules/previewer/extensions/video.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"""Previews video files."""
2626

2727

28+
from cds.modules.records.utils import parse_video_chapters
2829
from flask import render_template
2930

3031

@@ -63,6 +64,12 @@ def preview(self, file, embed_config=None):
6364
if "report_number" in record and len(record["report_number"])
6465
else ""
6566
)
67+
68+
description = record.get('description', '')
69+
if description:
70+
record['chapters'] = parse_video_chapters(description)
71+
else:
72+
record['chapters'] = []
6673

6774
return render_template(
6875
self.template,

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

Lines changed: 17 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -66,86 +66,37 @@
6666
}
6767
return null;
6868
}
69-
function cleanHtmlFromTitle(title) {
70-
if (!title) return title;
71-
72-
// Decode HTML entities by using a temporary DOM element
73-
const temp = document.createElement("textarea");
74-
temp.innerHTML = title;
75-
let decoded = temp.value;
76-
decoded = decoded.replace(/<[^>]+>/g, " ");
77-
decoded = decoded.replace(/\s+/g, " ").trim();
78-
79-
return decoded;
80-
}
81-
function parseChapters(description) {
82-
if (!description) return [];
83-
84-
const pattern = /(?:^|\n)\s*(\d{1,2}:(?:\d{1,2}:)?\d{1,2})\s*[-\s]*(.+?)(?=\n|$)/gm;
85-
const chapters = [];
86-
let match;
87-
88-
while ((match = pattern.exec(description)) !== null) {
89-
const [, timestampStr, title] = match;
90-
91-
const timeParts = timestampStr.split(":");
92-
let totalSeconds;
93-
94-
if (timeParts.length === 2) {
95-
const [minutes, seconds] = timeParts.map(Number);
96-
totalSeconds = minutes * 60 + seconds;
97-
} else if (timeParts.length === 3) {
98-
const [hours, minutes, seconds] = timeParts.map(Number);
99-
totalSeconds = hours * 3600 + minutes * 60 + seconds;
100-
} else {
101-
continue;
102-
}
103-
104-
const cleanTitle = cleanHtmlFromTitle(title);
105-
if (cleanTitle) {
106-
chapters.push({
107-
startTime: totalSeconds,
108-
text: cleanTitle
109-
});
110-
}
111-
}
112-
113-
chapters.sort((a, b) => a.startTime - b.startTime);
114-
115-
// Add endTime for VTT
116-
const videoDuration = durationToSeconds({{ (record.duration if record and record.duration else "") | tojson }});
117-
for (let i = 0; i < chapters.length; i++) {
118-
if (i + 1 < chapters.length) {
119-
chapters[i].endTime = chapters[i + 1].startTime;
120-
} else {
121-
chapters[i].endTime = videoDuration || (chapters[i].startTime + 1);
122-
}
123-
}
124-
125-
return chapters;
126-
}
12769
function formatVttTime(total) {
12870
const h = Math.floor(total / 3600);
12971
const m = Math.floor((total % 3600) / 60);
13072
const s = Math.floor(total % 60);
13173
return String(h).padStart(2,'0') + ':' + String(m).padStart(2,'0') + ':' + String(s).padStart(2,'0') + '.000';
13274
}
133-
function buildChaptersVtt(description) {
134-
const chapters = parseChapters(description);
135-
if (!chapters.length) return null;
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;
13681
let vtt = 'WEBVTT\n\n';
137-
for (let i = 0; i < chapters.length; i++) {
138-
const c = chapters[i];
139-
vtt += `${i+1}\n${formatVttTime(c.startTime)} --> ${formatVttTime(c.endTime)}\n${c.text}\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`;
14090
}
14191
return vtt;
14292
}
14393

14494
// Build textTracks
14595
var textTracksArr = [];
14696
// Chapters
147-
var descStr = {{ (record.description if record and record.description else "") | tojson }};
148-
var vttStr = buildChaptersVtt(descStr);
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);
149100
if (vttStr) {
150101
var chapUrl = URL.createObjectURL(new Blob([vttStr], { type: 'text/vtt' }));
151102
textTracksArr.push({ kind: 'chapters', src: chapUrl, label: 'Chapters' });

cds/modules/records/serializers/json.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
has_read_record_eos_path_permission,
3232
has_read_record_permission,
3333
)
34-
from ..utils import HTMLTagRemover, remove_html_tags
34+
from ..utils import HTMLTagRemover, parse_video_chapters, remove_html_tags
3535

3636

3737
class CDSJSONSerializer(JSONSerializer):
@@ -81,6 +81,12 @@ def preprocess_record(self, pid, record, links_factory=None):
8181
except KeyError:
8282
# ignore error if keys are missing in the metadata
8383
pass
84+
85+
description = metadata.get('description', '')
86+
if description:
87+
metadata['chapters'] = parse_video_chapters(description)
88+
else:
89+
metadata['chapters'] = []
8490

8591
return result
8692

cds/modules/records/serializers/schemas/video.py

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,6 @@ class VideoSchema(StrictKeysSchema):
167167
)
168168
collections = fields.List(fields.Str, many=True)
169169
additional_languages = fields.List(fields.Str, many=True)
170-
chapters = fields.List(fields.Dict, dump_only=True)
171170

172171
# Preservation fields
173172
location = fields.Str()
@@ -180,13 +179,3 @@ def post_load(self, data, **kwargs):
180179
data["$schema"] = current_jsonschemas.path_to_url(Video._schema)
181180
return data
182181

183-
@post_dump(pass_many=False)
184-
def post_dump(self, data, **kwargs):
185-
"""Post dump - add parsed chapters."""
186-
description = data.get('description', '')
187-
if description:
188-
data['chapters'] = parse_video_chapters(description)
189-
else:
190-
data['chapters'] = []
191-
192-
return data

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<div class="cds-detail-video cds-detail-video-wrapper bg-n pb-20">
55
<!-- Video Section -->
66
<div ng-if="record.metadata.recid && (record | findMaster).key" class="bg-b">
7-
<div ng-class="{ 'container-fluid': showInThisVideoSection }">
7+
<div ng-class="{ 'container-fluid': showInThisVideoSection }" class="video-section" id="videoPlayerSection">
88
<div ng-class="{ 'row': ((record.metadata._files | filter:{context_type:'subtitle', content_type:'vtt'}).length > 0 || chapters.length > 0) && showInThisVideoSection }">
99
<div ng-class="{ 'col-md-8 pt-20': ((record.metadata._files | filter:{context_type:'subtitle', content_type:'vtt'}).length > 0 || chapters.length > 0) && showInThisVideoSection }">
1010
<div class="cds-video-iframe text-center">
@@ -21,7 +21,7 @@
2121
<!-- Section In this video -->
2222
<div ng-show="((record.metadata._files | filter:{context_type:'subtitle', content_type:'vtt'}).length > 0 || chapters.length > 0) && showInThisVideoSection"
2323
class="col-md-4 bg-b pt-20"
24-
id="transcriptionsSection">
24+
id="inThisVideoSection">
2525
<div class="in-this-video-panel bg-w pl-10 pb-10 pr-10 pt-10">
2626
<div class="cds-detail-title cds-detail-video-title">
2727
<h3 class="bt bw-1 pt-10 mb-0">
@@ -316,13 +316,12 @@ <h3><strong>Chapters</strong></h3>
316316
</button>
317317
</div>
318318

319-
<!-- Horizontal Chapter List (first 6 always visible) -->
319+
<!-- Horizontal Chapter List -->
320320
<div class="chapters-main-horizontal" style="overflow-x: auto; overflow-y: hidden; white-space: nowrap;">
321321
<div style="display: inline-flex; gap: 15px; padding-bottom: 8px; padding-top: 8px;">
322322
<div ng-repeat="chapter in chapters"
323-
ng-if="$index < 6"
324323
class="chapter-item-horizontal-main"
325-
ng-click="seekTo(chapter.seconds)"
324+
ng-click="jumpToChapter(chapter.seconds)"
326325
style="display: inline-block; cursor: pointer; width: 180px; flex-shrink: 0; padding: 12px; border: 1px solid #e1e8ed; border-radius: 8px; transition: all 0.2s ease; background-color: white;"
327326
onmouseover="this.style.backgroundColor='#f8f9fa'; this.style.borderColor='#2196F3'; this.style.transform='translateY(-2px)';"
328327
onmouseout="this.style.backgroundColor='white'; this.style.borderColor='#e1e8ed'; this.style.transform='translateY(0)';">

cds/modules/records/utils.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,7 @@ def parse_video_chapters(description):
500500
Returns:
501501
list: List of chapter dicts with 'timestamp', 'seconds', and 'title' keys
502502
"""
503+
html_tag_remover = HTMLTagRemover()
503504
if not description:
504505
return []
505506

@@ -524,7 +525,7 @@ def parse_video_chapters(description):
524525
continue
525526

526527
# Clean up title
527-
title = title.strip()
528+
title = remove_html_tags(html_tag_remover, title).strip()
528529
if title:
529530
chapters.append({
530531
'timestamp': timestamp_str,

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

Lines changed: 27 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,26 @@ function cdsRecordController($scope, $sce, $http, $timeout, $filter) {
6767
"X-CSRFToken": getCookie("csrftoken"),
6868
};
6969

70+
$scope.scrollToElement = function (id) {
71+
setTimeout(function () {
72+
const el = document.getElementById(id);
73+
if (el) {
74+
const rect = el.getBoundingClientRect();
75+
const isVisible =
76+
rect.top >= 0 &&
77+
rect.bottom <=
78+
(window.innerHeight || document.documentElement.clientHeight);
79+
80+
if (!isVisible) {
81+
const topOffset = rect.top + window.scrollY - 60; // adjust for sticky header
82+
window.scrollTo({ top: topOffset, behavior: "smooth" });
83+
}
84+
} else {
85+
console.warn("Element not found:", id);
86+
}
87+
}, 100);
88+
};
89+
7090
$scope.seekTo = function (timecode) {
7191
const player = window.top.player;
7292
if (player) {
@@ -92,27 +112,18 @@ function cdsRecordController($scope, $sce, $http, $timeout, $filter) {
92112
}
93113
};
94114

115+
$scope.jumpToChapter = function (timecode) {
116+
$scope.scrollToElement("videoPlayerSection");
117+
$scope.seekTo(timecode);
118+
};
119+
95120
$scope.toggleInThisVideo = function (tab) {
96121
$scope.showInThisVideoSection = !$scope.showInThisVideoSection;
97122
$scope.activeTab = tab;
98123

99124
// Jump to Transcriptions section
100125
if ($scope.showInThisVideoSection) {
101-
setTimeout(function () {
102-
const el = document.getElementById("transcriptionsSection");
103-
if (el) {
104-
const rect = el.getBoundingClientRect();
105-
const isVisible =
106-
rect.top >= 0 &&
107-
rect.bottom <=
108-
(window.innerHeight || document.documentElement.clientHeight);
109-
110-
if (!isVisible) {
111-
const topOffset = rect.top + window.scrollY - 60;
112-
window.scrollTo({ top: topOffset, behavior: "smooth" });
113-
}
114-
}
115-
}, 100);
126+
$scope.scrollToElement("inThisVideoSection");
116127
}
117128
};
118129

@@ -197,8 +208,6 @@ function cdsRecordController($scope, $sce, $http, $timeout, $filter) {
197208
// Use chapters from API or parse from description as fallback
198209
if (record.metadata.chapters && record.metadata.chapters.length > 0) {
199210
$scope.chapters = record.metadata.chapters;
200-
} else if (record.metadata.description) {
201-
$scope.chapters = $scope.parseChapters(record.metadata.description);
202211
}
203212

204213
// Set default active tab based on what's available (prioritize chapters)
@@ -345,50 +354,6 @@ function cdsRecordController($scope, $sce, $http, $timeout, $filter) {
345354
return `${minutes}:${paddedSecs}`;
346355
};
347356

348-
$scope.parseChapters = function (description) {
349-
if (!description) return [];
350-
351-
// Regex pattern to match timestamp formats: 0:00, 00:00, 0:00:00, 00:00:00
352-
const pattern =
353-
/(?:^|\n)\s*(\d{1,2}:(?:\d{1,2}:)?\d{1,2})\s*[-\s]*(.+?)(?=\n|$)/gm;
354-
const chapters = [];
355-
let match;
356-
357-
while ((match = pattern.exec(description)) !== null) {
358-
const [, timestampStr, title] = match;
359-
360-
// Parse timestamp to seconds
361-
const timeParts = timestampStr.split(":");
362-
let totalSeconds;
363-
364-
if (timeParts.length === 2) {
365-
// MM:SS format
366-
const [minutes, seconds] = timeParts.map(Number);
367-
totalSeconds = minutes * 60 + seconds;
368-
} else if (timeParts.length === 3) {
369-
// HH:MM:SS format
370-
const [hours, minutes, seconds] = timeParts.map(Number);
371-
totalSeconds = hours * 3600 + minutes * 60 + seconds;
372-
} else {
373-
continue;
374-
}
375-
376-
// Clean up title
377-
const cleanTitle = title.trim();
378-
if (cleanTitle) {
379-
chapters.push({
380-
timestamp: timestampStr,
381-
seconds: totalSeconds,
382-
title: cleanTitle,
383-
});
384-
}
385-
}
386-
387-
// Sort chapters by timestamp
388-
chapters.sort((a, b) => a.seconds - b.seconds);
389-
return chapters;
390-
};
391-
392357
$scope.setActiveTab = function (tab) {
393358
$scope.activeTab = tab;
394359
};
@@ -415,7 +380,7 @@ function cdsRecordController($scope, $sce, $http, $timeout, $filter) {
415380
}
416381

417382
// Return clickable timestamp using onclick for ng-bind-html compatibility
418-
return `<a href="javascript:void(0)" class="timestamp-link" onclick="(function(){ try { angular.element(document.querySelector('.cds-detail-description')).scope().seekTo(${totalSeconds}); } catch(e) { console.error('Could not seek to timestamp:', e); } })()" style="color: #2196F3; font-weight: 600; cursor: pointer;">${match}</a>`;
383+
return `<a href="javascript:void(0)" class="timestamp-link" onclick="(function(){ try { angular.element(document.querySelector('.cds-detail-description')).scope().jumpToChapter(${totalSeconds}); } catch(e) { console.error('Could not seek to timestamp:', e); } })()" style="color: #2196F3; font-weight: 600; cursor: pointer;">${match}</a>`;
419384
});
420385
};
421386

0 commit comments

Comments
 (0)