lingxia-logic 0.4.3

JavaScript runtime for LingXia lightweight application
use crate::i18n::{
    js_error_from_lxapp_error, js_error_from_platform_error, js_internal_error,
    js_invalid_parameter_error,
};
use lingxia_platform::traits::media_runtime::{
    CompressVideoRequest, CompressedVideo as PlatformCompressedVideo, ExtractVideoThumbnailRequest,
    MediaRuntime, VideoCompressQuality, VideoInfo as PlatformVideoInfo,
};
use lxapp::{LxApp, lx};
use rong::{FromJSObj, IntoJSObj, JSContext, JSFunc, JSResult};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};

static THUMBNAIL_NAME_COUNTER: AtomicU64 = AtomicU64::new(0);
static COMPRESS_VIDEO_NAME_COUNTER: AtomicU64 = AtomicU64::new(0);

#[derive(FromJSObj)]
struct JSGetVideoInfoOptions {
    path: String,
}

#[derive(Debug, Clone, IntoJSObj)]
struct JSVideoInfoResult {
    width: u32,
    height: u32,
    #[rename = "durationMs"]
    duration_ms: u64,
    rotation: Option<u16>,
    bitrate: Option<u64>,
    fps: Option<f64>,
    #[rename = "type"]
    video_type: Option<String>,
    path: String,
}

#[derive(FromJSObj)]
struct JSVideoThumbnailOptions {
    path: String,
    #[rename = "outputPath"]
    output_path: Option<String>,
    #[rename = "maxWidth"]
    max_width: Option<u32>,
    #[rename = "maxHeight"]
    max_height: Option<u32>,
    #[rename = "timeMs"]
    time_ms: Option<i64>,
    quality: Option<i32>,
}

#[derive(FromJSObj)]
struct JSCompressVideoOptions {
    path: String,
    #[rename = "outputPath"]
    output_path: Option<String>,
    quality: Option<String>,
    bitrate: Option<u32>,
    fps: Option<u32>,
    resolution: Option<f64>,
}

#[derive(Debug, Clone, IntoJSObj)]
struct JSVideoThumbnailResult {
    #[rename = "tempFilePath"]
    temp_file_path: String,
    width: u32,
    height: u32,
    #[rename = "type"]
    image_type: String,
}

#[derive(Debug, Clone, IntoJSObj)]
struct JSCompressVideoResult {
    #[rename = "tempFilePath"]
    temp_file_path: String,
    width: u32,
    height: u32,
    #[rename = "durationMs"]
    duration_ms: u64,
    size: u64,
    #[rename = "type"]
    video_type: String,
}

pub fn init(ctx: &JSContext) -> JSResult<()> {
    let get_video_info_func = JSFunc::new(ctx, get_video_info_api)?;
    lx::register_js_api(ctx, "getVideoInfo", get_video_info_func)?;

    let extract_video_thumbnail_func = JSFunc::new(ctx, extract_video_thumbnail_api)?;
    lx::register_js_api(ctx, "extractVideoThumbnail", extract_video_thumbnail_func)?;

    let compress_video_func = JSFunc::new(ctx, compress_video_api)?;
    lx::register_js_api(ctx, "compressVideo", compress_video_func)?;
    Ok(())
}

async fn get_video_info_api(
    ctx: JSContext,
    options: JSGetVideoInfoOptions,
) -> JSResult<JSVideoInfoResult> {
    let lxapp = LxApp::from_ctx(&ctx)?;
    let runtime = &lxapp.runtime;

    let original_path = options.path;
    let trimmed_path = original_path.trim();
    let resolved = lxapp
        .resolve_accessible_path(trimmed_path)
        .map_err(|err| js_error_from_lxapp_error(&err))?;
    let normalized_path = resolved.to_string_lossy().into_owned();

    let response_path = if trimmed_path.starts_with("lx://")
        || is_bundle_relative_path(trimmed_path)
    {
        trimmed_path.to_string()
    } else {
        lxapp
            .to_uri(&resolved)
            .ok_or_else(|| js_internal_error("getVideoInfo failed to convert path to lx:// uri"))?
            .into_string()
    };

    runtime
        .get_video_info(&normalized_path)
        .map(|info| platform_video_info_to_js(info, response_path))
        .map_err(|e| js_error_from_platform_error(&e))
}

async fn extract_video_thumbnail_api(
    ctx: JSContext,
    options: JSVideoThumbnailOptions,
) -> JSResult<JSVideoThumbnailResult> {
    let lxapp = LxApp::from_ctx(&ctx)?;
    let runtime = &lxapp.runtime;

    let resolved_source = lxapp
        .resolve_accessible_path(options.path.trim())
        .map_err(|err| js_error_from_lxapp_error(&err))?;
    let source_uri = resolved_source.to_string_lossy().into_owned();

    let output_path = resolve_thumbnail_output_path(&lxapp, options.output_path.as_deref())?;
    let request = ExtractVideoThumbnailRequest {
        source_uri,
        output_path,
        max_width: sanitize_optional_u32(options.max_width),
        max_height: sanitize_optional_u32(options.max_height),
        time_ms: sanitize_time_ms(options.time_ms),
        quality: clamp_quality(options.quality),
    };

    let thumbnail = runtime
        .extract_video_thumbnail(&request)
        .map_err(|e| js_error_from_platform_error(&e))?;

    let uri = lxapp
        .to_uri(&thumbnail.path)
        .ok_or_else(|| {
            js_internal_error("extractVideoThumbnail failed to convert output path to lx:// uri")
        })?
        .into_string();

    Ok(JSVideoThumbnailResult {
        temp_file_path: uri,
        width: thumbnail.width,
        height: thumbnail.height,
        image_type: thumbnail
            .mime_type
            .filter(|m| !m.is_empty())
            .unwrap_or_else(|| "image/jpeg".to_string()),
    })
}

async fn compress_video_api(
    ctx: JSContext,
    options: JSCompressVideoOptions,
) -> JSResult<JSCompressVideoResult> {
    let lxapp = LxApp::from_ctx(&ctx)?;
    let runtime = &lxapp.runtime;

    let resolved_source = lxapp
        .resolve_accessible_path(options.path.trim())
        .map_err(|err| js_error_from_lxapp_error(&err))?;
    let source_uri = resolved_source.to_string_lossy().into_owned();

    let output_path = resolve_compress_video_output_path(&lxapp, options.output_path.as_deref())?;
    let quality = parse_video_quality(options.quality.as_deref())?;
    let (bitrate_kbps, fps, resolution_ratio) = if quality.is_some() {
        (None, None, None)
    } else {
        (
            sanitize_optional_u32(options.bitrate),
            sanitize_optional_u32(options.fps),
            sanitize_resolution(options.resolution)?,
        )
    };

    let request = CompressVideoRequest {
        source_uri,
        quality,
        bitrate_kbps,
        fps,
        resolution_ratio,
        output_path,
    };

    let compressed = runtime
        .compress_video(&request)
        .map_err(|e| js_error_from_platform_error(&e))?;

    compressed_video_to_js(&lxapp, compressed)
}

fn resolve_thumbnail_output_path(
    lxapp: &LxApp,
    raw_output_path: Option<&str>,
) -> JSResult<PathBuf> {
    resolve_output_path(lxapp, raw_output_path, || {
        generate_thumbnail_output_path(&lxapp.user_cache_dir)
    })
}

fn resolve_compress_video_output_path(
    lxapp: &LxApp,
    raw_output_path: Option<&str>,
) -> JSResult<PathBuf> {
    resolve_output_path(lxapp, raw_output_path, || {
        generate_compress_video_output_path(&lxapp.user_cache_dir)
    })
}

fn resolve_output_path<F>(
    lxapp: &LxApp,
    raw_output_path: Option<&str>,
    default: F,
) -> JSResult<PathBuf>
where
    F: FnOnce() -> JSResult<PathBuf>,
{
    match raw_output_path.map(str::trim).filter(|s| !s.is_empty()) {
        Some(path) => lxapp
            .resolve_accessible_path(path)
            .map_err(|err| js_error_from_lxapp_error(&err)),
        None => default(),
    }
}

fn platform_video_info_to_js(info: PlatformVideoInfo, path: String) -> JSVideoInfoResult {
    JSVideoInfoResult {
        width: info.width,
        height: info.height,
        duration_ms: info.duration_ms,
        rotation: info.rotation,
        bitrate: info.bitrate,
        fps: info.fps.map(|v| v as f64),
        video_type: info.mime_type,
        path,
    }
}

fn compressed_video_to_js(
    lxapp: &LxApp,
    compressed: PlatformCompressedVideo,
) -> JSResult<JSCompressVideoResult> {
    let temp_file_path = lxapp
        .to_uri(&compressed.path)
        .ok_or_else(|| {
            js_internal_error("compressVideo failed to convert output path to lx:// uri")
        })?
        .into_string();

    Ok(JSCompressVideoResult {
        temp_file_path,
        width: compressed.width,
        height: compressed.height,
        duration_ms: compressed.duration_ms,
        size: compressed.size,
        video_type: "video/mp4".to_string(),
    })
}

fn parse_video_quality(value: Option<&str>) -> JSResult<Option<VideoCompressQuality>> {
    let Some(raw) = value.map(str::trim).filter(|v| !v.is_empty()) else {
        return Ok(None);
    };
    let quality = match raw.to_ascii_lowercase().as_str() {
        "low" => VideoCompressQuality::Low,
        "medium" => VideoCompressQuality::Medium,
        "high" => VideoCompressQuality::High,
        _ => {
            return Err(js_invalid_parameter_error(
                "compressVideo quality must be one of: low, medium, high",
            ));
        }
    };
    Ok(Some(quality))
}

fn sanitize_optional_u32(value: Option<u32>) -> Option<u32> {
    value.filter(|v| *v > 0)
}

fn sanitize_resolution(value: Option<f64>) -> JSResult<Option<f32>> {
    let Some(v) = value else {
        return Ok(None);
    };
    if !v.is_finite() || v <= 0.0 || v > 1.0 {
        return Err(js_invalid_parameter_error(
            "compressVideo resolution must be in range (0, 1]",
        ));
    }
    Ok(Some(v as f32))
}

fn sanitize_time_ms(value: Option<i64>) -> Option<u64> {
    match value {
        Some(v) if v >= 0 => Some(v as u64),
        _ => None,
    }
}

fn clamp_quality(value: Option<i32>) -> u8 {
    let raw = value.unwrap_or(80);
    raw.clamp(0, 100) as u8
}

fn is_bundle_relative_path(value: &str) -> bool {
    !Path::new(value).is_absolute() && !value.contains(':')
}

fn ensure_dir(path: &Path) -> JSResult<()> {
    if let Err(err) = fs::create_dir_all(path) {
        return Err(js_internal_error(format!(
            "Failed to prepare directory {}: {}",
            path.display(),
            err
        )));
    }
    Ok(())
}

fn generate_thumbnail_output_path(cache_root: &Path) -> JSResult<PathBuf> {
    generate_timestamped_output_path(
        &cache_root.join("video-thumbnail"),
        "vx",
        "jpg",
        &THUMBNAIL_NAME_COUNTER,
    )
}

fn generate_compress_video_output_path(cache_root: &Path) -> JSResult<PathBuf> {
    generate_timestamped_output_path(
        &cache_root.join("video-compress"),
        "vx_comp",
        "mp4",
        &COMPRESS_VIDEO_NAME_COUNTER,
    )
}

fn generate_timestamped_output_path(
    base_dir: &Path,
    prefix: &str,
    ext: &str,
    counter: &AtomicU64,
) -> JSResult<PathBuf> {
    ensure_dir(base_dir)?;
    let timestamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_millis();
    let nonce = counter.fetch_add(1, Ordering::Relaxed);
    let filename = format!("{}_{}_{}.{}", prefix, timestamp, nonce, ext);

    Ok(base_dir.join(filename))
}