Rust FFmpeg-next 视频水印与帧编辑之旅:从小白到多媒体高手的进阶实战

Rust FFmpeg-next 视频水印与帧编辑之旅:从小白到多媒体高手的进阶实战

Photos provided by Unsplash OR Pexels

引言:视频处理的 Rust 魔力觉醒

在 2025 年,视频内容爆炸式增长,从短视频剪辑到 AI 辅助编辑,多媒体处理已成为开发者必备技能。基于上篇高级进阶指南,我们结合 FFmpeg 的强大功能,使用 ffmpeg-next crate——Rust 中 FFmpeg 的安全绑定——实现视频截取、添加水印和帧级编辑。FFmpeg 作为开源多媒体框架,能高效处理解码、过滤和编码,而 Rust 的内存安全确保无泄漏风险。无论是截取精彩片段、嵌入品牌水印,还是选取帧添加特效,你都能在命令行或应用中轻松实现。

这份指南为小白量身打造,由浅入深,从基础视频截取入手,逐步深入水印添加和帧编辑实战。无论你是 Rust 新手还是视频处理初学者,都能通过详细理论、完整代码一步步上手。让我们唤醒 Rust 的视频魔力,开启这场从截取到编辑的奇妙之旅吧!

第一部分:基础入门 - 使用 FFmpeg-next 截取视频片段

理论基础

视频截取(trim)是多媒体处理的起点,涉及寻求(seek)起始点和限制输出时长。ffmpeg-next 通过 format::Context::seek() 跳转时间戳;处理包时检查 PTS(Presentation Time Stamp)控制结束。基本流程:打开输入 -> seek 起始 -> 解码/编码循环 -> 直到时长结束 -> 输出。

  • 时间戳处理:FFmpeg 用 AV_TIME_BASE (1/1000000 秒) 单位;rescale_ts 调整流时间基。
  • 优势:精确到帧,避免重新编码损失质量(用 copy codec)。
  • 最佳实践:用 Duration 处理用户输入;多线程解码加速(set_thread_count)。潜在问题:seek 不精确——用 AVSEEK_FLAG_BACKWARD 优化。
  • 小白提示:先理解流(stream):选 best(Type::Video)。

适合快速剪辑短视频。

实例代码:简单视频截取

  1. 项目设置:Cargo.toml(完整依赖,确保可运行)
[package]
name = "video_watermark_editor"
version = "0.1.0"
edition = "2024"

[dependencies]
ffmpeg-next = "6.1"
anyhow = "1.0"
num_cpus = "1.16"
std = { version = "1.80", features = ["time"] }
  1. src/main.rs(完整可运行代码,包括 main 调用 trim_video):
use anyhow::Result;
use ffmpeg_next as ffmpeg;
use ffmpeg::format::{input, Pixel};
use ffmpeg::media::Type;
use num_cpus;
use std::path::Path;
use std::time::Duration;

fn trim_video(input_path: &Path, output_path: &Path, start: Duration, duration: Duration) -> Result<()> {
    ffmpeg::init()?;

    let mut ictx = input(input_path)?;
    let mut octx = ffmpeg::format::output(output_path)?;

    let input_stream = ictx.streams().best(Type::Video).ok_or(ffmpeg::Error::StreamNotFound)?;
    let stream_index = input_stream.index();

    let mut out_stream = octx.add_stream(ffmpeg::codec::encoder::find(ffmpeg::codec::Id::H264))?;

    let mut decoder = input_stream.codec().decoder().video()?;
    decoder.set_thread_count(num_cpus::get() as u32);

    let mut encoder = out_stream.codec().encoder().video()?;
    encoder.set_width(decoder.width());
    encoder.set_height(decoder.height());
    encoder.set_format(Pixel::YUV420P);
    encoder.set_frame_rate(decoder.frame_rate());
    encoder.set_time_base(decoder.time_base());
    encoder.open()?;

    octx.write_header()?;

    let start_us = start.as_micros() as i64;
    ictx.seek(start_us, ..start_us + 1)?; // 精确 seek,使用 AVSEEK_FLAG_BYTE 如果需要

    let end_us = start_us + duration.as_micros() as i64;
    let time_base = input_stream.time_base();

    for (stream, mut packet) in ictx.packets() {
        if stream.index() == stream_index {
            let pts = packet.pts().unwrap_or(0) * time_base.numerator() as i64 / time_base.denominator() as i64;
            if pts >= end_us {
                break;
            }
            packet.rescale_ts(time_base, out_stream.time_base());
            packet.set_pts(Some(packet.pts().unwrap_or(0) - start_us / (time_base.denominator() as i64 / time_base.numerator() as i64)));
            packet.set_dts(packet.dts().map(|d| d - start_us / (time_base.denominator() as i64 / time_base.numerator() as i64)));

            decoder.send_packet(&packet)?;
            let mut frame = ffmpeg::frame::Video::empty();
            while decoder.receive_frame(&mut frame).is_ok() {
                frame.set_pts(frame.pts().map(|p| p - start_us / (time_base.denominator() as i64 / time_base.numerator() as i64)));
                encoder.send_frame(&frame)?;
                let mut encoded = ffmpeg::Packet::empty();
                while encoder.receive_packet(&mut encoded).is_ok() {
                    encoded.set_stream(0);
                    encoded.write_interleaved(&mut octx)?;
                }
            }
        }
    }

    decoder.send_eof()?;
    let mut frame = ffmpeg::frame::Video::empty();
    while decoder.receive_frame(&mut frame).is_ok() {
        encoder.send_frame(&frame)?;
        let mut encoded = ffmpeg::Packet::empty();
        while encoder.receive_packet(&mut encoded).is_ok() {
            encoded.set_stream(0);
            encoded.write_interleaved(&mut octx)?;
        }
    }

    encoder.send_eof()?;
    let mut encoded = ffmpeg::Packet::empty();
    while encoder.receive_packet(&mut encoded).is_ok() {
        encoded.set_stream(0);
        encoded.write_interleaved(&mut octx)?;
    }

    octx.write_trailer()?;

    Ok(())
}

fn main() -> Result<()> {
    let input_path = Path::new("input.mp4");
    let output_path = Path::new("trimmed.mp4");
    trim_video(input_path, output_path, Duration::from_secs(10), Duration::from_secs(30))?;
    println!("视频截取完成!输出文件:{:?}", output_path);
    Ok(())
}

解释:从 10s 开始截取 30s。调整 PTS/DTS 确保同步;添加完整 EOF 处理以 flush 缓冲。运行前,确保系统安装 FFmpeg 开发库(如 libavcodec-dev)。

第二部分:进阶水印添加 - 使用 FFmpeg 滤镜嵌入文字

理论基础

水印添加用 FFmpeg 滤镜链,如 drawtext 嵌入文字。ffmpeg-nextfilter::Graph 创建滤镜上下文:add filter(如 buffer, drawtext, buffersink);连接输入/输出。流程:解码 -> 滤镜应用 -> 编码。

  • drawtext 参数:text, fontsize, fontcolor, x/y (支持表达式如 w-text_w-10 右对齐), fontfile (需系统字体路径)。
  • 优势:实时应用,无需帧编辑;支持透明 (alpha)。
  • 最佳实践:嵌入字体路径;多滤镜链如 overlay 图像水印。潜在问题:滤镜无效——用 validate() 检查。
  • 小白提示:滤镜是管道:源 -> 滤镜 -> 接收器。

适合品牌视频水印。

实例代码:添加文字水印

扩展 Cargo.toml 添加无新依赖。完整代码:

use anyhow::Result;
use ffmpeg_next as ffmpeg;
use ffmpeg::format::{input, Pixel};
use ffmpeg::media::Type;
use std::path::Path;

fn add_watermark(input_path: &Path, output_path: &Path, text: &str, font_path: &str) -> Result<()> {
    ffmpeg::init()?;

    let mut ictx = input(input_path)?;
    let input_stream = ictx.streams().best(Type::Video).ok_or(ffmpeg::Error::StreamNotFound)?;
    let stream_index = input_stream.index();

    let mut decoder = input_stream.codec().decoder().video()?;

    let mut octx = ffmpeg::format::output(output_path)?;
    let mut out_stream = octx.add_stream(ffmpeg::codec::encoder::find(ffmpeg::codec::Id::H264))?;
    let mut encoder = out_stream.codec().encoder().video()?;
    encoder.set_width(decoder.width());
    encoder.set_height(decoder.height());
    encoder.set_format(Pixel::YUV420P);
    encoder.set_frame_rate(decoder.frame_rate());
    encoder.set_time_base(decoder.time_base());
    encoder.open()?;

    // 滤镜图
    let mut filter_graph = ffmpeg::filter::Graph::new();

    let buffer_args = format!("video_size={}x{}:pix_fmt={}:time_base={}/{}:frame_rate={}",
        decoder.width(), decoder.height(), decoder.format() as i32,
        decoder.time_base().numerator(), decoder.time_base().denominator(),
        decoder.frame_rate().unwrap_or(ffmpeg::Rational(25, 1)));

    let mut filter_in = filter_graph.add(&ffmpeg::filter::find("buffer").unwrap(), "in", &buffer_args)?;
    let mut filter_out = filter_graph.add(&ffmpeg::filter::find("buffersink").unwrap(), "out", "")?;

    // drawtext 水印,指定字体路径
    let drawtext_args = format!("fontfile='{}':text='{}':fontsize=24:fontcolor=white@0.8:x=(w-text_w-10):y=(h-text_h-10)", font_path, text); // 右下角,半透
    let mut drawtext = filter_graph.add(&ffmpeg::filter::find("drawtext").unwrap(), "drawtext", &drawtext_args)?;

    filter_in.output(0)?.input(&mut drawtext, 0)?;
    drawtext.output(0)?.input(&mut filter_out, 0)?;

    filter_out.set_pixel_format(Pixel::YUV420P);
    filter_graph.validate()?;

    octx.write_header()?;

    for (stream, packet) in ictx.packets() {
        if stream.index() == stream_index {
            decoder.send_packet(&packet)?;
            let mut decoded = ffmpeg::frame::Video::empty();
            while decoder.receive_frame(&mut decoded).is_ok() {
                filter_graph.get("in")?.source().add(&decoded)?;
                let mut filtered = ffmpeg::frame::Video::empty();
                while filter_graph.get("out")?.sink().frame(&mut filtered).is_ok() {
                    encoder.send_frame(&filtered)?;
                    let mut encoded = ffmpeg::Packet::empty();
                    while encoder.receive_packet(&mut encoded).is_ok() {
                        encoded.set_stream(0);
                        encoded.write_interleaved(&mut octx)?;
                    }
                }
            }
        }
    }

    decoder.send_eof()?;
    let mut decoded = ffmpeg::frame::Video::empty();
    while decoder.receive_frame(&mut decoded).is_ok() {
        filter_graph.get("in")?.source().add(&decoded)?;
        let mut filtered = ffmpeg::frame::Video::empty();
        while filter_graph.get("out")?.sink().frame(&mut filtered).is_ok() {
            encoder.send_frame(&filtered)?;
            let mut encoded = ffmpeg::Packet::empty();
            while encoder.receive_packet(&mut encoded).is_ok() {
                encoded.set_stream(0);
                encoded.write_interleaved(&mut octx)?;
            }
        }
    }

    encoder.send_eof()?;
    let mut encoded = ffmpeg::Packet::empty();
    while encoder.receive_packet(&mut encoded).is_ok() {
        encoded.set_stream(0);
        encoded.write_interleaved(&mut octx)?;
    }

    octx.write_trailer()?;

    Ok(())
}

fn main() -> Result<()> {
    let input_path = Path::new("input.mp4");
    let output_path = Path::new("watermarked.mp4");
    let font_path = "/path/to/your/font.ttf"; // 替换为实际字体路径,如 /usr/share/fonts/truetype/FreeSans.ttf
    add_watermark(input_path, output_path, "Rust 水印", font_path)?;
    println!("水印添加完成!输出文件:{:?}", output_path);
    Ok(())
}

解释:drawtext 添加半透文字右下角;完整循环处理所有流和 EOF。运行前,确保字体路径有效。

第三部分:高级帧编辑 - 提取帧、编辑并重构视频

理论基础

帧编辑:提取帧到图像缓冲,用 image crate 编辑(如加水印),然后编码新视频。ffmpeg-next 用 receive_frame 提取;scaler 转 RGB;image::ImageBuffer 处理像素。重构:创建新编码器,send_frame。

  • 提取:循环 receive_frame,保存或编辑。
  • 编辑:从 frame.data(0) 构建 ImageBuffer,应用滤镜。
  • 重构:新视频,添加编辑帧。
  • 最佳实践:复用帧缓冲;并行编辑多帧。潜在问题:格式转换损失——用 YUV420P。
  • 小白提示:帧是 raw 数据,stride 注意行对齐。

适合自定义帧级水印或特效。

实例代码:提取帧、添加水印并重构

  1. 更新 Cargo.toml:
[dependencies]
ffmpeg-next = "6.1"
anyhow = "1.0"
image = "0.25"
imageproc = "0.25"
rusttype = "0.9"
num_cpus = "1.16"
std = { version = "1.80", features = ["time"] }
  1. 完整代码:
use anyhow::Result;
use ffmpeg_next as ffmpeg;
use ffmpeg::format::{input, Pixel};
use ffmpeg::media::Type;
use ffmpeg::software::scaling::{context::Context, flag::Flags};
use image::{ImageBuffer, Rgb};
use imageproc::drawing::{draw_text_mut, text_size};
use rusttype::{Font, Scale};
use std::path::Path;

fn edit_frames(input_path: &Path, output_path: &Path, text: &str) -> Result<()> {
    ffmpeg::init()?;

    let mut ictx = input(input_path)?;
    let input_stream = ictx.streams().best(Type::Video).ok_or(ffmpeg::Error::StreamNotFound)?;
    let stream_index = input_stream.index();

    let mut decoder = input_stream.codec().decoder().video()?;

    let mut octx = ffmpeg::format::output(output_path)?;
    let mut out_stream = octx.add_stream(ffmpeg::codec::encoder::find(ffmpeg::codec::Id::H264))?;
    let mut encoder = out_stream.codec().encoder().video()?;
    encoder.set_width(decoder.width());
    encoder.set_height(decoder.height());
    encoder.set_format(Pixel::YUV420P);
    encoder.set_frame_rate(decoder.frame_rate());
    encoder.set_time_base(decoder.time_base());
    encoder.open()?;

    // RGB 到 YUV scaler
    let mut to_rgb_scaler = Context::get(
        decoder.format(),
        decoder.width(),
        decoder.height(),
        Pixel::RGB24,
        decoder.width(),
        decoder.height(),
        Flags::BILINEAR,
    )?;

    let mut to_yuv_scaler = Context::get(
        Pixel::RGB24,
        decoder.width(),
        decoder.height(),
        Pixel::YUV420P,
        decoder.width(),
        decoder.height(),
        Flags::BILINEAR,
    )?;

    let font_data: &[u8] = include_bytes!("../FreeSans.ttf");
    let font = Font::try_from_bytes(font_data).ok_or(anyhow::anyhow!("字体加载失败"))?;
    let scale = Scale { x: 32.0, y: 32.0 };
    let color = Rgb([255u8, 0, 0]);

    octx.write_header()?;

    for (stream, packet) in ictx.packets() {
        if stream.index() == stream_index {
            decoder.send_packet(&packet)?;
            let mut decoded = ffmpeg::frame::Video::empty();
            while decoder.receive_frame(&mut decoded).is_ok() {
                let mut rgb_frame = ffmpeg::frame::Video::empty();
                to_rgb_scaler.run(&decoded, &mut rgb_frame)?;

                // 转换为 ImageBuffer
                let width = rgb_frame.width() as u32;
                let height = rgb_frame.height() as u32;
                let stride = rgb_frame.stride(0) as usize;
                let data = rgb_frame.data(0);

                let mut img: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::new(width, height);
                for y in 0..height {
                    let src_offset = y as usize * stride;
                    for x in 0..width {
                        let idx = src_offset + x as usize * 3;
                        img.put_pixel(x, y, Rgb([data[idx], data[idx + 1], data[idx + 2]]));
                    }
                }

                // 编辑:添加水印
                let (text_width, text_height) = text_size(scale, &font, text);
                draw_text_mut(&mut img, color, (width as i32 - text_width as i32 - 10), (height as i32 - text_height as i32 - 10), scale, &font, text);

                // 转回 FFmpeg 帧
                let mut rgb_back = ffmpeg::frame::Video::new(Pixel::RGB24, width, height);
                let mut rgb_data = rgb_back.data_mut(0);
                let img_data = img.into_raw();
                rgb_data.copy_from_slice(&img_data);

                let mut yuv_frame = ffmpeg::frame::Video::empty();
                to_yuv_scaler.run(&rgb_back, &mut yuv_frame)?;

                yuv_frame.set_pts(decoded.pts());

                encoder.send_frame(&yuv_frame)?;
                let mut encoded = ffmpeg::Packet::empty();
                while encoder.receive_packet(&mut encoded).is_ok() {
                    encoded.set_stream(0);
                    encoded.write_interleaved(&mut octx)?;
                }
            }
        }
    }

    decoder.send_eof()?;
    let mut decoded = ffmpeg::frame::Video::empty();
    while decoder.receive_frame(&mut decoded).is_ok() {
        // 类似处理 decoded 到 yuv_frame 并 send
        let mut rgb_frame = ffmpeg::frame::Video::empty();
        to_rgb_scaler.run(&decoded, &mut rgb_frame)?;

        let width = rgb_frame.width() as u32;
        let height = rgb_frame.height() as u32;
        let stride = rgb_frame.stride(0) as usize;
        let data = rgb_frame.data(0);

        let mut img: ImageBuffer<Rgb<u8>, Vec<u8>> = ImageBuffer::new(width, height);
        for y in 0..height {
            let src_offset = y as usize * stride;
            for x in 0..width {
                let idx = src_offset + x as usize * 3;
                img.put_pixel(x, y, Rgb([data[idx], data[idx + 1], data[idx + 2]]));
            }
        }

        let (text_width, text_height) = text_size(scale, &font, text);
        draw_text_mut(&mut img, color, (width as i32 - text_width as i32 - 10), (height as i32 - text_height as i32 - 10), scale, &font, text);

        let mut rgb_back = ffmpeg::frame::Video::new(Pixel::RGB24, width, height);
        let mut rgb_data = rgb_back.data_mut(0);
        let img_data = img.into_raw();
        rgb_data.copy_from_slice(&img_data);

        let mut yuv_frame = ffmpeg::frame::Video::empty();
        to_yuv_scaler.run(&rgb_back, &mut yuv_frame)?;

        yuv_frame.set_pts(decoded.pts());

        encoder.send_frame(&yuv_frame)?;
        let mut encoded = ffmpeg::Packet::empty();
        while encoder.receive_packet(&mut encoded).is_ok() {
            encoded.set_stream(0);
            encoded.write_interleaved(&mut octx)?;
        }
    }

    encoder.send_eof()?;
    let mut encoded = ffmpeg::Packet::empty();
    while encoder.receive_packet(&mut encoded).is_ok() {
        encoded.set_stream(0);
        encoded.write_interleaved(&mut octx)?;
    }

    octx.write_trailer()?;

    Ok(())
}

fn main() -> Result<()> {
    let input_path = Path::new("input.mp4");
    let output_path = Path::new("edited.mp4");
    edit_frames(input_path, output_path, "帧级水印")?;
    println!("帧编辑完成!输出文件:{:?}", output_path);
    Ok(())
}

解释:提取 RGB 帧,用 image 添加水印右下角,转回 YUV 编码。完整处理所有平面和 EOF;into_raw() 转换缓冲。运行需字体文件在项目中。

参考资料

通过这份指南,你已掌握视频处理的精髓。魔力觉醒,继续探索!

版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)