调用 Windows 自带的 Media Foundation 读写音视频,不需要下载任何组件。
sys.mediaFoundation.writer:写 MP4(H.264 视频 + AAC 音频),帧来自 gdip 位图,声音来自 int16 PCM。sys.mediaFoundation.reader:读取并解码 MP4、MOV、M4A、MP3、WAV、WMV 等文件,逐帧得到 gdip 位图,音频得到 int16 PCM。sys.mediaFoundation:底层接口声明、GUID 表和属性工具函数,供高级用法使用。sys.mediaFoundation.mp3Decoder:把内存中陆续到达的 MP3 字节增量解码为 int16 PCM,详见 MP3 增量解码。sys.mediaFoundation.streamingMp3Player:流式 MP3 播放器,边收边播,提供 write/finish/close,详见 流式 MP3 播放。它由 mp3Decoder 解码、sys.waveOut 输出 PCM。下面生成一段 3 秒、带声音的 720p 视频。每写一帧画面,就写入这一帧时长的音频。
import sys.mediaFoundation.writer;
import gdip.graphics;
import gdip.solidBrush;
var W,H,FPS,SR = 1280,720,30,48000;
var writer,err = sys.mediaFoundation.writer("/demo.mp4",{
width = W; height = H; fps = FPS;
audio = {sampleRate = SR; channels = 2};
});
if(!writer) return print(err);
var bmp = gdip.bitmap(W,H,0xE200B/*PARGB*/);
var g = gdip.graphics(bmp);
var brush = gdip.solidBrush(0xFFE0A030);
var spf = SR / FPS; //每帧对应的采样数
for(i=0;FPS*3-1){
g.clear(0xFF202020);
g.fillEllipse(brush,100 + i*10,300,120,120);
writer.writeFrame(bmp);
//这一帧时长的音频:440Hz 正弦,左右声道交错
var samples = {};
for(k=0;spf-1){
var v = math.floor(math.sin(2*math.pi*440*(i*spf+k)/SR) * 8000);
samples[k*2+1] = v;
samples[k*2+2] = v;
}
writer.writeAudio(raw.buffer({word s[] = samples}));
}
brush.delete();
g.delete();
bmp.dispose();
writer.close(); //必须调用,否则 MP4 文件不完整
| 字段 | 说明 |
|---|---|
width,height |
视频尺寸,必须是偶数。省略则只写音频(可生成 M4A) |
fps |
帧率,默认 30。29.97、23.976、59.94 自动转为 N*1000/1001,也可以直接写 {30000,1001} |
quality |
画质 1~100,默认 80。按固定 QP 编码,文件大小随画面复杂度变化,见下文 |
videoBitrate |
改为按平均码率编码(bps,VBR),需要控制文件大小时使用。与 quality 只能指定一个 |
profile |
"high"(默认)、"main"、"baseline" |
gop |
关键帧间隔(帧数),默认 2 秒 |
hardware |
是否允许硬件编码器,默认 false(软件编码),见下文 |
encoder |
编码器参数表,键为 CODECAPI_ 名称(可省略前缀),最后写入,可覆盖以上设置 |
audio |
{sampleRate=48000; channels=2; bitrate=192000},省略则不写音频 |
AAC 只支持 44100 或 48000 采样率,1、2、6 声道;码率只能是 96/128/160/192 kbps,其他值会取最接近的一档。
默认 quality=80:关键帧(I 帧)QP 12,其他帧(P 帧)QP 20。writer.rateControl 记录实际使用的设置,例如 {mode="quality";quality=80;qpI=12;qpP=20}。
videoBitrate。import sys.mediaFoundation.writer;
var writer = sys.mediaFoundation.writer("/demo-quality.mp4",{width = 1920; height = 1080; fps = 30; quality = 90});
print(writer.rateControl.qpI,writer.rateControl.qpP); //12 16
writer.writeFrame(raw.buffer(1920*1080*4),30); //1 秒黑屏:同一帧重复 30 次
writer.close();
下表是 1080p30 测试片段(12~20px 小字、1px 细线、暗部渐变、照片、滚动字幕、粒子、硬切换)的实测结果,PSNR 越高越接近原画:
| 设置 | 码率 | 小字 PSNR | 细线 PSNR | 问题 |
|---|---|---|---|---|
| 按码率 CBR 12 Mbps(软件) | 7.3 Mbps | 45.5 dB,首帧 38.4 | 43.4 dB,首帧 35.8 | 开头一秒明显发虚 |
| 编码器自带质量模式(关键帧与其他帧同 QP) | 5.2 Mbps | 45.6 dB | 43.8 dB,关键帧 35.4 | 每 2 秒闪一下 |
| quality=80(默认,软件编码) | 5.0 Mbps | 48.2 dB | 46.7 dB | — |
quality=80 + hardware=true(Intel 核显 QSV) |
4.5 Mbps | 49.0 dB | 48.4 dB | 结果因显卡厂商和驱动而异 |
默认使用系统自带的软件编码器(hardware=false),原因:
hardware=true 时使用同样的画质设置。在测试机的 Intel 核显上,同样的 quality 硬件编码文件略小、画质略好(见上表),但换一台电脑结果可能完全不同。其他厂商的编码器如果不支持固定 QP,创建写入器会返回错误,这时可改用 videoBitrate。照片、实拍素材较多时可以试试 hardware=true,并用读取器回读检查画质。
writer.writeFrame(frame,repeatCount) 的 frame 可以是:
gdip.bitmap:尺寸必须与视频一致。建议用 PARGB 格式(0xE200B),这时只是一次内存复制;其他格式由 GDI+ 自动转换。半透明的部分按黑底合成。宽×4 字节。repeatCount 表示把同一帧连续写入多次(共用同一份数据),适合静止画面。时间戳按帧号自动计算,长时间写入也不会漂移。
writer.writeAudio(pcm) 写入 int16 交错 PCM(立体声为 L,R,L,R…),字节数必须是 声道数×2 的整数倍。可以分块多次写入,时间戳按已写入的采样数自动计算。
音视频要交替写入:如果先写完全部画面再写声音,系统会在内存里缓存整段视频,长视频会耗尽 32 位进程的内存。writer.videoTime 与 writer.audioTime 记录了两路已写入的时长,可以据此对齐。
import sys.mediaFoundation.reader;
var reader,err = sys.mediaFoundation.reader("/demo.mp4");
if(!reader) return print(err);
var info = reader.info;
print(info.duration,info.video[["width"]],info.video[["height"]],info.audio[["sampleRate"]]);
for(bitmap,time,frame in reader.eachFrame()){
//bitmap 是读取器复用的 PARGB 位图,需要保留时调用 bitmap.copy()
if(frame == 0) bitmap.save("/first.png");
}
var pcm,startTime = reader.readAudio(); //int16 交错 PCM(buffer)
reader.close();
reader.info:duration(秒)、video(width、height、fps、frameRate、subtype、bitrate)、audio(sampleRate、channels、bitsPerSample、subtype、bitrate)。没有视频或音频时对应字段为 null。reader.readFrame():返回 位图,时间,帧号,帧号从 0 开始、按时间戳换算;读完返回 null,出错返回 null,错误信息。eachFrame() 迭代中遇到的错误记录在 reader.lastError。reader.readAudio(seconds):解码为 int16 PCM,返回 PCM,起始时间。指定秒数时按解码块边界读取(会略多一点),适合分段处理长音频;读完返回 null。解码后的采样率和声道数见 reader.info.audio。sys.mediaFoundation.reader(path,{sampleRate=48000;channels=2}) 让系统解码器直接重采样、混缩(MP3、AAC 等任意采样率都能统一到 48k)。省略时与源相同;多于 2 声道的源默认混缩为立体声。reader.seek(seconds):视频与音频同时定位。之后读到的首帧是离该时刻最近的帧,音频从该时刻开始。视频和音频各用一个系统读取器,读取位置互不影响,只读其中一路时也不会在内存里堆积另一路的数据。
以下行为已在 Windows 11 上用库自带的验收测试实测确认,并用 Chromium(WebView2)独立解码核对过。
close(),否则 MP4 文件不完整(对象被回收时也会自动调用,但不要依赖它)。import 并创建对象。null,错误信息,错误信息里包含 HRESULT 错误码。sys.mediaFoundation 提供了 IMFAttributes、IMFMediaType、IMFSample、IMFMediaBuffer、IMF2DBuffer、IMFSinkWriter、IMFSourceReader 接口声明(方法顺序按 Windows SDK 头文件)、常用 GUID 表 guids,以及按名称批量设置属性的函数:
import sys.mediaFoundation;
var mf = sys.mediaFoundation;
mf.startup();
var mediaType = mf.createMediaType({
MF_MT_MAJOR_TYPE = "MFMediaType_Video"; //字符串写入 GUID
MF_MT_SUBTYPE = "MFVideoFormat_H264";
MF_MT_AVG_BITRATE = 8000000; //数值写入 UINT32
MF_MT_FRAME_SIZE = {1920,1080}; //{高32位,低32位} 写入 UINT64
});
var width,height = mf.getPair(mediaType,"MF_MT_FRAME_SIZE");
print(width,height,mf.guidName(mf.getGUID(mediaType,"MF_MT_SUBTYPE")));
com.Release(mediaType);
mf.shutdown();
writer.sinkWriter 是写入器内部的 IMFSinkWriter 接口对象,可用于库没有封装的高级操作。