hisui 2025.2.0

Recording Composition Tool Hisui
use std::{
    path::{Path, PathBuf},
    sync::Arc,
    time::Duration,
};

use orfail::OrFail;

/// Sora の report-*.json から必要な情報のみを取り出した構造体
#[derive(Debug, Clone)]
pub struct RecordingMetadata {
    pub split_only: bool,
    pub archives: Vec<ArchiveEntry>,
}

impl<'text, 'raw> TryFrom<nojson::RawJsonValue<'text, 'raw>> for RecordingMetadata {
    type Error = nojson::JsonParseError;

    fn try_from(value: nojson::RawJsonValue<'text, 'raw>) -> Result<Self, Self::Error> {
        let archives = value.to_member("archives")?.required()?;
        let split_only = value.to_member("split_only")?;

        Ok(Self {
            split_only: Option::unwrap_or_default(split_only.try_into()?),
            archives: archives.try_into()?,
        })
    }
}

impl RecordingMetadata {
    pub fn from_file<P: AsRef<Path>>(path: P) -> orfail::Result<Self> {
        crate::json::parse_file(path).or_fail()
    }

    pub fn archive_metadata_paths(&self) -> orfail::Result<Vec<PathBuf>> {
        if self.split_only {
            // split_only の場合には JSON 内に具体的なファイルパスは含まれていないので
            // 命名規則に従って生成する
            let mut paths = Vec::new();
            for archive in &self.archives {
                let last_index_str = archive.split_last_index.as_ref().or_fail()?;
                let last_index = last_index_str.parse::<usize>().or_fail()?;
                for i in 1..=last_index {
                    let path = format!("split-archive-{}_{:04}.json", archive.connection_id, i);
                    paths.push(PathBuf::from(path));
                }
            }
            Ok(paths)
        } else {
            Ok(self
                .archives
                .iter()
                .filter_map(|a| a.metadata_filename.clone())
                .collect())
        }
    }
}

/// Sora の report-*.json の archives 配列の要素に対応する構造体(必要な情報のみ)
#[derive(Debug, Clone)]
pub struct ArchiveEntry {
    pub connection_id: String,
    pub split_last_index: Option<String>,
    pub metadata_filename: Option<PathBuf>,
}

impl<'text, 'raw> TryFrom<nojson::RawJsonValue<'text, 'raw>> for ArchiveEntry {
    type Error = nojson::JsonParseError;

    fn try_from(value: nojson::RawJsonValue<'text, 'raw>) -> Result<Self, Self::Error> {
        let connection_id = value.to_member("connection_id")?.required()?;
        let split_last_index = value.to_member("split_last_index")?;
        let metadata_filename = value.to_member("metadata_filename")?;

        Ok(Self {
            connection_id: connection_id.try_into()?,
            split_last_index: Option::unwrap_or_default(split_last_index.try_into()?),
            metadata_filename: Option::unwrap_or_default(metadata_filename.try_into()?),
        })
    }
}

/// Sora の archive-*.json から必要な情報のみを取り出した構造体
#[derive(Debug, Clone)]
pub struct ArchiveMetadata {
    pub connection_id: String,
    pub format: ContainerFormat,
    pub audio: bool,
    pub video: bool,
    pub start_time_offset: u64,
    pub stop_time_offset: u64,
}

impl<'text, 'raw> TryFrom<nojson::RawJsonValue<'text, 'raw>> for ArchiveMetadata {
    type Error = nojson::JsonParseError;

    fn try_from(value: nojson::RawJsonValue<'text, 'raw>) -> Result<Self, Self::Error> {
        let connection_id = value.to_member("connection_id")?.required()?;
        let format = value
            .to_member("format")?
            .map(ContainerFormat::try_from)?
            .unwrap_or(ContainerFormat::Webm); // MP4 録画に対応する前は format 項目自体がなかった
        let audio = value.to_member("audio")?.required()?;
        let video = value.to_member("video")?.required()?;
        let start_time_offset = value.to_member("start_time_offset")?.required()?;
        let stop_time_offset = value.to_member("stop_time_offset")?.required()?;

        Ok(Self {
            connection_id: connection_id.try_into()?,
            format,
            start_time_offset: start_time_offset.try_into()?,
            stop_time_offset: stop_time_offset.try_into()?,

            // 以下のフィールドは、 Sora のバージョンによって、
            // 値がブールだったりオブジェクトだったりするので、ゆるい判定にしておく
            audio: audio.as_raw_str() != "false",
            video: video.as_raw_str() != "false",
        })
    }
}

impl ArchiveMetadata {
    pub fn from_file<P: AsRef<Path>>(path: P) -> orfail::Result<Self> {
        crate::json::parse_file(path).or_fail()
    }

    pub fn source_id(&self) -> SourceId {
        SourceId(Arc::new(self.connection_id.clone()))
    }

    pub fn source_info(&self) -> SourceInfo {
        SourceInfo {
            id: self.source_id(),
            format: self.format,
            start_timestamp: Duration::from_secs(self.start_time_offset),
            stop_timestamp: Duration::from_secs(self.stop_time_offset),
            audio: self.audio,
            video: self.video,
        }
    }
}

#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct SourceId(Arc<String>);

impl nojson::DisplayJson for SourceId {
    fn fmt(&self, f: &mut nojson::JsonFormatter<'_, '_>) -> std::fmt::Result {
        f.value(&*self.0)
    }
}

impl SourceId {
    pub fn new(id: &str) -> Self {
        Self(Arc::new(id.to_owned()))
    }

    pub fn get(&self) -> &str {
        &self.0
    }
}

impl From<SourceId> for String {
    fn from(value: SourceId) -> Self {
        (*value.0).clone()
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceInfo {
    pub id: SourceId,
    pub format: ContainerFormat,
    pub audio: bool,
    pub video: bool,
    pub start_timestamp: Duration,
    pub stop_timestamp: Duration,
}

#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum ContainerFormat {
    #[default]
    Webm,
    Mp4,
}

impl ContainerFormat {
    pub fn from_path<P: AsRef<Path>>(path: P) -> orfail::Result<Self> {
        let ext = path
            .as_ref()
            .extension()
            .or_fail_with(|()| format!("no media file extension: {}", path.as_ref().display()))?;
        if ext == "mp4" {
            Ok(Self::Mp4)
        } else if ext == "webm" {
            Ok(Self::Webm)
        } else {
            Err(orfail::Failure::new(format!(
                "unexpected media file extension: {}",
                path.as_ref().display()
            )))
        }
    }
}

impl nojson::DisplayJson for ContainerFormat {
    fn fmt(&self, f: &mut nojson::JsonFormatter<'_, '_>) -> std::fmt::Result {
        match self {
            ContainerFormat::Webm => f.string("webm"),
            ContainerFormat::Mp4 => f.string("mp4"),
        }
    }
}

impl<'text, 'raw> TryFrom<nojson::RawJsonValue<'text, 'raw>> for ContainerFormat {
    type Error = nojson::JsonParseError;

    fn try_from(value: nojson::RawJsonValue<'text, 'raw>) -> Result<Self, Self::Error> {
        match value.to_unquoted_string_str()?.as_ref() {
            "webm" => Ok(Self::Webm),
            "mp4" => Ok(Self::Mp4),
            v => Err(value.invalid(format!("unknown container format: {v}"))),
        }
    }
}