Write dive data as video subtitles

This commit adds an entry to the dive media context
menu which offers to write a subtitle file. This
creates an .ass file for the selected videos.

In an attempt to to clutter the screen too much, don't
show irrelevant entries (zero temperature or
NDL and show TTS only for dives with stops).

VLC is able to show these subtitles directly, they
can be integrated into the video file with ffmpeg.

Signed-off-by: Robert C. Helling <helling@atdotde.de>
This commit is contained in:
Robert C. Helling 2019-04-14 16:19:23 +02:00 committed by bstoeger
parent 0573b19b65
commit 52105e5217
8 changed files with 125 additions and 2 deletions

View file

@ -1,3 +1,4 @@
- Desktop: For videos, add save data export as subtitle file
- Desktop: make dive sites 1st class citizens with their own dive site table
- Desktop: only show dives at the dive sites selected in dive site table
- Desktop: add undo functionality to edit operations and remove 'edited' state;

View file

@ -1638,6 +1638,17 @@ or play the video, overlaying the _Subsurface_ window. Delete media from the _Me
it (single-click) and then by pressing the _Del_ key on the keyboard. This removes it BOTH
from the _Media_ tab as well as the dive profile.
By right-clicking on a video and selecting the "Save dive data as subtitles" option, a subtitles
file with the same name as the video but with an ".ass" extension is created that contains
time dependent dive data (runtime, depth, temperature, NDL, TTS, surface GF) to be overlayed
with the video. The VLC video player automatically finds this file upon playing the video
and overlays the dive data. Alternatively, the ffmpeg video encoder can be used to create a
new video file with the dive data encoded in the video stream. To do so run
ffmpeg -v video.mp4 -vf "ass=video.ass" video_with_data.mp4
from the command line. You need to have the libass library installed.
==== Media on an external hard disk
Most underwater photographers store media on an external drive. If such a drive can be mapped by the operating system
(almost always the case) the media can be directly accessed by _Subsurface_. This eases the interaction

View file

@ -5,6 +5,7 @@
#include "core/membuffer.h"
#include "core/subsurface-string.h"
#include "core/save-profiledata.h"
#include "core/version.h"
static void put_int(struct membuffer *b, int val)
{
@ -21,6 +22,15 @@ static void put_double(struct membuffer *b, double val)
put_format(b, "\"%f\" ", val);
}
static void put_video_time(struct membuffer *b, int secs)
{
int hours = secs / 3600;
secs -= hours * 3600;
int mins = secs / 60;
secs -= mins * 60;
put_format(b, "%d:%02d:%02d.000,", hours, mins, secs);
}
static void put_pd(struct membuffer *b, struct plot_data *entry)
{
if (!entry)
@ -148,6 +158,39 @@ static void put_headers(struct membuffer *b)
put_csv_string(b, "icd_warning");
}
static void put_st_event(struct membuffer *b, struct plot_data *entry, int offset, int length)
{
double value;
int decimals;
const char *unit;
if (entry->sec < offset || entry->sec > offset + length)
return;
put_format(b, "Dialogue: 0,");
put_video_time(b, entry->sec - offset);
put_video_time(b, (entry+1)->sec - offset < length ? (entry+1)->sec - offset : length);
put_format(b, "Default,,0,0,0,,");
put_format(b, "%d:%02d ", FRACTION(entry->sec, 60));
value = get_depth_units(entry->depth, &decimals, &unit);
put_format(b, "D=%02.2f %s ", value, unit);
if (entry->temperature) {
value = get_temp_units(entry->temperature, &unit);
put_format(b, "T=%.1f%s ", value, unit);
}
// Only show NDL if it is not essentially infinite, show TTS for mandatory stops.
if (entry->ndl_calc < 3600) {
if (entry->ndl_calc > 0)
put_format(b, "NDL=%d:%02d ", FRACTION(entry->ndl_calc, 60));
else
if (entry->tts_calc > 0)
put_format(b, "TTS=%d:%02d ", FRACTION(entry->tts_calc, 60));
}
if (entry->surface_gf > 0.0) {
put_format(b, "sGF=%.1f%% ", entry->surface_gf);
}
put_format(b, "\n");
}
static void save_profiles_buffer(struct membuffer *b, bool select_only)
{
int i;
@ -171,6 +214,27 @@ static void save_profiles_buffer(struct membuffer *b, bool select_only)
}
}
void save_subtitles_buffer(struct membuffer *b, struct dive *dive, int offset, int length)
{
struct plot_info pi;
struct deco_state *planner_deco_state = NULL;
pi = calculate_max_limits_new(dive, &dive->dc);
create_plot_info_new(dive, &dive->dc, &pi, false, planner_deco_state);
put_format(b, "[Script Info]\n");
put_format(b, "; Script generated by Subsurface %s\n", subsurface_canonical_version());
put_format(b, "ScriptType: v4.00+\nPlayResX: 384\nPlayResY: 288\n\n");
put_format(b, "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n");
put_format(b, "Style: Default,Arial,12,&Hffffff,&Hffffff,&H0,&H0,0,0,0,0,100,100,0,0,1,1,0,7,10,10,10,0\n\n");
put_format(b, "[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n");
for (int i = 0; i < pi.nr; i++) {
put_st_event(b, &pi.entry[i], offset, length);
}
put_format(b, "\n");
}
int save_profiledata(const char *filename, const bool select_only)
{
struct membuffer buf = { 0 };

View file

@ -9,6 +9,7 @@ extern "C" {
#endif
int save_profiledata(const char *filename, bool selected_only);
void save_subtitles_buffer(struct membuffer *b, struct dive *dive, int offset, int length);
#ifdef __cplusplus
}

View file

@ -11,6 +11,8 @@
#include <QUrl>
#include <QMessageBox>
#include <QFileInfo>
#include "core/save-profiledata.h"
#include "core/membuffer.h"
//TODO: Remove those in the future.
#include "../mainwindow.h"
@ -57,6 +59,7 @@ void TabDivePhotos::contextMenuEvent(QContextMenuEvent *event)
popup.addAction(tr("Delete all media files"), this, SLOT(removeAllPhotos()));
popup.addAction(tr("Open folder of selected media files"), this, SLOT(openFolderOfSelectedFiles()));
popup.addAction(tr("Recalculate selected thumbnails"), this, SLOT(recalculateSelectedThumbnails()));
popup.addAction(tr("Save dive data as subtitles"), this, SLOT(saveSubtitles()));
popup.exec(event->globalPos());
event->accept();
}
@ -106,6 +109,40 @@ void TabDivePhotos::recalculateSelectedThumbnails()
Thumbnailer::instance()->calculateThumbnails(getSelectedFilenames());
}
void TabDivePhotos::saveSubtitles()
{
QVector<QString> selectedPhotos;
if (!ui->photosView->selectionModel()->hasSelection())
return;
QModelIndexList indexes = ui->photosView->selectionModel()->selectedRows();
if (indexes.count() == 0)
indexes = ui->photosView->selectionModel()->selectedIndexes();
selectedPhotos.reserve(indexes.count());
for (const auto &photo: indexes) {
if (photo.isValid()) {
QString fileUrl = photo.data(Qt::DisplayPropertyRole).toString();
if (!fileUrl.isEmpty()) {
QFileInfo fi = QFileInfo(fileUrl);
QFile subtitlefile;
subtitlefile.setFileName(QString(fi.path()) + "/" + fi.completeBaseName() + ".ass");
int offset = photo.data(Qt::UserRole + 1).toInt();
int duration = photo.data(Qt::UserRole + 2).toInt();
// Only videos have non-zero duration
if (!duration)
continue;
struct membuffer b = { 0 };
save_subtitles_buffer(&b, &displayed_dive, offset, duration);
char *data = detach_buffer(&b);
subtitlefile.open(QIODevice::WriteOnly);
subtitlefile.write(data, strlen(data));
subtitlefile.close();
free(data);
}
}
}
}
//TODO: This looks overly wrong. We shouldn't call MainWindow to retrieve the DiveList to add Images.
void TabDivePhotos::addPhotosFromFile()
{

View file

@ -29,6 +29,7 @@ private slots:
void recalculateSelectedThumbnails();
void openFolderOfSelectedFiles();
void changeZoomLevel(int delta);
void saveSubtitles();
private:
Ui::TabDivePhotos *ui;

View file

@ -59,7 +59,7 @@ void DivePictureModel::updateDivePictures()
if (dive->selected) {
int first = pictures.count();
FOR_EACH_PICTURE(dive)
pictures.push_back({ dive->id, picture, picture->filename, {}, picture->offset.seconds });
pictures.push_back({ dive->id, picture, picture->filename, {}, picture->offset.seconds, {.seconds = 0}});
// Sort pictures of this dive by offset.
// Thus, the list will be sorted by (diveId, offset).
@ -101,6 +101,11 @@ QVariant DivePictureModel::data(const QModelIndex &index, int role) const
case Qt::UserRole:
ret = entry.diveId;
break;
case Qt::UserRole + 1:
ret = entry.offsetSeconds;
break;
case Qt::UserRole + 2:
ret = entry.length.seconds;
}
} else if (index.column() == 1) {
switch (role) {
@ -197,8 +202,10 @@ void DivePictureModel::updateThumbnail(QString filename, QImage thumbnail, durat
{
int i = findPictureId(filename);
if (i >= 0) {
if (duration.seconds > 0)
if (duration.seconds > 0) {
addDurationToThumbnail(thumbnail, duration); // If we know the duration paint it on top of the thumbnail
pictures[i].length = duration;
}
pictures[i].image = thumbnail;
emit dataChanged(createIndex(i, 0), createIndex(i, 1));
}

View file

@ -14,6 +14,7 @@ struct PictureEntry {
QString filename;
QImage image;
int offsetSeconds;
duration_t length;
};
class DivePictureModel : public QAbstractTableModel {