Program Listing for File session_info.hpp#

Return to documentation for file (src/session/session_info.hpp)

#pragma once
#include <QColor>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QList>
#include <QString>
#include <QStringList>
#include <algorithm>

#include "session/session_name.hpp"

namespace mosaic {

// ── AnnotationCategory ─────────────────────────────────────────────────────

struct AnnotationCategory {
    QString name;
    QColor color;

    static QList<AnnotationCategory> defaults() {
        return {
            {"Grooming", QColor("#e8865a")}, {"Exploration", QColor("#5ab4e8")},
            {"Social", QColor("#a05ae8")},   {"Rearing", QColor("#e8c05a")},
            {"Freezing", QColor("#5ae8c0")}, {"Vocalization", QColor("#e85a8a")},
            {"Eating", QColor("#88e85a")},   {"Drinking", QColor("#5a8ae8")},
            {"Custom", QColor("#909090")},
        };
    }

    static QColor color_for(const QString& name) {
        for (const auto& c : defaults()) {
            if (c.name == name) {
                return c.color;
            }
        }
        return QColor("#909090");
    }
};

// ── Annotation ─────────────────────────────────────────────────────────────

struct Annotation {
    int64_t timestampMs = 0; // ms from session start (0 = session begin)
    QString category;
    QString note;

    QJsonObject to_json() const {
        return {
            {"timestamp_ms", timestampMs},
            {"category", category},
            {"note", note},
        };
    }

    static Annotation from_json(const QJsonObject& o) {
        Annotation a;
        a.timestampMs = static_cast<int64_t>(o["timestamp_ms"].toVariant().toLongLong());
        a.category    = o["category"].toString();
        a.note        = o["note"].toString();
        return a;
    }
};

// ── SessionInfo ────────────────────────────────────────────────────────────

struct SessionInfo {
    QString path;
    QString name; // directory basename
    QString recordedBy;
    QDateTime startUtc;
    int durationMs  = -1;
    int cameraCount = 0;
    int micCount    = 0;
    QString mosaicVersion;
    QString videoCodec;
    QString audioCodec;
    bool hasPoseAnalysis   = false;
    bool hasMotionAnalysis = false;
    bool hasTranscript     = false;
    bool hasExpression     = false;
    bool hasGazeFusion     = false;
    bool hasSkeleton3D     = false;
    bool hasRppg           = false;
    bool hasGaze2d         = false;
    bool hasSyncRepair     = false;
    QStringList videoFiles;
    QStringList audioFiles;
    QStringList analysisFiles;
    QList<Annotation> annotations;

    /// Free-text operator note for the whole session, from notes.txt.
    /// Session-level prose, as opposed to an Annotation, which is pinned to a
    /// moment inside the recording.
    QString notes;

    // Load from a session directory.
    static SessionInfo load(const QString& dir) {
        SessionInfo info;
        info.path = QDir::cleanPath(dir);
        info.name = QDir(dir).dirName();

        QFile metaFile(dir + "/session_meta.json");
        if (metaFile.open(QIODevice::ReadOnly)) {
            const auto doc     = QJsonDocument::fromJson(metaFile.readAll());
            const auto root    = doc.object();
            info.recordedBy    = root["recorded_by"].toString();
            info.mosaicVersion = root["mosaic_version"].toString();
            info.startUtc =
                QDateTime::fromString(root["session_start_utc"].toString(), Qt::ISODateWithMs);
            info.startUtc.setTimeZone(QTimeZone::utc());
            info.cameraCount = root["cameras"].toArray().size();
            info.micCount    = root["microphones"].toArray().size();
            const auto rec   = root["recording"].toObject();
            info.videoCodec  = rec["video_codec"].toString();
            info.audioCodec  = rec["audio_codec"].toString();
        }

        // Video/audio recordings live under video/ and audio/ subfolders.
        // Pose output has its own pose/ subfolder (analysis/run_pose.py's
        // pose_dir), Expression has its own expression/ subfolder
        // (analysis/run_expression.py's expression_dir), rPPG has its own
        // rppg/ subfolder (analysis/run_rppg.py's rppg_dir), and 2D Gaze has
        // its own gaze2d/ subfolder (analysis/run_gaze2d.py's gaze2d_dir);
        // per-video motion output still lands beside its source video in
        // video/; session-level aggregate motion output (motion_results.*,
        // motion_heatmap.png, …) stays at the session root — so analysis
        // files are scanned across all seven locations. Entries are stored
        // as paths relative to the session dir (e.g. "video/video_0.mp4",
        // "pose/video_0.pose.json") so callers can join them onto `path`
        // directly without needing to know which subfolder a given file
        // lives in.
        //
        // anonymized/ (Face Masking output) and synced/ (Frame Sync Repair
        // output) are deliberately NOT among these locations — see the
        // comment at this function's own classify() call sites below for
        // why (they hold full copy videos, not analysis sidecars, and
        // scanning them here would corrupt videoFiles's 1:1 camera-index
        // mapping).
        const auto classify = [&info](const QDir& scanDir, const QString& prefix) {
            if (!scanDir.exists()) {
                return;
            }
            for (const auto& fi : scanDir.entryInfoList(QDir::Files, QDir::Name)) {
                const QString sfx = fi.suffix().toLower();
                const QString fn  = prefix + fi.fileName();
                if (sfx == "mp4" || sfx == "mkv" || sfx == "avi") {
                    info.videoFiles << fn;
                } else if (sfx == "wav" || sfx == "flac") {
                    info.audioFiles << fn;
                } else if (fi.fileName().contains("pose") || fi.fileName().contains("motion") ||
                           fi.fileName().contains("keypoint") ||
                           fi.fileName().contains("heatmap") ||
                           fi.fileName().contains("trajectory") ||
                           fi.fileName().contains("velocity") ||
                           fi.fileName().contains("transcript") ||
                           fi.fileName().contains("expression") || fi.fileName().contains("gaze") ||
                           fi.fileName().contains("skeleton") || fi.fileName().contains("rppg")) {
                    info.analysisFiles << fn;
                    if (fi.fileName().contains("pose")) {
                        info.hasPoseAnalysis = true;
                    }
                    if (fi.fileName().contains("motion")) {
                        info.hasMotionAnalysis = true;
                    }
                    if (fi.fileName().contains("transcript")) {
                        info.hasTranscript = true;
                    }
                    if (fi.fileName().contains("expression")) {
                        info.hasExpression = true;
                    }
                    // Checked before the plain "gaze" substring below —
                    // "video_0.gaze2d.json" also contains "gaze", and would
                    // otherwise be mis-classified as Multi-Camera Gaze
                    // Fusion output too.
                    if (fi.fileName().contains("gaze2d")) {
                        info.hasGaze2d = true;
                    } else if (fi.fileName().contains("gaze")) {
                        info.hasGazeFusion = true;
                    }
                    if (fi.fileName().contains("skeleton")) {
                        info.hasSkeleton3D = true;
                    }
                    if (fi.fileName().contains("rppg")) {
                        info.hasRppg = true;
                    }
                }
            }
        };
        classify(QDir(dir), "");
        classify(QDir(dir + "/video"), "video/");
        classify(QDir(dir + "/audio"), "audio/");
        classify(QDir(dir + "/pose"), "pose/");
        classify(QDir(dir + "/expression"), "expression/");
        classify(QDir(dir + "/rppg"), "rppg/");
        classify(QDir(dir + "/gaze2d"), "gaze2d/");
        // synced/ (Frame Sync Repair output) and anonymized/ (Face Masking
        // output) are deliberately NEVER scanned via classify() — both
        // contain full COPY videos (video_N.mp4), and classify()'s
        // mp4/mkv/avi branch above adds any match to info.videoFiles
        // regardless of `prefix`, which would corrupt cameraCombo's 1:1
        // camera-index mapping used by every Analysis-tab plugin (see
        // AnalysisTabW::rebuild_session_list()). hasSyncRepair is set via a
        // narrow, standalone existence check below instead.
        //
        // The summary JSON is written even when every camera failed or was
        // skipped, so its presence alone would light the SYNCED badge for a
        // session that has no repaired video at all. run_sync_repair.py
        // deletes a failed camera's output, so "at least one synced/
        // video_*.mp4 survives" is the cheap, accurate test for "something
        // was actually repaired" — no JSON parsing needed on this hot
        // session-listing path.
        info.hasSyncRepair =
            QFileInfo::exists(dir + "/synced/sync_repair.json") &&
            !QDir(dir + "/synced").entryList({"video_*.mp4"}, QDir::Files).isEmpty();

        // Approximate duration: video file mtime vs session start
        if (info.startUtc.isValid()) {
            qint64 maxMtime = 0;
            for (const auto& fn : info.videoFiles) {
                const QFileInfo fi2(dir + "/" + fn);
                const auto mt = fi2.lastModified().toSecsSinceEpoch();
                if (mt > maxMtime) {
                    maxMtime = mt;
                }
            }
            if (maxMtime > 0) {
                const qint64 startSec = info.startUtc.toSecsSinceEpoch();
                if (maxMtime > startSec) {
                    info.durationMs = static_cast<int>((maxMtime - startSec) * 1000);
                }
            }
        }

        info.load_annotations();
        info.load_notes();
        return info;
    }

    // Order a session list newest first, by recorded start time.
    //
    // This used to be done by listing directories in reversed *name* order,
    // which was only ever accidentally chronological: "yyyy-MM-dd_hh-mm-ss"
    // happens to sort lexicographically the same way it sorts in time. A
    // BIDS-style "sub-..." prefix destroys that coincidence and would silently
    // reorder the browsers by subject instead, so ordering now uses startUtc —
    // which is what "newest first" always meant. The predicate itself lives in
    // session_name.hpp because this header pulls in QtGui (QColor, for
    // Annotation) and so can never be unit-tested.
    static void sort_newest_first(QList<SessionInfo>& sessions) {
        std::stable_sort(sessions.begin(), sessions.end(),
                         [](const SessionInfo& a, const SessionInfo& b) {
                             return session_entry_newer(a.startUtc, a.name, b.startUtc, b.name);
                         });
    }

    // List all valid sessions under rootDir, newest first.
    static QList<SessionInfo> list_all(const QString& rootDir) {
        QList<SessionInfo> result;
        const QDir root(rootDir);
        if (!root.exists()) {
            return result;
        }
        // QDir::Name only for a deterministic scan order; the real ordering is
        // applied below, once each session's start time has been read.
        const auto dirs = root.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name);
        for (const auto& di : dirs) {
            if (QFile::exists(di.filePath() + "/session_meta.json")) {
                result.append(load(di.filePath()));
            }
        }
        sort_newest_first(result);
        return result;
    }

    // Kept out of session_meta.json deliberately: that file is written once,
    // before any data exists, and holds calibration matrices and time origins.
    // Notes have to stay editable afterwards — the useful one is usually
    // written after the session — and rewriting the provenance file to fix a
    // sentence risks taking the rest with it. Same split as annotations.json.
    void load_notes() {
        notes.clear();
        QFile f(path + "/notes.txt");
        if (f.open(QIODevice::ReadOnly | QIODevice::Text)) {
            notes = QString::fromUtf8(f.readAll());
        }
    }

    void save_notes() const {
        QFile f(path + "/notes.txt");
        const QString trimmed = notes.trimmed();
        if (trimmed.isEmpty()) {
            f.remove(); // emptied on purpose — don't leave a stale note behind
            return;
        }
        if (f.open(QIODevice::WriteOnly | QIODevice::Text)) {
            f.write(trimmed.toUtf8());
        }
    }

    void load_annotations() {
        annotations.clear();
        QFile f(path + "/annotations.json");
        if (!f.open(QIODevice::ReadOnly)) {
            return;
        }
        const auto arr = QJsonDocument::fromJson(f.readAll()).array();
        for (const auto& v : arr) {
            annotations.append(Annotation::from_json(v.toObject()));
        }
    }

    void save_annotations() const {
        QJsonArray arr;
        for (const auto& a : annotations) {
            arr.append(a.to_json());
        }
        QFile f(path + "/annotations.json");
        if (f.open(QIODevice::WriteOnly | QIODevice::Text)) {
            f.write(QJsonDocument(arr).toJson(QJsonDocument::Indented));
        }
    }

    QString format_duration() const {
        if (durationMs < 0) {
            return "—";
        }
        const int secs  = durationMs / 1000;
        const int mins  = secs / 60;
        const int hours = mins / 60;
        if (hours > 0) {
            return QString("%1h %2m").arg(hours).arg(mins % 60);
        }
        return QString("%1m %2s").arg(mins).arg(secs % 60);
    }

    // Format milliseconds into HH:MM:SS string.
    static QString ms_to_hms(int64_t ms) {
        const int64_t secs = ms / 1000;
        const int64_t h    = secs / 3600;
        const int64_t m    = (secs % 3600) / 60;
        const int64_t s    = secs % 60;
        return QString("%1:%2:%3")
            .arg(h, 2, 10, QChar('0'))
            .arg(m, 2, 10, QChar('0'))
            .arg(s, 2, 10, QChar('0'));
    }
};

} // namespace mosaic