ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java录屏录音实战:JavaCV+FFmpeg实现MP4视频与AAC音频同步编码

Java录屏录音实战:JavaCV+FFmpeg实现MP4视频与AAC音频同步编码 简介一份JavaFX桌面录屏录音软件项目源码面向Java桌面应用与多媒体处理的开发者。项目完整实现录屏、录音、暂停、播放、MP4保存等功能覆盖从界面搭建到音视频流处理的常见路径。压缩包共48个文件以PNG截图、Java源码、class编译产物和第三方jar依赖为主附带Eclipse工程配置整体约14.93MB便于导入分析。核心思路是用java.awt.Robot抓屏、javax.sound.sampled采集音频再通过FFmpeg/JavaCV完成编码封装可学习JavaFX与AWT桥接、音视频同步与第三方库集成暂停恢复、多线程与异常处理也体现了录制工具的典型设计。目前已有6258人学习下载适合从源码层面拆解录制流程并扩展播放或剪辑功能的开发者。1. Java 录屏录音项目从哪入手先想清楚「截帧」和「编码」谁来做给后端 Java 工程师一个反直觉的结论桌面录屏最难的不是「拿到画面」而是把 Robot 截出的 BufferedImage 和 TargetDataLine 采出的裸 PCM 转成 MP4 里的 H.264 视频流与 AAC 音频流。系统自带录屏、Word 录制画布都绕开了这个环节所以一到自定义码率、暂停续录就露馅。适合看这篇的是没碰过 JavaCV 和 Java Sound、想搭一套能跑通的录屏源码的人以及被音画不同步和暂停时长错位折磨过的桌面端开发者。常见做法是用 JavaCV 封装 FFmpeg 做帧编码AWT Robot 做视频源javax.sound.sampled 做音频源暂停和续录用统一的状态机制约。下文按「视频帧管道 → 音频帧管道 → 暂停与播放 → 校验排错」四层推进。2. 用 JavaCV Robot 搭最小录屏链路把桌面帧直接落成 MP42.1 为什么录屏不能只用 Robot 一条路走到底Robot 是 java.awt 里最直接的截屏 APIcreateScreenCapture接收一个 Rectangle返回 BufferedImage。多显示器场景下用 GraphicsDevice 数组可以拼出完整桌面区域。问题在于 Robot 只负责「拿图」不负责「编码」。H.264 编码器吃的是 YUV 色彩空间的帧Robot 给的是 RGB 的 BufferedImage。RGB 直接写 MP4 会有两个后果一是编码器要额外做色彩转换CPU 占用高二是很多播放器对非 YUV420P 的 MP4 兼容性差手机上放出来偏色甚至绿屏。另一种思路是用 JavaCV 直接调 FFmpeg 的 gdigrab、avfoundation 设备抓屏Windows 和 macOS 上性能更好但桌面捕获下沉到了 native 层代码里全是平台相关分支。作为项目源码交付这类代码反而难维护。我一般把 Robot 当跨平台取帧器把 JavaCV 当编码器取帧逻辑守住 AWT 的兼容面编码和封装交给 FFmpeg。这样一份源码在三个系统上都能跑只是帧率上限不同。实际验证中Robot 在 1080p 下跑到 30 FPS 没有压力4K 想跑 60 FPS 就得换 gdigrab。2.2 最小依赖与录屏写 MP4 的核心代码pom 里加一个坐标dependency groupIdorg.bytedeco/groupId artifactIdjavacv-platform/artifactId version1.5.10/version /dependencyjavacv-platform 会把 ffmpeg、opencv 以及对应平台的 native 库一起拉进来体积大但省去手动配 dll/so 的麻烦。生产环境想瘦身可以换成 javacv javacv-ffmpeg 两个模块配合裁剪。然后是最小录屏线程// 屏幕捕获与 MP4 编码的最小链路 Rectangle screenRect new Rectangle(Toolkit.getDefaultToolkit().getScreenSize()); Robot robot new Robot(); try (FFmpegFrameRecorder recorder new FFmpegFrameRecorder( record.mp4, screenRect.width, screenRect.height)) { recorder.setFormat(mp4); recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264); recorder.setVideoBitrate(4_000_000); // 4 Mbps1080p30 够用 recorder.setFrameRate(30); recorder.setPixelFormat(avutil.AV_PIX_FMT_YUV420P); recorder.start(); Java2DFrameConverter converter new Java2DFrameConverter(); Frame frame new Frame(); while (recording) { BufferedImage img robot.createScreenCapture(screenRect); converter.convert(img, frame); // 复用 frame避免频繁分配对象 frame.timestamp System.nanoTime() / 1000L; // FFmpeg 内部时间基准是微秒 recorder.record(frame); Thread.sleep(33); // 30 FPS 的节奏控制 } recorder.stop(); }逻辑说明每轮循环先从桌面抓一张快照转成 JavaCV 的 Frame 后直接交给 recorder 写入。timestamp 用微秒是因为 FFmpeg 内部时间轴默认按百万分之一秒计算把它设为墙钟时间后面合音频时才不会因为时间基准不同导致音画错位。Thread.sleep(33) 不是必须的但它能把编码压力摊平更精细的写法是按 System.nanoTime 计算下一帧的启动时刻避免 sleep 累加出帧间隔漂移。参数里 4 Mbps 不是越大越好录文字界面的培训视频 2 Mbps 就够录动态游戏画面再考虑 8 Mbps。提示recorder 用了 try-with-resources确保异常时也能释放 native 资源。转换器务必使用 convert(img, frame) 复用 Frame 的重载否则长时间录屏会产生大量短期对象GC 停顿会直接引起掉帧。2.3 录屏写 MP4 的关键参数表录屏写 MP4 的参数决定了文件体积、清晰度和播放器兼容性下面这六个是 FFmpegFrameRecorder 上最常被调的对象参数常见取值作用建议videoCodech264视频编码器用 libx264播放器兼容性最好pixelFormatyuv420p像素格式播放器兼容性的底线别用 yuv444frameRate15 / 30 / 60每秒帧数培训录屏 15 够用操作演示 3060 留给高性能机器videoBitrate2M / 4M / 8M画质与体积的平衡静态界面 2M动态窗口 4M游戏画面 8MgopSize帧率的 2 倍I 帧间隔画面平缓时大 GOP 能省不少体积audioCodecaac音频编码器MP4 容器下比 mp3 兼容性更好videoBitrate 和 gopSize 是录屏项目里最容易被忽略的一组。码率决定瞬时画质GOP 决定关键帧间隔I 帧越稀疏体积越小但播放器拖进度条时的起播等待越久。录屏画面变化平缓把 GOP 设成帧率的两倍是通用选择。pixelFormat 则没有商量余地YUV420P 是 MP4 兼容性的底线改 YUV444 会显著增加解码负担且收益有限。2.4 给「项目源码」一个可维护的工程结构录屏软件涉及采集、编码、UI、播放器四个功能域源码如果全塞在一个 Main 类里到加暂停时就会失控。我一般会摆成下面这样的结构把功能边界拆到独立包下这也符合 Java 项目源码交付时对模块划分的基本要求src/main/java ├── capture │ ├── ScreenCapturer.java // Robot 取帧输出 BufferedImage │ ├── AudioCapturer.java // TargetDataLine 采集 PCM │ └── PauseGate.java // 线程安全的暂停门闩 ├── encoder │ └── Mp4Encoder.java // 持有 FFmpegFrameRecorder ├── player │ └── Mp4Player.java // JavaFX 播放器 └── App.java // 状态机与录制流程编排ScreenCapturer 和 AudioCapturer 各自跑一个线程共享 PauseGateMp4Encoder 只暴露 start / pause / resume / stop不关心画面和声音从哪来。这样后面换 gdigrab 抓屏源或者加一个「只录选区」的功能上游取帧和下游封装都不用改。3. TargetDataLine 录音与暂停控制音频流不能拖垮主流程3.1 用 Java Sound 拿到的裸 PCM怎么进 MP4javax.sound.sampled 提供的 TargetDataLine 是 Java 侧访问麦克风的标准入口采出来的是 PCM 原始数据没有编码、没有压缩、没有文件头。要把这堆字节写进 MP4得先明确采样格式再用 FFmpegFrameRecorder 的 recordSamples 按格式喂进去。最常用的录音参数是 44100 Hz、16 bit、双声道、signed、little-endian这也是 Windows 声卡最常见的数据形态。采样率必须和设备实际能力匹配。44100 Hz 是 CD 音质麦克风基本都支持48000 Hz 常见于 HDMI 采集卡。如果录音源码里写死 44100而用户麦克风设备默认 48000AudioFormat 匹配不上会直接抛 LineUnavailableException。另一个容易错的是字节序。AudioFormat 构造器最后一个参数是 bigEndianfalse 表示小端。Windows 上声卡写出的字节流是小端如果一边读一边按大端解析成 short声音会变成刺耳噪声。3.2 录音线程代码与 recordSamples 的正确调用音频线程独立于视频线程跑核心代码如下AudioFormat audioFormat new AudioFormat(44_100f, 16, 2, true, false); DataLine.Info info new DataLine.Info(TargetDataLine.class, audioFormat); TargetDataLine line (TargetDataLine) AudioSystem.getLine(info); line.open(audioFormat); line.start(); byte[] buffer new byte[4096]; short[] samples new short[buffer.length / 2]; // PCM16 两字节一个样本 while (audioRunning) { pauseGate.await(); // 暂停状态时阻塞在门闩上 int bytesRead line.read(buffer, 0, buffer.length); int samplesToWrite bytesRead / 2; ByteBuffer.wrap(buffer) .order(ByteOrder.LITTLE_ENDIAN) .asShortBuffer() .get(samples, 0, samplesToWrite); if (bytesRead buffer.length) { Arrays.fill(samples, samplesToWrite, samples.length, (short) 0); } recorder.recordSamples(samples, 0, samples.length); } line.close();在这段代码启动之前recorder 需要补上音频参数并且必须在 recorder.start() 之前完成设置recorder.setAudioCodec(avcodec.AV_CODEC_ID_AAC); recorder.setSampleRate(44_100); recorder.setAudioChannels(2);逻辑说明read 从声卡缓冲取回字节数组这个调用是阻塞的正好适合放进独立线程。ByteBuffer 把小端字节转成 short 样本顺序不能反。recordSamples 第三个参数传 samples.length是为了避免 API 对「样本数」和「帧数」的语义差异不足一 buffer 的尾巴补零防止把上次残留的旧数据写进音频流。录音线程和视频线程并发调 record / recordSamples 是常见用法recorder 内部对写入做了同步但如果你发现某个平台下有卡顿可以在录音线程里把 buffer 加大到 8192 字节减少切换次数。音频格式参数本身也值得单独列出来作为录制源码里的「约定基准」AudioFormat 参数取值说明sampleRate44100 / 48000必须和声卡实际采样率一致否则抛 LineUnavailableExceptionsampleSizeInBits16用 168 位动态范围不够底噪会明显channels2立体声给后期处理留空间signedtrue有符号 PCMJava Sound 默认样本格式bigEndianfalseWindows 默认小端改动会导致声音失真实时降噪在 Java 侧一般不做采集端只保证拿到干净的 PCM。工程上更常见的做法是录完以后交给 Audacity 这类音频编辑器做降噪去底噪Java 源码里不必为这个需求引入额外的 DSP 依赖。3.3 暂停的正确姿势门闩与时间戳校准录屏软件的暂停和播放器的暂停不一样。播放器暂停时间轴本身停住录制端暂停时如果视频线程和音频线程只是简单 sleep线程恢复时机不一致MP4 中间会多出一段只有单轨数据的时间隙。更稳的做法是所有采集线程共享同一个 PauseGate暂停时阻塞恢复时同时放行。public class PauseGate { private volatile boolean paused; public synchronized void setPaused(boolean paused) { this.paused paused; notifyAll(); } public synchronized void await() throws InterruptedException { while (paused) { wait(); } } }await 放在循环里很重要wait 被 notify 唤醒后要重新检查状态防止虚假唤醒。恢复录音时line 缓冲区里可能残留暂停期间攒下的音频数据直接读取会把暂停段也写进 MP4。我一般会在线程恢复后先调用 line.drain() 清空声卡缓冲再继续 recordSamples。视频端没有同类缓冲问题但要注意暂停期间帧序号的变化要和恢复后的 timestamp 对齐这部分在 5.3 里单独处理。3.4 系统音频源差异不是所有机器都能录到「对方的声音」麦克风是标准音频输入但很多录屏需求要录的是系统内部播放的声音比如网课视频的原声。Java 侧没有跨平台的「录系统声音」API只能靠操作系统驱动层支持。Windows 上要在声音设置里启用「立体声混音」设备录音线程再通过 AudioSystem.getMixerInfo 遍历包含该设备的 mixermacOS 需要安装 BlackHole 这类虚拟声卡Linux 则要选 PulseAudio 的 monitor 源。这部分跟 Java 代码本身无关却是录屏源码交付后最大的落地阻力。所以录音设备不能硬编码默认设备建议在 UI 里枚举全部输入设备做成下拉框for (Mixer.Info mi : AudioSystem.getMixerInfo()) { Mixer mixer AudioSystem.getMixer(mi); Line.Info[] lines mixer.getTargetLineInfo(); for (Line.Info li : lines) { if (li.getLineClass() TargetDataLine.class) { System.out.println(mi.getName() - li); } } }4. 暂停/恢复状态机与 JavaFX 播放器联动录放一体化4.1 用枚举把录制状态机定清楚录屏项目有四个状态IDLE未开始、RECORDING录制中、PAUSED暂停中、STOPPED已停止。暂停按钮只能在 RECORDING 时生效恢复只能在 PAUSED 时生效开始按钮只能在 IDLE 时生效。状态机不建模用户手抖连点两次「开始」同一份 MP4 就会写进两路帧流。我在 App 里维护一个枚举字段并用迁移边限制非法跳转enum RecorderState { IDLE, RECORDING, PAUSED, STOPPED } void to(RecorderState next) { boolean allowed (state RecorderState.IDLE next RecorderState.RECORDING) || (state RecorderState.RECORDING next RecorderState.PAUSED) || (state RecorderState.PAUSED next RecorderState.RECORDING) || ((state RecorderState.RECORDING || state RecorderState.PAUSED) next RecorderState.STOPPED); if (!allowed) { throw new IllegalStateException(非法状态迁移: state - next); } this.state next; gate.setPaused(next RecorderState.PAUSED); }状态迁移的同时驱动 PauseGate一次调用完成两件事。停止时允许从 PAUSED 回到 STOPPED保证用户暂停很久后直接点「保存」MP4 的时长只算到暂停前的内容暂停区间不会以空白帧形式写进文件。这套状态机的实现也经常被拿来当录屏相关的 Java 面试题核心考察点就是「暂停时多个采集线程如何联动」。4.2 JavaFX 播放器的最小实现录完要在同一个桌面应用里回放JavaFX 的 MediaPlayer 是最省事的方案。依赖加上 javafx-media 模块启动时走 module-path核心代码很短Media media new Media(new File(record.mp4).toURI().toString()); MediaPlayer mediaPlayer new MediaPlayer(media); MediaView mediaView new MediaView(mediaPlayer); mediaView.setFitWidth(1280); mediaView.setPreserveRatio(true); Button pauseButton new Button(播放/暂停); pauseButton.setOnAction(e - { if (mediaPlayer.getStatus() MediaPlayer.Status.PAUSED) { mediaPlayer.play(); } else { mediaPlayer.pause(); } });MediaPlayer 是异步组件play 调用后画面不会立刻渲染必须等 setOnReady 回调里再 play否则本地文件还没解析完就开始播放表现是黑屏假死。进度条用 Timeline 周期性读取 getCurrentTime() 更新即可javafx.animation 包自带这个类。JavaFX 播放 javacv 录出的 MP4 有个兼容点只要第 2 章用了 libx264 编码JavaFX 就能正常识别如果换成 openh264部分平台的 MediaPlayer 会报 UnsupportedMediaException所以源码里不要轻易替换编码器。4.3 录制端暂停与播放端暂停是两个层面的操作很多录屏源码会把这两个暂停混在一个按钮里这是典型的交互设计错误。录制端暂停是 PauseGate 停止向编码器投递帧和样本播放端暂停是 MediaPlayer 停止消费视频流。按钮上写的都是「暂停」行为完全不同。工具条上的暂停控制采集播放器控件上的暂停控制回放两者要用独立的按钮和独立的事件处理器。把录制暂停误绑到 mediaPlayer.pause() 上最后会得到一份完整 MP4但回放永远停在一开始不动这类 bug 在代码评审里出现频率不低。5. 录完先校验再交付ffprobe 与高频异常排查5.1 ffprobe 一行命令验证 MP4 完整性项目交付前用一行 ffprobe 验证文件结构比任何播放器都可靠。ffprobe 随 ffmpeg 一起分发Windows 下把安装目录加进 PATH 即可ffprobe -v error \ -show_entries streamcodec_type,codec_name,width,height,avg_frame_rate,sample_rate \ -show_entries formatduration \ -of defaultnoprint_wrappers1 record.mp4正常输出类似[STREAM] codec_typevideo codec_nameh264 width1920 height1080 avg_frame_rate30/1 [/STREAM] [STREAM] codec_typeaudio codec_nameaac sample_rate44100 [/STREAM] [FORMAT] duration98.726000 [/FORMAT]检查三项视频流存在、音频流存在、duration 和真实录制时长之差在 1 秒内。缺任何一个流问题一定出在 start() 之前的参数设置而不是录制过程中。5.2 时长不对、黑屏、无声、不同步的排查对照表录屏源码里反复出现的几类问题根因基本都集中在这五个方向症状根因处理MP4 时长远大于实际录制时间暂停期间编码器仍按墙钟生成时间戳暂停时记录时间偏移恢复后从 timestamp 中扣除视频时长正常但画面纯黑DPI 缩放导致 Robot 截取区域超出物理显示范围调用 user32 的 SetProcessDPIAware或按显示器缩放比例换算 Rectangle有画面无声录音设备被占或采样率与设备不匹配枚举全部 mixer确认默认设备不是「立体声混音」之外的虚拟设备声音比画面慢视频帧设了墙钟时间音频样本没有显式时间戳视频与音频都统一来自 pausedAccumulatedNanos 基准播放器打不开文件stop 前 release或录制中途异常退出moov 原子没写录制结束先 stop 再 release并用 try-with-resources 兜底黑屏需要单独强调Windows 在 150% 缩放下Toolkit.getScreenSize() 返回的是逻辑像素尺寸Robot 按这个尺寸截图内容只覆盖物理屏幕的一部分剩下的区域就是黑帧。处理方式是进程启动时调用 SetProcessDPIAware让 Java 拿到真实物理分辨率。5.3 续录时的时间戳校准技巧暂停后继续录制最容易被忽略的就是帧时间戳。假设第一次从 0 秒录到 30 秒暂停 10 秒后恢复再录 20 秒。如果直接把 System.nanoTime 塞进 frame.timestamp文件里的时间轴是 0~30 秒和 40~60 秒播放器为了补齐空缺会自动加速看起来就像视频快进。正确做法是累计暂停时长从恢复后的时间戳里扣掉long pauseStartNanos 0L; long accumulatedPauseNanos 0L; // 点击暂停 pauseStartNanos System.nanoTime(); gate.setPaused(true); // 点击恢复 accumulatedPauseNanos System.nanoTime() - pauseStartNanos; gate.setPaused(false); // 采集线程每帧生成时间戳时 frame.timestamp (System.nanoTime() - accumulatedPauseNanos) / 1000L;音频端 recordSamples 没有显式传时间戳的重载它跟随编码器内部时钟所以只要视频端校准了暂停偏移音频端在恢复后正常写入 sample 就能自然对齐。录制结束后用 ffprobe 核对 duration如果和真实运行时长差在 1 秒以内说明这条时间戳链路是干净的。本文还有配套的精品资源点击获取
返回列表