lumen-server 0.2.2

Embeddable render service primitives and local HTTP server for Lumen.
Documentation
use std::{fs, time::Instant};

use lumen_engine::composition::Composition;
use lumen_engine::gpu::CudaNvencTargetPool;
use lumen_ffmpeg::{
    AudioEncoderConfig, EncodeMode, GpuBackend, MuxedEncoder, VideoCodec, VideoEncoderConfig,
};

use crate::render::{
    RenderError, RenderProgress,
    encoder::has_audio,
    frame_timing::{FRAME_TIMING_LOG_INTERVAL, FrameTiming, FrameTimingTotals},
    media::LocalMediaStore,
};

use super::{MEDIA_PREFETCH_LOOKAHEAD_FRAMES, create_gpu_renderer, prefetch_media_frames};

#[allow(clippy::too_many_arguments)]
pub(super) fn render_project_mp4_cuda(
    composition: &Composition,
    media_store: &LocalMediaStore,
    width: u32,
    height: u32,
    fps: f32,
    total_frames: u32,
    encoder_name: &str,
    codec: VideoCodec,
    verbose_debug: bool,
    on_progress: &mut dyn FnMut(RenderProgress),
) -> Result<Vec<u8>, RenderError> {
    let tmp = tempfile::tempdir().map_err(|err| RenderError {
        code: "encode_failed",
        message: format!("failed to create temp dir: {err}"),
        retryable: true,
    })?;
    let output_path = tmp.path().join("output.mp4");
    let mut renderer = create_gpu_renderer().map_err(|err| RenderError {
        code: "render_failed",
        message: format!("failed to create GPU renderer: {err:#}"),
        retryable: true,
    })?;
    renderer
        .compile_with_media(
            composition,
            media_store,
            lumen_gpu::wgpu::TextureFormat::Rgba8Unorm,
        )
        .map_err(|err| RenderError {
            code: "render_failed",
            message: format!("failed to compile composition: {err}"),
            retryable: false,
        })?;

    let size = lumen_gpu::Size::new(width, height);
    let target_pool =
        CudaNvencTargetPool::rgba8(renderer.gpu_renderer(), size).map_err(|err| RenderError {
            code: "render_failed",
            message: format!("failed to create CUDA/NVENC target pool: {err}"),
            retryable: false,
        })?;
    let target = target_pool
        .acquire(renderer.gpu_renderer())
        .map_err(|err| RenderError {
            code: "render_failed",
            message: format!("failed to create CUDA/NVENC target: {err}"),
            retryable: false,
        })?;
    let vulkan_device = target_pool.vulkan_device();
    if verbose_debug {
        tracing::debug!(
            width,
            height,
            allocation_size = target.allocation_size(),
            row_pitch = target.row_pitch(),
            memory_fd = target.memory_fd_raw(),
            memory_type_index = target.memory_type_index(),
            cuda_ordinal = target_pool.cuda_ordinal(),
            vulkan_device_name = %vulkan_device.name,
            vulkan_vendor_id = vulkan_device.vendor_id,
            vulkan_device_id = vulkan_device.device_id,
            vulkan_device_type = %vulkan_device.device_type,
            vulkan_device_uuid = %format_uuid(&vulkan_device.device_uuid),
            vulkan_driver_uuid = %format_uuid(&vulkan_device.driver_uuid),
            "created CUDA/NVENC render target"
        );
    }
    match target_pool.driver().driver_version() {
        Ok(version) if verbose_debug => {
            tracing::debug!(cuda_driver_version = version, "loaded CUDA driver");
        }
        Ok(_) => {}
        Err(err) => tracing::warn!(error = %err, "failed to query CUDA driver version"),
    }
    tracing::info!(
        cuda_ordinal = target_pool.cuda_ordinal(),
        vulkan_device_uuid = %format_uuid(&vulkan_device.device_uuid),
        "created CUDA primary context for Vulkan device"
    );

    let include_audio = has_audio(composition);
    let mut config =
        VideoEncoderConfig::cpu_rgba(width, height, fps.round().max(1.0) as u32, codec);
    config.encoder_name = Some(encoder_name.to_string());
    config.mode = EncodeMode::GpuTexture(GpuBackend::Cuda);
    config.bit_rate = 14_000_000;
    let audio = include_audio.then(|| {
        AudioEncoderConfig::aac(
            lumen_engine::audio::AUDIO_SAMPLE_RATE,
            lumen_engine::audio::AUDIO_CHANNELS as u16,
        )
    });
    let mut encoder =
        MuxedEncoder::create_with_audio(output_path.to_string_lossy().to_string(), config, audio)
            .map_err(|err| RenderError {
            code: "encode_failed",
            message: err.to_string(),
            retryable: true,
        })?;

    tracing::info!(
        width,
        height,
        total_frames,
        "using Vulkan-to-CUDA NVENC render path"
    );
    let mut timing_totals = FrameTimingTotals::default();
    for frame in 0..total_frames {
        let mut timing = FrameTiming::default();
        let started = Instant::now();
        prefetch_media_frames(composition, media_store, frame);
        timing.prefetch = started.elapsed();

        let started = Instant::now();
        let submitted = renderer
            .render_frame_into_external(composition, frame, media_store, target.external_texture())
            .map_err(|err| RenderError {
                code: "render_failed",
                message: format!("render failed at frame {frame}: {err}"),
                retryable: true,
            })?;
        timing.render_submit = started.elapsed();

        let started = Instant::now();
        let _ = renderer.precompile_frame_window(
            composition,
            frame.saturating_add(1),
            MEDIA_PREFETCH_LOOKAHEAD_FRAMES,
            media_store,
        );
        timing.precompile = started.elapsed();

        let started = Instant::now();
        submitted
            .wait(&renderer.gpu_renderer().device)
            .map_err(|err| RenderError {
                code: "render_failed",
                message: format!("GPU poll failed at frame {frame}: {err}"),
                retryable: true,
            })?;
        timing.gpu_wait = started.elapsed();

        let started = Instant::now();
        target
            .copy_rendered_frame_to_cuda()
            .map_err(|err| RenderError {
                code: "encode_failed",
                message: format!("failed to copy Vulkan frame into CUDA frame: {err}"),
                retryable: true,
            })?;
        timing.cuda_copy = started.elapsed();

        let started = Instant::now();
        let frame_ref = target.video_frame(Some(i64::from(frame)));
        encoder
            .write_gpu_frame(&target.video_input(&frame_ref))
            .map_err(|err| RenderError {
                code: "encode_failed",
                message: err.to_string(),
                retryable: true,
            })?;
        timing.encode_write = started.elapsed();

        let completed = frame.saturating_add(1);
        let ratio = (completed as f32 / total_frames as f32).clamp(0.0, 1.0);
        let started = Instant::now();
        on_progress(RenderProgress {
            stage: "rendering",
            frame: completed,
            total_frames,
            ratio,
        });
        timing.progress = started.elapsed();
        timing_totals.add(timing);
        if verbose_debug
            && (completed % FRAME_TIMING_LOG_INTERVAL == 0 || completed == total_frames)
        {
            timing_totals.log(completed, total_frames, "cuda_nvenc");
        }
    }

    let finish_started = Instant::now();
    if include_audio {
        let audio_started = Instant::now();
        crate::render::encoder::write_composited_audio_with(composition, media_store, |frame| {
            encoder
                .write_audio_frame(&frame)
                .map_err(|err| anyhow::anyhow!(err.to_string()))
        })
        .map_err(|err| RenderError {
            code: "audio_render_failed",
            message: err.to_string(),
            retryable: true,
        })?;
        if verbose_debug {
            tracing::debug!(
                audio_ms = audio_started.elapsed().as_millis(),
                "finished composited audio write"
            );
        }
    }

    let encoder_finish_started = Instant::now();
    encoder.finish().map_err(|err| RenderError {
        code: "encode_failed",
        message: err.to_string(),
        retryable: true,
    })?;
    let encoder_finish_ms = encoder_finish_started.elapsed().as_millis();

    let read_started = Instant::now();
    fs::read(&output_path)
        .map_err(|err| RenderError {
            code: "encode_failed",
            message: format!("failed to read encoded output: {err}"),
            retryable: true,
        })
        .inspect(|bytes| {
            if verbose_debug {
                tracing::debug!(
                    encoder_finish_ms,
                    read_output_ms = read_started.elapsed().as_millis(),
                    finalization_ms = finish_started.elapsed().as_millis(),
                    output_bytes = bytes.len(),
                    "finished CUDA/NVENC render finalization"
                );
            }
        })
}

fn format_uuid(uuid: &[u8; 16]) -> String {
    uuid.iter()
        .map(|byte| format!("{byte:02x}"))
        .collect::<Vec<_>>()
        .join("")
}