use log::info;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::Sender;
use crate::cache::Cache;
use crate::convert::SwsContext;
use crate::frame::{CropAlign, FrameConversion, FrameStatus};
use playa_ffmpeg as ffmpeg;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct EncoderSettings {
pub output_path: PathBuf,
pub container: Container,
pub codec: VideoCodec,
pub encoder_impl: EncoderImpl,
pub quality_mode: QualityMode,
pub quality_value: u32, pub fps: f32,
#[serde(default)]
pub preset: Option<String>, #[serde(default)]
pub profile: Option<String>, #[serde(default)]
pub prores_profile: Option<ProResProfile>, }
impl Default for EncoderSettings {
fn default() -> Self {
Self {
output_path: PathBuf::from("output.mp4"),
container: Container::MP4,
codec: VideoCodec::H264,
encoder_impl: EncoderImpl::Auto,
quality_mode: QualityMode::CRF,
quality_value: 23, fps: 24.0, preset: Some("medium".to_string()),
profile: Some("high".to_string()),
prores_profile: Some(ProResProfile::Standard),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct H264Settings {
pub encoder_impl: EncoderImpl,
pub quality_mode: QualityMode,
pub quality_value: u32, pub preset: String, pub profile: String, }
impl Default for H264Settings {
fn default() -> Self {
Self {
encoder_impl: EncoderImpl::Auto,
quality_mode: QualityMode::CRF,
quality_value: 23,
preset: "medium".to_string(),
profile: "high".to_string(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct H265Settings {
pub encoder_impl: EncoderImpl,
pub quality_mode: QualityMode,
pub quality_value: u32, pub preset: String, }
impl Default for H265Settings {
fn default() -> Self {
Self {
encoder_impl: EncoderImpl::Auto,
quality_mode: QualityMode::CRF,
quality_value: 28, preset: "medium".to_string(),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[allow(clippy::upper_case_acronyms)]
pub enum ProResProfile {
Proxy, LT, Standard, HQ, FourFourFourFour, FourFourFourFourXQ, }
impl ProResProfile {
pub fn all() -> &'static [ProResProfile] {
&[
ProResProfile::Proxy,
ProResProfile::LT,
ProResProfile::Standard,
ProResProfile::HQ,
ProResProfile::FourFourFourFour,
ProResProfile::FourFourFourFourXQ,
]
}
pub fn to_ffmpeg_value(self) -> &'static str {
match self {
ProResProfile::Proxy => "0",
ProResProfile::LT => "1",
ProResProfile::Standard => "2",
ProResProfile::HQ => "3",
ProResProfile::FourFourFourFour => "4",
ProResProfile::FourFourFourFourXQ => "5",
}
}
}
impl std::fmt::Display for ProResProfile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProResProfile::Proxy => write!(f, "Proxy"),
ProResProfile::LT => write!(f, "LT"),
ProResProfile::Standard => write!(f, "422 (Standard)"),
ProResProfile::HQ => write!(f, "422 HQ"),
ProResProfile::FourFourFourFour => write!(f, "4444"),
ProResProfile::FourFourFourFourXQ => write!(f, "4444 XQ"),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ProResSettings {
pub profile: ProResProfile,
}
impl Default for ProResSettings {
fn default() -> Self {
Self {
profile: ProResProfile::Standard,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AV1Settings {
pub encoder_impl: EncoderImpl,
pub quality_mode: QualityMode,
pub quality_value: u32, pub preset: String, }
impl Default for AV1Settings {
fn default() -> Self {
Self {
encoder_impl: EncoderImpl::Auto,
quality_mode: QualityMode::CRF,
quality_value: 30, preset: "p4".to_string(), }
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct CodecSettings {
pub h264: H264Settings,
pub h265: H265Settings,
pub prores: ProResSettings,
pub av1: AV1Settings,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[allow(clippy::upper_case_acronyms)]
pub enum Container {
MP4,
MOV,
}
impl Container {
pub fn extension(&self) -> &'static str {
match self {
Container::MP4 => "mp4",
Container::MOV => "mov",
}
}
}
impl std::fmt::Display for Container {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Container::MP4 => write!(f, "MP4"),
Container::MOV => write!(f, "MOV"),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[allow(clippy::upper_case_acronyms)]
pub enum VideoCodec {
H264,
H265,
ProRes,
AV1,
}
impl VideoCodec {
pub fn all() -> &'static [VideoCodec] {
&[
VideoCodec::H264,
VideoCodec::H265,
VideoCodec::AV1,
VideoCodec::ProRes,
]
}
pub fn preferred_container(&self) -> Container {
match self {
VideoCodec::H264 => Container::MP4,
VideoCodec::H265 => Container::MP4,
VideoCodec::AV1 => Container::MP4,
VideoCodec::ProRes => Container::MOV, }
}
pub fn is_available(&self) -> bool {
match self {
VideoCodec::H264 => {
#[cfg(target_os = "macos")]
if ffmpeg::encoder::find_by_name("h264_videotoolbox").is_some() {
return true;
}
ffmpeg::encoder::find_by_name("h264_nvenc").is_some()
|| ffmpeg::encoder::find_by_name("h264_qsv").is_some()
|| ffmpeg::encoder::find_by_name("h264_amf").is_some()
|| ffmpeg::encoder::find_by_name("libx264").is_some()
}
VideoCodec::H265 => {
#[cfg(target_os = "macos")]
if ffmpeg::encoder::find_by_name("hevc_videotoolbox").is_some() {
return true;
}
ffmpeg::encoder::find_by_name("hevc_nvenc").is_some()
|| ffmpeg::encoder::find_by_name("hevc_qsv").is_some()
|| ffmpeg::encoder::find_by_name("hevc_amf").is_some()
|| ffmpeg::encoder::find_by_name("libx265").is_some()
}
VideoCodec::AV1 => {
ffmpeg::encoder::find_by_name("av1_nvenc").is_some()
|| ffmpeg::encoder::find_by_name("av1_qsv").is_some()
|| ffmpeg::encoder::find_by_name("av1_amf").is_some()
|| ffmpeg::encoder::find_by_name("libsvtav1").is_some()
|| ffmpeg::encoder::find_by_name("libaom-av1").is_some()
}
VideoCodec::ProRes => ffmpeg::encoder::find_by_name("prores_ks").is_some(),
}
}
}
impl std::fmt::Display for VideoCodec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VideoCodec::H264 => write!(f, "H.264"),
VideoCodec::H265 => write!(f, "H.265 (HEVC)"),
VideoCodec::AV1 => write!(f, "AV1"),
VideoCodec::ProRes => write!(f, "ProRes"),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum EncoderImpl {
Auto, Hardware, Software, }
impl EncoderImpl {
pub fn all() -> &'static [EncoderImpl] {
&[
EncoderImpl::Auto,
EncoderImpl::Hardware,
EncoderImpl::Software,
]
}
}
impl std::fmt::Display for EncoderImpl {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EncoderImpl::Auto => write!(f, "Auto (HW → CPU)"),
EncoderImpl::Hardware => write!(f, "Hardware only"),
EncoderImpl::Software => write!(f, "Software (CPU)"),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[allow(clippy::upper_case_acronyms)]
pub enum QualityMode {
CRF, Bitrate, }
impl QualityMode {
pub fn all() -> &'static [QualityMode] {
&[QualityMode::CRF, QualityMode::Bitrate]
}
}
impl std::fmt::Display for QualityMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
QualityMode::CRF => write!(f, "CRF (Quality)"),
QualityMode::Bitrate => write!(f, "Bitrate (kbps)"),
}
}
}
#[derive(Clone, Debug)]
pub struct EncodeProgress {
pub current_frame: usize,
pub total_frames: usize,
pub stage: EncodeStage,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum EncodeStage {
Validating, Opening, Encoding, Flushing, Complete, #[allow(dead_code)] Error(String), }
#[derive(Debug)]
pub enum EncodeError {
EncoderNotFound,
HardwareEncoderUnavailable,
OutputCreateFailed(String),
EncodeFrameFailed(String),
Cancelled,
}
impl std::fmt::Display for EncodeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EncodeError::EncoderNotFound => write!(f, "Encoder not found"),
EncodeError::HardwareEncoderUnavailable => {
write!(f, "Hardware encoder not available")
}
EncodeError::OutputCreateFailed(msg) => {
write!(f, "Failed to create output file: {}", msg)
}
EncodeError::EncodeFrameFailed(msg) => {
write!(f, "Frame encoding failed: {}", msg)
}
EncodeError::Cancelled => write!(f, "Encoding cancelled by user"),
}
}
}
impl std::error::Error for EncodeError {}
fn get_encoder_name(
codec: VideoCodec,
encoder_impl: EncoderImpl,
) -> Result<&'static str, EncodeError> {
match (codec, encoder_impl) {
(VideoCodec::H264, EncoderImpl::Hardware) | (VideoCodec::H264, EncoderImpl::Auto) => {
#[cfg(target_os = "macos")]
if ffmpeg::encoder::find_by_name("h264_videotoolbox").is_some() {
return Ok("h264_videotoolbox");
}
if ffmpeg::encoder::find_by_name("h264_nvenc").is_some() {
Ok("h264_nvenc")
} else if ffmpeg::encoder::find_by_name("h264_qsv").is_some() {
Ok("h264_qsv")
} else if ffmpeg::encoder::find_by_name("h264_amf").is_some() {
Ok("h264_amf")
} else if encoder_impl == EncoderImpl::Auto {
Ok("libx264") } else {
Err(EncodeError::HardwareEncoderUnavailable)
}
}
(VideoCodec::H264, EncoderImpl::Software) => Ok("libx264"),
(VideoCodec::H265, EncoderImpl::Hardware) | (VideoCodec::H265, EncoderImpl::Auto) => {
#[cfg(target_os = "macos")]
if ffmpeg::encoder::find_by_name("hevc_videotoolbox").is_some() {
return Ok("hevc_videotoolbox");
}
if ffmpeg::encoder::find_by_name("hevc_nvenc").is_some() {
Ok("hevc_nvenc")
} else if ffmpeg::encoder::find_by_name("hevc_qsv").is_some() {
Ok("hevc_qsv")
} else if ffmpeg::encoder::find_by_name("hevc_amf").is_some() {
Ok("hevc_amf")
} else if encoder_impl == EncoderImpl::Auto {
Ok("libx265") } else {
Err(EncodeError::HardwareEncoderUnavailable)
}
}
(VideoCodec::H265, EncoderImpl::Software) => Ok("libx265"),
(VideoCodec::AV1, EncoderImpl::Hardware) | (VideoCodec::AV1, EncoderImpl::Auto) => {
if ffmpeg::encoder::find_by_name("av1_nvenc").is_some() {
Ok("av1_nvenc")
} else if ffmpeg::encoder::find_by_name("av1_qsv").is_some() {
Ok("av1_qsv")
} else if ffmpeg::encoder::find_by_name("av1_amf").is_some() {
Ok("av1_amf")
} else if encoder_impl == EncoderImpl::Auto {
if ffmpeg::encoder::find_by_name("libsvtav1").is_some() {
Ok("libsvtav1")
} else {
Ok("libaom-av1")
}
} else {
Err(EncodeError::HardwareEncoderUnavailable)
}
}
(VideoCodec::AV1, EncoderImpl::Software) => {
if ffmpeg::encoder::find_by_name("libsvtav1").is_some() {
Ok("libsvtav1")
} else {
Ok("libaom-av1")
}
}
(VideoCodec::ProRes, _) => Ok("prores_ks"),
}
}
pub fn encode_sequence(
cache: &mut Cache,
settings: &EncoderSettings,
progress_tx: Sender<EncodeProgress>,
cancel_flag: Arc<AtomicBool>,
) -> Result<(), EncodeError> {
let play_range = cache.get_play_range();
let total_frames = play_range.1 - play_range.0 + 1;
info!(
"Starting encode: {} frames ({}..{}) to {:?}",
total_frames, play_range.0, play_range.1, settings.output_path
);
let _ = progress_tx.send(EncodeProgress {
current_frame: 0,
total_frames,
stage: EncodeStage::Validating,
});
let first_frame = cache.get_frame(play_range.0).ok_or_else(|| {
EncodeError::EncodeFrameFailed(format!("First frame {} not in cache", play_range.0))
})?;
if first_frame.status() == FrameStatus::Header {
first_frame.load().map_err(|e| {
EncodeError::EncodeFrameFailed(format!("Failed to load first frame: {}", e))
})?;
}
let (width, height) = first_frame.resolution();
let (width, height) = (width as u32, height as u32);
info!("Using first frame dimensions as target: {}x{}", width, height);
if cancel_flag.load(Ordering::Relaxed) {
return Err(EncodeError::Cancelled);
}
let _ = progress_tx.send(EncodeProgress {
current_frame: 0,
total_frames,
stage: EncodeStage::Opening,
});
unsafe {
ffmpeg::ffi::av_log_set_level(ffmpeg::ffi::AV_LOG_QUIET);
}
let _container_format = match settings.container {
Container::MP4 => "mp4",
Container::MOV => "mov",
};
let mut octx = ffmpeg::format::output(&settings.output_path)
.map_err(|e| EncodeError::OutputCreateFailed(e.to_string()))?;
let encoder_name = get_encoder_name(settings.codec, settings.encoder_impl)?;
info!("Looking for encoder: {}", encoder_name);
let codec = ffmpeg::encoder::find_by_name(encoder_name).ok_or_else(|| {
info!("Encoder '{}' not found", encoder_name);
EncodeError::EncoderNotFound
})?;
info!(
"Using encoder: {} for codec {:?}",
encoder_name, settings.codec
);
let mut encoder = ffmpeg::codec::context::Context::new_with_codec(codec)
.encoder()
.video()
.map_err(|e| EncodeError::OutputCreateFailed(format!("Failed to create encoder: {}", e)))?;
encoder.set_width(width);
encoder.set_height(height);
let needs_yuv = matches!(
encoder_name,
"h264_nvenc"
| "hevc_nvenc"
| "av1_nvenc"
| "h264_qsv"
| "hevc_qsv"
| "av1_qsv"
| "h264_amf"
| "hevc_amf"
| "av1_amf"
| "h264_videotoolbox"
| "hevc_videotoolbox"
| "libsvtav1"
| "libaom-av1"
| "prores_ks"
);
let pixel_format = if encoder_name == "prores_ks" {
ffmpeg::format::Pixel::YUV422P10LE
} else if needs_yuv {
ffmpeg::format::Pixel::YUV420P
} else {
ffmpeg::format::Pixel::RGB24
};
encoder.set_format(pixel_format);
let fps_num = settings.fps as i32;
encoder.set_frame_rate(Some(ffmpeg::util::rational::Rational::new(fps_num, 1)));
encoder.set_time_base(ffmpeg::util::rational::Rational::new(1, fps_num));
let gop_size = (fps_num * 10).max(1);
encoder.set_gop(gop_size as u32);
let mut opts = ffmpeg::Dictionary::new();
match settings.quality_mode {
QualityMode::CRF => {
if encoder_name == "h264_nvenc" || encoder_name == "hevc_nvenc" {
opts.set("rc", "constqp"); opts.set("cq", &settings.quality_value.to_string()); if let Some(ref preset) = settings.preset {
if !preset.is_empty() {
opts.set("preset", preset); }
}
opts.set("forced-idr", "1"); opts.set("no-scenecut", "1"); } else if encoder_name == "libx264" {
opts.set("crf", &settings.quality_value.to_string());
if let Some(ref preset) = settings.preset {
if !preset.is_empty() {
opts.set("preset", preset);
}
}
if let Some(ref profile) = settings.profile {
opts.set("profile", profile);
}
opts.set("keyint", &gop_size.to_string()); opts.set("sc_threshold", "0"); } else if encoder_name == "libx265" {
opts.set("crf", &settings.quality_value.to_string());
if let Some(ref preset) = settings.preset {
if !preset.is_empty() {
opts.set("preset", preset);
}
}
opts.set("keyint", &gop_size.to_string()); opts.set("scenecut", "0"); } else if encoder_name == "h264_qsv" || encoder_name == "hevc_qsv" {
opts.set("global_quality", &settings.quality_value.to_string());
} else if encoder_name == "h264_amf" || encoder_name == "hevc_amf" {
opts.set("rc", "cqp");
opts.set("qp", &settings.quality_value.to_string());
} else if encoder_name == "h264_videotoolbox" || encoder_name == "hevc_videotoolbox" {
let bitrate_kbps = if settings.quality_value <= 18 {
10000
} else if settings.quality_value <= 23 {
5000
} else {
2500
};
encoder.set_bit_rate(bitrate_kbps * 1000);
} else if encoder_name == "av1_nvenc" {
opts.set("rc", "constqp");
opts.set("qp", &settings.quality_value.to_string()); if let Some(ref preset) = settings.preset {
if !preset.is_empty() {
opts.set("preset", preset); }
}
} else if encoder_name == "av1_qsv" {
opts.set("global_quality", &settings.quality_value.to_string());
} else if encoder_name == "av1_amf" {
opts.set("rc", "cqp");
opts.set("qp", &settings.quality_value.to_string());
} else if encoder_name == "libsvtav1" {
opts.set("crf", &settings.quality_value.to_string());
if let Some(ref preset) = settings.preset {
if !preset.is_empty() {
opts.set("preset", preset); }
}
} else if encoder_name == "libaom-av1" {
opts.set("crf", &settings.quality_value.to_string());
if let Some(ref preset) = settings.preset {
if !preset.is_empty() {
opts.set("cpu-used", preset); }
}
} else if encoder_name == "prores_ks" {
let profile = settings
.prores_profile
.as_ref()
.map(|p| p.to_ffmpeg_value())
.unwrap_or("2");
info!(
"ProRes encoding with profile {} ({:?})",
profile, settings.prores_profile
);
opts.set("profile", profile);
opts.set("vendor", "apl0"); }
}
QualityMode::Bitrate => {
encoder.set_bit_rate(settings.quality_value as usize * 1000); }
}
info!(
"Opening encoder '{}' with pixel_format={:?}, size={}x{}",
encoder_name,
encoder.format(),
width,
height
);
info!("Encoder options:");
for (key, value) in opts.iter() {
info!(" {} = {}", key, value);
}
let mut encoder = encoder.open_with(opts).map_err(|e| {
EncodeError::OutputCreateFailed(format!("Failed to open encoder '{}': {}", encoder_name, e))
})?;
let mut ost = octx
.add_stream(codec)
.map_err(|e| EncodeError::OutputCreateFailed(format!("Failed to add stream: {}", e)))?;
ost.set_parameters(&encoder);
ost.set_time_base(encoder.time_base());
if settings.codec == VideoCodec::H265
&& matches!(settings.container, Container::MP4 | Container::MOV)
{
unsafe {
(*ost.parameters().as_mut_ptr()).codec_tag = u32::from_le_bytes(*b"hvc1");
}
info!("Set HEVC codec tag to 'hvc1' for Apple compatibility");
}
let mut container_opts = ffmpeg::Dictionary::new();
if matches!(settings.container, Container::MP4) {
container_opts.set("movflags", "faststart");
}
octx.set_metadata(octx.metadata().to_owned());
octx.write_header_with(container_opts)
.map_err(|e| EncodeError::OutputCreateFailed(format!("Failed to write header: {}", e)))?;
let stream_tb = octx.stream(0).unwrap().time_base();
let encoder_tb = encoder.time_base();
info!(
"Encoder initialized: {}x{} @ {} fps, quality mode: {:?}, time_base: encoder={:?} stream={:?}",
width, height, settings.fps, settings.quality_mode, encoder_tb, stream_tb
);
if cancel_flag.load(Ordering::Relaxed) {
return Err(EncodeError::Cancelled);
}
let _ = progress_tx.send(EncodeProgress {
current_frame: 0,
total_frames,
stage: EncodeStage::Encoding,
});
info!("Starting encoding loop for {} frames", total_frames);
let mut sws_ctx = if needs_yuv {
info!("Creating SwsContext for RGB→{:?} conversion", pixel_format);
Some(SwsContext::new(ffmpeg::format::Pixel::RGB24, pixel_format, width, height)
.map_err(|e| EncodeError::OutputCreateFailed(format!("Failed to create swscale context: {}", e)))?)
} else {
info!("Using RGB24 directly (no YUV conversion)");
None
};
let mut pts = 0i64;
info!("Entering frame encoding loop...");
#[allow(clippy::explicit_counter_loop)]
for frame_idx in play_range.0..=play_range.1 {
if cancel_flag.load(Ordering::Relaxed) {
return Err(EncodeError::Cancelled);
}
if frame_idx % 10 == 0 {
info!("Processing frame {}/{}", frame_idx - play_range.0, total_frames);
}
let frame = cache.get_frame(frame_idx).ok_or_else(|| {
EncodeError::EncodeFrameFailed(format!("Frame {} not in cache", frame_idx))
})?;
if frame.status() == FrameStatus::Header {
frame.load().map_err(|e| {
EncodeError::EncodeFrameFailed(format!("Failed to load frame {}: {}", frame_idx, e))
})?;
}
let (frame_width, frame_height) = frame.resolution();
let frame_to_encode = if frame_width != width as usize || frame_height != height as usize {
info!(
"Cropping frame {} from {}x{} to {}x{}",
frame_idx, frame_width, frame_height, width, height
);
frame.crop_copy(width as usize, height as usize, CropAlign::Center)
} else {
frame.clone()
};
let rgb24_data = frame_to_encode.to_rgb24().map_err(|e| {
EncodeError::EncodeFrameFailed(format!("Frame {} RGBA→RGB24 conversion failed: {}", frame_idx, e))
})?;
let mut ffmpeg_frame = if needs_yuv {
sws_ctx.as_mut().unwrap()
.convert(&rgb24_data, width, height)
.map_err(|e| {
EncodeError::EncodeFrameFailed(format!("RGB→YUV conversion failed: {}", e))
})?
} else {
let mut ffmpeg_frame =
ffmpeg::util::frame::video::Video::new(ffmpeg::format::Pixel::RGB24, width, height);
let dst_stride = ffmpeg_frame.stride(0);
let src_stride = (width * 3) as usize;
{
let dst_data = ffmpeg_frame.data_mut(0);
for y in 0..height as usize {
let src_offset = y * src_stride;
let dst_offset = y * dst_stride;
dst_data[dst_offset..dst_offset + src_stride]
.copy_from_slice(&rgb24_data[src_offset..src_offset + src_stride]);
}
}
ffmpeg_frame
};
ffmpeg_frame.set_pts(Some(pts));
pts += 1;
encoder.send_frame(&ffmpeg_frame).map_err(|e| {
EncodeError::EncodeFrameFailed(format!("Failed to send frame {}: {}", frame_idx, e))
})?;
if cancel_flag.load(Ordering::Relaxed) {
return Err(EncodeError::Cancelled);
}
let mut encoded = ffmpeg::Packet::empty();
while encoder.receive_packet(&mut encoded).is_ok() {
if cancel_flag.load(Ordering::Relaxed) {
return Err(EncodeError::Cancelled);
}
encoded.set_stream(0);
encoded.rescale_ts(encoder_tb, stream_tb);
encoded.set_stream(0);
encoded.set_duration(1);
let pts_val = encoded.pts();
let dts_val = encoded.dts();
if dts_val.is_none() {
if let Some(pts) = pts_val {
encoded.set_dts(Some(pts));
}
}
if frame_idx - play_range.0 < 3 {
info!(
"Packet {}: pts={:?}, dts={:?}, duration={}, keyframe={}, tb={:?}→{:?}",
frame_idx - play_range.0,
encoded.pts(),
encoded.dts(),
encoded.duration(),
encoded.is_key(),
encoder_tb,
stream_tb
);
}
encoded.write_interleaved(&mut octx).map_err(|e| {
EncodeError::EncodeFrameFailed(format!("Failed to write packet: {}", e))
})?;
}
let current_frame = frame_idx - play_range.0 + 1;
let _ = progress_tx.send(EncodeProgress {
current_frame,
total_frames,
stage: EncodeStage::Encoding,
});
if current_frame.is_multiple_of(10) {
info!("Encoded frame {}/{}", current_frame, total_frames);
}
}
let _ = progress_tx.send(EncodeProgress {
current_frame: total_frames,
total_frames,
stage: EncodeStage::Flushing,
});
info!("Flushing encoder...");
encoder
.send_eof()
.map_err(|e| EncodeError::EncodeFrameFailed(format!("Failed to flush encoder: {}", e)))?;
let mut encoded = ffmpeg::Packet::empty();
while encoder.receive_packet(&mut encoded).is_ok() {
if cancel_flag.load(Ordering::Relaxed) {
return Err(EncodeError::Cancelled);
}
encoded.rescale_ts(encoder_tb, stream_tb);
encoded.set_stream(0);
encoded.set_duration(1);
if encoded.dts().is_none() {
if let Some(pts) = encoded.pts() {
encoded.set_dts(Some(pts));
}
}
encoded.write_interleaved(&mut octx).map_err(|e| {
EncodeError::EncodeFrameFailed(format!("Failed to write packet: {}", e))
})?;
}
info!("Flushed {} remaining packets", total_frames - (play_range.1 - play_range.0 + 1));
info!("Writing trailer...");
octx.write_trailer()
.map_err(|e| EncodeError::OutputCreateFailed(format!("Failed to write trailer: {}", e)))?;
info!("Trailer written successfully");
let _ = progress_tx.send(EncodeProgress {
current_frame: total_frames,
total_frames,
stage: EncodeStage::Complete,
});
info!(
"Encoding complete: {} frames written to {:?}",
total_frames, settings.output_path
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cache::Cache;
use crate::frame::Frame;
use crate::sequence::Sequence;
#[test]
fn test_encode_placeholder_frames() {
playa_ffmpeg::init().expect("Failed to init FFmpeg");
println!("Testing available video encoders:");
let test_encoders = [
"libx264",
"h264_nvenc",
"h264_qsv", "libx265",
"hevc_nvenc",
"hevc_qsv", "mpeg4",
"libxvid", "libvpx",
"libvpx-vp9", "libaom-av1", ];
let mut found_encoder: Option<&str> = None;
for name in &test_encoders {
if ffmpeg::encoder::find_by_name(name).is_some() {
println!(" ✓ {} FOUND", name);
if found_encoder.is_none() {
found_encoder = Some(name);
}
} else {
println!(" ✗ {} not found", name);
}
}
if found_encoder.is_none() {
panic!(
"NO VIDEO ENCODERS FOUND - FFmpeg build has no encoding support! Skipping test."
);
}
println!("\nUsing encoder: {}", found_encoder.unwrap());
let (mut cache, _ui_rx) = Cache::new(0.1, None);
let frames: Vec<Frame> = (0..100).map(|_| Frame::new(640, 480)).collect();
let seq = Sequence::from_frames(frames, "test_placeholder.*.rgb".to_string(), 640, 480);
cache.append_seq(seq);
cache.set_play_range(10, 49);
let (play_start, play_end) = cache.get_play_range();
println!(
"Play range set: {}..{} ({} frames)",
play_start,
play_end,
play_end - play_start + 1
);
let (codec, encoder_impl, encoder_name) =
if ffmpeg::encoder::find_by_name("h264_nvenc").is_some() {
println!("\n🎬 Using NVENC hardware encoder");
(VideoCodec::H264, EncoderImpl::Hardware, "h264_nvenc")
} else if ffmpeg::encoder::find_by_name("libx264").is_some() {
println!("\n🎬 Using libx264 software encoder");
(VideoCodec::H264, EncoderImpl::Software, "libx264")
} else if ffmpeg::encoder::find_by_name("mpeg4").is_some() {
println!("\n🎬 Using mpeg4 encoder");
(VideoCodec::MPEG4, EncoderImpl::Software, "mpeg4")
} else {
println!("\nâš No compatible encoder available, skipping encoding test");
println!(" Available: {}", found_encoder.unwrap());
println!(" Need: libx264, h264_nvenc, or mpeg4");
println!("\n✓ Test infrastructure verified:");
println!(" - Cache with 100 placeholder frames created");
println!(" - Encoder discovery working");
return;
};
let output_path = std::path::PathBuf::from("test_encode_output.mp4");
let _ = std::fs::remove_file(&output_path);
let settings = EncoderSettings {
output_path: output_path.clone(),
container: Container::MP4,
codec,
encoder_impl,
quality_mode: QualityMode::Bitrate,
quality_value: 2000, };
let (tx, rx) = std::sync::mpsc::channel();
let cancel_flag = Arc::new(AtomicBool::new(false));
let abs_path = std::fs::canonicalize(&output_path)
.unwrap_or_else(|_| std::env::current_dir().unwrap().join(&output_path));
println!(
"Encoding frames {}..{} to: {}",
play_start,
play_end,
abs_path.display()
);
let result = encode_sequence(&mut cache, &settings, tx, cancel_flag);
let mut last_progress: Option<EncodeProgress> = None;
while let Ok(progress) = rx.try_recv() {
last_progress = Some(progress);
}
assert!(result.is_ok(), "Encoding failed: {:?}", result);
assert!(output_path.exists(), "Output file was not created");
let metadata =
std::fs::metadata(&output_path).expect("Failed to read output file metadata");
assert!(metadata.len() > 0, "Output file is empty");
println!("✓ Encoding test passed!");
println!(" Encoder: {}", encoder_name);
println!(" Output: {}", abs_path.display());
println!(
" Size: {} bytes ({:.2} KB)",
metadata.len(),
metadata.len() as f64 / 1024.0
);
if let Some(progress) = last_progress {
assert_eq!(
progress.stage,
EncodeStage::Complete,
"Encoding did not complete"
);
println!(
" Frames: {}/{} (play range: {}..{})",
progress.current_frame, progress.total_frames, play_start, play_end
);
assert_eq!(
progress.total_frames, 40,
"Should encode exactly 40 frames from play range"
);
}
}
}