use std::{env, fs, sync::mpsc};
use anyhow::Context;
use lumen_engine::{composition::Composition, gpu::GpuCompositionRenderer, media::MediaStore};
use lumen_ffmpeg::VideoCodec;
use super::{
ProjectBundle, RenderError, RenderOptions, RenderProgress,
encoder::{
ENCODER_FRAME_QUEUE_CAPACITY, EncoderFrame, LumenFfmpegEncoder, has_audio,
write_composited_audio,
},
media::{LocalMediaStore, media_root},
};
#[cfg(all(target_os = "linux", feature = "cuda", feature = "vulkan"))]
mod linux_cuda;
#[cfg(not(all(target_os = "linux", feature = "cuda", feature = "vulkan")))]
mod unsupported_cuda;
#[cfg(all(target_os = "linux", feature = "cuda", feature = "vulkan"))]
use linux_cuda::render_project_mp4_cuda;
#[cfg(not(all(target_os = "linux", feature = "cuda", feature = "vulkan")))]
use unsupported_cuda::render_project_mp4_cuda;
pub(super) const MEDIA_PREFETCH_LOOKAHEAD_FRAMES: u32 = 30;
pub fn render_project_mp4(
bundle: &ProjectBundle,
options: &RenderOptions,
on_progress: &mut dyn FnMut(RenderProgress),
) -> Result<Vec<u8>, RenderError> {
let composition = &bundle.composition;
let width = composition.render_settings.width;
let height = composition.render_settings.height;
let fps = composition.timeline.fps;
let total_frames = composition.timeline.duration_frames;
if fps <= 0.0 {
return Err(RenderError {
code: "invalid_project_payload",
message: format!("invalid timeline fps: {fps}"),
retryable: false,
});
}
if total_frames == 0 {
return Err(RenderError {
code: "invalid_project_payload",
message: "composition duration_frames must be greater than zero".to_string(),
retryable: false,
});
}
let media_root = media_root(options.media_root.as_deref()).map_err(|err| RenderError {
code: "media_root_error",
message: err.to_string(),
retryable: false,
})?;
let media_store = LocalMediaStore::new(media_root);
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 encoder =
choose_video_encoder(options.video_encoder.as_deref()).map_err(|err| RenderError {
code: "invalid_render_profile",
message: err.to_string(),
retryable: false,
})?;
tracing::info!(
encoder = encoder.name,
codec = ?encoder.codec,
cuda_fast_path = cfg!(all(target_os = "linux", feature = "cuda", feature = "vulkan"))
&& encoder.cuda_fast_path,
"selected video encoder"
);
if encoder.cuda_fast_path {
return render_project_mp4_cuda(
composition,
&media_store,
width,
height,
fps,
total_frames,
encoder.codec,
options.verbose_debug,
on_progress,
);
}
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 include_audio = has_audio(composition);
let encoder = LumenFfmpegEncoder::create(
&output_path,
width,
height,
fps,
&encoder.name,
encoder.codec,
include_audio,
)
.map_err(|err| RenderError {
code: "encode_failed",
message: err.to_string(),
retryable: true,
})?;
let (pixel_recycle_tx, _pixel_recycle_rx) =
mpsc::sync_channel::<Vec<u8>>(ENCODER_FRAME_QUEUE_CAPACITY + 2);
for frame in 0..total_frames {
prefetch_media_frames(composition, &media_store, frame);
let (raster, _submission) = renderer
.render_frame_submitted(composition, frame, &media_store)
.map_err(|err| RenderError {
code: "render_failed",
message: format!("render failed at frame {frame}: {err}"),
retryable: true,
})?;
let _ = renderer.precompile_frame_window(
composition,
frame.saturating_add(1),
MEDIA_PREFETCH_LOOKAHEAD_FRAMES,
&media_store,
);
renderer
.gpu_renderer()
.device
.poll(lumen_gpu::wgpu::PollType::Poll)
.map_err(|err| RenderError {
code: "render_failed",
message: format!("GPU poll failed at frame {frame}: {err}"),
retryable: true,
})?;
let size = raster.domain.storage_size;
if size.width != width || size.height != height {
return Err(RenderError {
code: "render_failed",
message: format!(
"frame {frame} dimensions {}x{} do not match composition {}x{}",
size.width, size.height, width, height
),
retryable: true,
});
}
let pixels =
read_texture_rgba8(renderer.gpu_renderer(), raster.texture, size).map_err(|err| {
RenderError {
code: "render_failed",
message: format!("failed reading rendered pixels for frame {frame}: {err}"),
retryable: true,
}
})?;
encoder
.send(EncoderFrame {
frame,
pixels,
recycle_tx: pixel_recycle_tx.clone(),
})
.map_err(|err| RenderError {
code: "encode_failed",
message: err.to_string(),
retryable: true,
})?;
let completed = frame.saturating_add(1);
let ratio = (completed as f32 / total_frames as f32).clamp(0.0, 1.0);
on_progress(RenderProgress {
stage: "rendering",
frame: completed,
total_frames,
ratio,
});
}
if include_audio {
write_composited_audio(composition, &media_store, &encoder).map_err(|err| RenderError {
code: "audio_render_failed",
message: err.to_string(),
retryable: true,
})?;
}
encoder.finish().map_err(|err| RenderError {
code: "encode_failed",
message: err.to_string(),
retryable: true,
})?;
fs::read(&output_path).map_err(|err| RenderError {
code: "encode_failed",
message: format!("failed to read encoded output: {err}"),
retryable: true,
})
}
pub(super) fn prefetch_media_frames(
composition: &Composition,
media_store: &LocalMediaStore,
frame: u32,
) {
let Ok(requirements) =
lumen_engine::media::collect_frame_requirements(composition, media_store, frame)
else {
return;
};
for video in requirements.videos {
let Some(resolver) = media_store.get_video_resolver(&video.stream_id) else {
continue;
};
let mut frames = video.frames;
for required_frame in frames.clone() {
for offset in 1..=MEDIA_PREFETCH_LOOKAHEAD_FRAMES {
frames.push(required_frame.saturating_add(offset));
}
}
frames.sort_unstable();
frames.dedup();
for frame in &frames {
let _ = resolver.enqueue_frame(*frame);
}
resolver.retain_frames(&frames);
}
}
fn read_texture_rgba8(
renderer: &lumen_gpu::Renderer,
id: lumen_gpu::TextureId,
size: lumen_gpu::Size,
) -> anyhow::Result<Vec<u8>> {
let bytes_per_pixel = 4;
let unpadded_bytes_per_row = size.width.saturating_mul(bytes_per_pixel);
let padded_bytes_per_row = align_to(
unpadded_bytes_per_row,
lumen_gpu::wgpu::COPY_BYTES_PER_ROW_ALIGNMENT,
);
let output_size = u64::from(padded_bytes_per_row).saturating_mul(u64::from(size.height));
let output = renderer
.device
.create_buffer(&lumen_gpu::wgpu::BufferDescriptor {
label: Some("lumen-server readback"),
size: output_size.max(1),
usage: lumen_gpu::wgpu::BufferUsages::COPY_DST
| lumen_gpu::wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder =
renderer
.device
.create_command_encoder(&lumen_gpu::wgpu::CommandEncoderDescriptor {
label: Some("lumen-server readback encoder"),
});
let texture = renderer
.texture(id)
.ok_or_else(|| anyhow::anyhow!("render output texture {id:?} is unavailable"))?;
encoder.copy_texture_to_buffer(
texture.as_image_copy(),
lumen_gpu::wgpu::TexelCopyBufferInfo {
buffer: &output,
layout: lumen_gpu::wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded_bytes_per_row),
rows_per_image: Some(size.height),
},
},
lumen_gpu::wgpu::Extent3d {
width: size.width,
height: size.height,
depth_or_array_layers: 1,
},
);
renderer.queue.submit([encoder.finish()]);
let slice = output.slice(..);
let (tx, rx) = mpsc::channel();
slice.map_async(lumen_gpu::wgpu::MapMode::Read, move |result| {
let _ = tx.send(result);
});
renderer
.device
.poll(lumen_gpu::wgpu::PollType::wait_indefinitely())
.map_err(|error| anyhow::anyhow!("GPU readback poll failed: {error}"))?;
rx.recv()
.map_err(|_| anyhow::anyhow!("GPU readback channel closed"))?
.map_err(|error| anyhow::anyhow!("GPU readback map failed: {error}"))?;
let mapped = slice.get_mapped_range();
let mut pixels = vec![
0;
(size.width as usize)
.saturating_mul(size.height as usize)
.saturating_mul(bytes_per_pixel as usize)
];
for row in 0..size.height as usize {
let src_start = row.saturating_mul(padded_bytes_per_row as usize);
let src_end = src_start.saturating_add(unpadded_bytes_per_row as usize);
let dst_start = row.saturating_mul(unpadded_bytes_per_row as usize);
let dst_end = dst_start.saturating_add(unpadded_bytes_per_row as usize);
pixels[dst_start..dst_end].copy_from_slice(&mapped[src_start..src_end]);
}
drop(mapped);
output.unmap();
Ok(pixels)
}
fn align_to(value: u32, alignment: u32) -> u32 {
value.div_ceil(alignment) * alignment
}
pub(super) fn create_gpu_renderer() -> anyhow::Result<GpuCompositionRenderer> {
let handle = tokio::runtime::Handle::try_current()
.context("GPU renderer creation requires a Tokio runtime")?;
handle
.block_on(GpuCompositionRenderer::new())
.context("failed to initialize wgpu renderer")
}
#[derive(Debug)]
struct VideoEncoderSelection {
name: String,
codec: VideoCodec,
cuda_fast_path: bool,
}
fn choose_video_encoder(override_encoder: Option<&str>) -> anyhow::Result<VideoEncoderSelection> {
if let Some(encoder) = override_encoder {
let encoder = encoder.trim();
if !encoder.is_empty() {
return video_encoder_selection(encoder);
}
}
if let Ok(encoder) = env::var("LUMEN_VIDEO_ENCODER") {
let encoder = encoder.trim();
if !encoder.is_empty() {
return video_encoder_selection(encoder);
}
}
let name = if cfg!(target_os = "macos") {
"h264_videotoolbox"
} else {
"libx264"
};
video_encoder_selection(name)
}
fn video_encoder_selection(name: &str) -> anyhow::Result<VideoEncoderSelection> {
let codec = match name {
"h264" | "libx264" | "h264_videotoolbox" | "h264_vulkan" | "h264_nvenc" => VideoCodec::H264,
"hevc" | "h265" | "libx265" | "hevc_videotoolbox" | "hevc_vulkan" | "hevc_nvenc" => {
VideoCodec::Hevc
}
"av1" | "libaom-av1" | "av1_nvenc" => VideoCodec::Av1,
_ => anyhow::bail!(
"unsupported video encoder `{name}`; expected a known H.264, HEVC, or AV1 encoder"
),
};
Ok(VideoEncoderSelection {
name: name.to_string(),
codec,
cuda_fast_path: matches!(name, "h264_nvenc" | "hevc_nvenc" | "av1_nvenc"),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nvenc_encoders_select_cuda_capable_codecs() {
let h264 = video_encoder_selection("h264_nvenc").unwrap();
assert_eq!(h264.name, "h264_nvenc");
assert_eq!(h264.codec, VideoCodec::H264);
let hevc = video_encoder_selection("hevc_nvenc").unwrap();
assert_eq!(hevc.name, "hevc_nvenc");
assert_eq!(hevc.codec, VideoCodec::Hevc);
let av1 = video_encoder_selection("av1_nvenc").unwrap();
assert_eq!(av1.name, "av1_nvenc");
assert_eq!(av1.codec, VideoCodec::Av1);
}
#[test]
fn unknown_encoder_is_rejected_before_rendering() {
let error = video_encoder_selection("definitely_not_an_encoder").unwrap_err();
assert!(
error
.to_string()
.contains("expected a known H.264, HEVC, or AV1 encoder")
);
}
}