Add PTT (voice) upload support with FFmpeg addon

Introduce NT SILK-based voice (PTT) upload pipeline and native FFmpeg addon wrapper.

- Add ffmpeg-addon wrapper (packages/core/src/bridge/highway/ffmpeg-addon.ts) to load platform/arch .node binaries, detect SILK files, and convert inputs to NT SILK via the native addon. Provides encodeSilk, isSilkFile and default temp dir helpers.
- Implement ptt-upload pipeline (packages/core/src/bridge/highway/ptt-upload.ts) to: resolve/fetch audio bytes, convert to NT SILK if needed, send OIDB upload request, perform highway HTTP upload when required, and produce Encodable MsgInfo bytes for commonElem.
- Wire record element support into element-builder: uploadPttMsgInfo is used to build a commonElem for 'record' sends; when SendContext is absent, record sends are skipped with a warning (graceful no-bridge path).
- Update message parsing to accept record segments from data.file, data.url or data.path and drop empty sources.
- Enable canSendRecord in OneBot instance context.
- Add tests covering record parsing and the graceful send path (packages/core/tests/message-parser.test.ts).
- Include prebuilt native ffmpeg addon binaries under packages/runtime/native/ffmpeg and update vite config to validate & copy the ffmpeg addon into the dist layout.

These changes enable sending voice messages by converting/encoding sources to the expected NT SILK format and uploading them through the existing highway/NTV2 flows.
This commit is contained in:
手瓜一十雪
2026-05-09 19:32:13 +08:00
parent 2af273b9e2
commit c39b3f5c4e
11 changed files with 525 additions and 5 deletions
@@ -14,6 +14,7 @@ import {
MarkdownDataSchema,
} from '../proto/action';
import { uploadImageMsgInfo } from '../highway/image-upload';
import { uploadPttMsgInfo } from '../highway/ptt-upload';
type ProtoElem = Partial<ProtoDecoded<typeof ElemSchema>>;
@@ -151,10 +152,28 @@ async function makeImageElem(ctx: SendContext, element: MessageElement): Promise
};
}
async function makePttElem(ctx: SendContext, element: MessageElement): Promise<ProtoElem> {
const isGroup = ctx.groupId !== undefined;
const targetIdOrUid = isGroup ? ctx.groupId! : (ctx.userUid ?? '');
if (!isGroup && !targetIdOrUid) {
throw new Error('private record target uid is missing');
}
const msgInfo = await uploadPttMsgInfo(ctx.bridge, isGroup, targetIdOrUid, element);
return {
commonElem: {
serviceType: 48,
pbElem: msgInfo,
businessType: 22,
} as any,
};
}
/**
* Build proto Elem objects from an array of MessageElements.
* Supports: text, face, at, reply, json, xml, markdown, image.
* Image elements trigger highway upload via the SendContext.
* Supports: text, face, at, reply, json, xml, markdown, image, record, forward.
* Image and record elements trigger NTV2 highway upload via the SendContext.
*/
export async function buildSendElems(elements: MessageElement[], ctx?: SendContext): Promise<ProtoElem[]> {
const result: ProtoElem[] = [];
@@ -202,6 +221,13 @@ export async function buildSendElems(elements: MessageElement[], ctx?: SendConte
break;
case 'record':
if (ctx) {
result.push(await makePttElem(ctx, elem));
} else {
console.warn('[ElemBuilder] record send requires SendContext');
}
break;
case 'video':
console.warn(`[ElemBuilder] ${elem.type} send not yet implemented`);
break;
@@ -0,0 +1,161 @@
// FFmpeg native addon wrapper — port of NapCat's FFmpegAddonAdapter.
// No fallback: throws if the addon isn't available on this platform/arch.
//
// The addon is shipped under `native/ffmpeg/ffmpegAddon.<platform>.<arch>.node`
// to match the layout used by the upstream NapCat build so the binaries
// can be dropped in as-is.
import fs from 'fs';
import os from 'os';
import path from 'path';
import crypto from 'crypto';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export interface FFmpegVideoInfo {
width: number;
height: number;
duration: number;
format: string;
videoCodec: string;
image: Buffer;
}
interface FFmpegNativeAddon {
convertFile(inputFile: string, outputFile: string, format: string): Promise<{ success: boolean }>;
getVideoInfo(filePath: string, format?: 'bmp' | 'bmp24'): Promise<FFmpegVideoInfo>;
getDuration(filePath: string): Promise<number>;
convertToNTSilkTct(inputPath: string, outputPath: string): Promise<void>;
decodeAudioToPCM(filePath: string, pcmPath: string, sampleRate?: number): Promise<{ result: boolean; sampleRate: number }>;
decodeAudioToFmt(filePath: string, pcmPath: string, format: string): Promise<{ channels: number; sampleRate: number; format: string }>;
}
let cachedAddon: FFmpegNativeAddon | null = null;
let cachedLoadError: string | null = null;
function addonFileName(): string {
return `ffmpegAddon.${process.platform}.${process.arch}.node`;
}
function addonSearchDirs(): string[] {
// Mirrors the hook injector's resolution strategy so the addon is found
// regardless of whether we're running from:
// 1. A released zip (dist/native/ffmpeg/<file>.node), where __dirname is
// the dist root.
// 2. The bundled build (repoRoot/dist/native/ffmpeg/<file>.node).
// 3. `tsx` dev mode (packages/runtime/native/ffmpeg/<file>.node), where
// __dirname is `packages/core/src/bridge/highway` — four levels deep
// under `packages/`. Note: the hook injector lives at
// `packages/core/src/hook` so it only needs 3 `..`; we need 4.
return [
path.resolve(__dirname, 'native', 'ffmpeg'),
path.resolve(__dirname, '..', '..', '..', '..', 'runtime', 'native', 'ffmpeg'),
path.resolve(process.cwd(), 'dist', 'native', 'ffmpeg'),
path.resolve(process.cwd(), 'packages', 'runtime', 'native', 'ffmpeg'),
];
}
function resolveAddonPath(): string | null {
const fileName = addonFileName();
for (const dir of addonSearchDirs()) {
const full = path.join(dir, fileName);
if (fs.existsSync(full)) return full;
}
return null;
}
/**
* Load the ffmpegAddon once and return a cached instance.
* Throws on first-use failure; subsequent calls replay the same error message
* so callers don't silently fall back.
*/
export function getFFmpegAddon(): FFmpegNativeAddon {
if (cachedAddon) return cachedAddon;
if (cachedLoadError) throw new Error(cachedLoadError);
const addonPath = resolveAddonPath();
if (!addonPath) {
cachedLoadError = `ffmpegAddon not found for ${process.platform}-${process.arch} (looked for ${addonFileName()})`;
throw new Error(cachedLoadError);
}
try {
const mod = { exports: {} as Record<string, unknown> };
process.dlopen(mod, addonPath);
cachedAddon = mod.exports as unknown as FFmpegNativeAddon;
return cachedAddon;
} catch (error) {
cachedLoadError = `failed to load ffmpegAddon (${addonPath}): ${error instanceof Error ? error.message : String(error)}`;
throw new Error(cachedLoadError);
}
}
/**
* Check whether `filePath` is already an NT SILK container. The addon can
* read any input directly so the only reason we peek at the header is to
* skip the conversion (and the temp file) when the caller already fed us
* a ready-to-upload silk file.
*/
export function isSilkFile(filePath: string): boolean {
try {
const fd = fs.openSync(filePath, 'r');
try {
const buf = Buffer.alloc(10);
fs.readSync(fd, buf, 0, 10, 0);
const header = buf.toString();
return header.includes('#!SILK') || header.includes('\x02#!SILK');
} finally {
fs.closeSync(fd);
}
} catch {
return false;
}
}
export interface EncodeSilkResult {
/** Absolute path to the silk file ready for highway upload. */
path: string;
/** Voice duration in whole seconds (>= 1). */
duration: number;
/** `true` if we wrote a new silk file and the caller should unlink it. */
converted: boolean;
}
/**
* Convert `inputFile` to NT SILK when necessary and return the output path
* + duration. The caller is responsible for unlinking the returned path
* when `converted === true`.
*/
export async function encodeSilk(inputFile: string, tempDir: string): Promise<EncodeSilkResult> {
const addon = getFFmpegAddon();
// Already silk — trust the file, just measure duration via the addon.
if (isSilkFile(inputFile)) {
let duration = 1;
try {
duration = Math.max(1, Math.round(await addon.getDuration(inputFile)));
} catch {
// getDuration on a silk file occasionally fails on certain encodings;
// the upload path only needs a rough, non-zero value, so fall back
// to the size-based heuristic used by NapCat's audio.ts.
const stat = fs.statSync(inputFile);
duration = Math.max(1, Math.floor(stat.size / 1024 / 3));
}
return { path: inputFile, duration, converted: false };
}
fs.mkdirSync(tempDir, { recursive: true });
const outPath = path.join(tempDir, crypto.randomUUID());
await addon.convertToNTSilkTct(inputFile, outPath);
if (!fs.existsSync(outPath)) {
throw new Error('convertToNTSilkTct produced no output file');
}
const duration = Math.max(1, Math.round(await addon.getDuration(inputFile)));
return { path: outPath, duration, converted: true };
}
/** Default location for temporary silk files. */
export function defaultPttTempDir(): string {
return path.join(os.tmpdir(), 'snowluma-ptt');
}
@@ -0,0 +1,274 @@
// Voice (PTT) upload pipeline:
// 1. resolve / fetch the source audio bytes,
// 2. convert to NT SILK via the ffmpegAddon (skip if already silk),
// 3. NTV2 OIDB upload request — 0x126E (group) / 0x126D (private),
// 4. highway HTTP upload — commandId 1008 (group) / 1007 (private),
// 5. return the encoded `MsgInfo` bytes ready for the commonElem.
//
// Port of NapCat's `UploadGroupPtt` / `UploadPrivatePtt` transformer + the
// `uploadGroupPtt` / `uploadC2CPtt` paths in `PacketHighwayContext`.
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import type { Bridge } from '../bridge';
import type { MessageElement } from '../events';
import { protoEncode, protoDecode } from '../../protobuf/decode';
import { makeOidbBaseSchema } from '../proto/oidb';
import {
NTV2UploadRichMediaReqSchema,
NTV2UploadRichMediaRespSchema,
EncodableMediaMsgInfoSchema,
} from '../proto/highway';
import { loadBinarySource, computeHashes } from './utils';
import {
fetchHighwaySession,
uploadHighwayHttp,
buildHighwayExtend,
} from './highway-client';
import { encodeSilk, defaultPttTempDir } from './ffmpeg-addon';
import { createLogger } from '../../utils/logger';
const log = createLogger('Ptt');
export const PRIVATE_PTT_CMD_ID = 1007;
export const GROUP_PTT_CMD_ID = 1008;
interface PttPayload {
/** Silk bytes that get uploaded to highway. */
bytes: Uint8Array;
md5: Uint8Array;
sha1: Uint8Array;
md5Hex: string;
sha1Hex: string;
fileName: string;
/** Whole seconds, >= 1. */
duration: number;
/** Cleanup hooks invoked once the OIDB request + highway upload finish. */
cleanups: Array<() => void>;
}
/**
* Decide whether `source` looks like a local filesystem path. The shape
* matches the heuristics used inside `loadBinarySource`: we only reuse the
* caller-supplied path verbatim for things the addon can read directly.
*/
function asLocalFilePath(source: string): string | null {
if (!source) return null;
if (source.startsWith('base64://')) return null;
if (source.startsWith('http://') || source.startsWith('https://')) return null;
let filePath = source;
if (filePath.startsWith('file://')) filePath = filePath.slice(7);
if (/^\/[a-zA-Z]:/.test(filePath)) filePath = filePath.slice(1);
return filePath;
}
async function loadPtt(element: MessageElement, tempDir: string): Promise<PttPayload> {
const source = element.url || element.fileId || '';
if (!source) throw new Error('record source is empty');
const cleanups: Array<() => void> = [];
// Run all queued cleanups; callable both on the success path (via the
// returned `cleanups` array) and the failure path (try/catch below).
const runCleanups = () => {
while (cleanups.length > 0) {
const fn = cleanups.pop();
if (!fn) continue;
try { fn(); } catch { /* best-effort */ }
}
};
fs.mkdirSync(tempDir, { recursive: true });
try {
// Resolve the on-disk path the addon should read. Anything that isn't
// already a local file (base64, HTTP) gets staged into the temp dir.
let inputPath: string;
const local = asLocalFilePath(source);
if (local && fs.existsSync(local)) {
inputPath = local;
} else {
const loaded = await loadBinarySource(source, 'record');
inputPath = path.join(tempDir, `snowluma-ptt-in-${crypto.randomUUID()}`);
fs.writeFileSync(inputPath, Buffer.from(loaded.bytes));
cleanups.push(() => { try { fs.unlinkSync(inputPath); } catch { /* ignore */ } });
}
const silk = await encodeSilk(inputPath, tempDir);
if (silk.converted) {
cleanups.push(() => { try { fs.unlinkSync(silk.path); } catch { /* ignore */ } });
log.debug('converted record to silk: %s -> %s (duration=%ds)', inputPath, silk.path, silk.duration);
}
const silkBytes = new Uint8Array(fs.readFileSync(silk.path));
if (silkBytes.length === 0) throw new Error('silk file is empty after conversion');
const hashes = computeHashes(silkBytes);
return {
bytes: silkBytes,
md5: hashes.md5,
sha1: hashes.sha1,
md5Hex: hashes.md5Hex,
sha1Hex: hashes.sha1Hex,
fileName: `${hashes.md5Hex}.amr`,
duration: silk.duration,
cleanups: [...cleanups],
};
} catch (err) {
runCleanups();
throw err;
}
}
function makeClientRandomId(): bigint {
// Same trick NapCat uses: 8 random bytes, masked into the positive int64
// range so it survives proto signed-int64 encoding without surprises.
const buf = crypto.randomBytes(8);
return buf.readBigUInt64BE() & 0x7FFFFFFFFFFFFFFFn;
}
async function startPttUpload(
bridge: Bridge,
isGroup: boolean,
targetIdOrUid: string | number,
ptt: PttPayload,
): Promise<any> {
const body: any = {
reqHead: {
// NapCat uses requestId=1 for group / 4 for c2c. Mirror it.
common: { requestId: isGroup ? 1 : 4, command: 100 },
scene: {
requestType: 2,
businessType: 3,
sceneType: isGroup ? 2 : 1,
...(isGroup
? { group: { groupUin: Number(targetIdOrUid) } }
: { c2c: { accountType: 2, targetUid: String(targetIdOrUid) } }),
},
client: { agentType: 2 },
},
upload: {
uploadInfo: [{
fileInfo: {
fileSize: ptt.bytes.length,
fileHash: ptt.md5Hex,
fileSha1: ptt.sha1Hex,
fileName: ptt.fileName,
// type=3 voice, voiceFormat=1 (silk).
type: { type: 3, picFormat: 0, videoFormat: 0, voiceFormat: 1 },
width: 0,
height: 0,
time: ptt.duration,
original: 0,
},
subFileType: 0,
}],
tryFastUploadCompleted: true,
srvSendMsg: false,
clientRandomId: makeClientRandomId(),
compatQmsgSceneType: isGroup ? 2 : 1,
extBizInfo: {
// NapCat fills in a placeholder textSummary so the legacy compat
// QMsg path still has something to render. Mirror it.
pic: { textSummary: 'Nya~' },
video: { bytesPbReserve: new Uint8Array(0) },
ptt: {
bytesReserve: new Uint8Array([0x08, 0x00, 0x38, 0x00]),
bytesPbReserve: new Uint8Array(0),
// `bytesGeneralFlags` differs between group / c2c voice. Lifted
// verbatim from NapCat (UploadGroupPtt.ts / UploadPrivatePtt.ts).
bytesGeneralFlags: isGroup
? new Uint8Array([0x9a, 0x01, 0x07, 0xaa, 0x03, 0x04, 0x08, 0x08, 0x12, 0x00])
: new Uint8Array([0x9a, 0x01, 0x0b, 0xaa, 0x03, 0x08, 0x08, 0x04, 0x12, 0x04, 0x00, 0x00, 0x00, 0x00]),
},
},
clientSeq: 0,
noNeedCompatMsg: false,
},
};
const oidbCmd = isGroup ? 0x126E : 0x126D;
const serviceCmd = isGroup ? 'OidbSvcTrpcTcp.0x126e_100' : 'OidbSvcTrpcTcp.0x126d_100';
const baseSchema = makeOidbBaseSchema(NTV2UploadRichMediaReqSchema);
const request = protoEncode({
command: oidbCmd, subCommand: 100, errorCode: 0, body, errorMsg: '', reserved: 1,
}, baseSchema);
const result = await bridge.sendRawPacket(serviceCmd, request);
if (!result.success || !result.gotResponse || !result.responseData) {
throw new Error(result.errorMessage || 'ptt upload request failed');
}
const respBaseSchema = makeOidbBaseSchema(NTV2UploadRichMediaRespSchema);
const resp: any = protoDecode(result.responseData, respBaseSchema);
if (!resp) throw new Error('failed to decode ptt upload response');
if (resp.errorCode && resp.errorCode !== 0) {
throw new Error(`OIDB error ${resp.errorCode}: ${resp.errorMsg ?? ''}`);
}
const uploadBody = resp.body;
if (!uploadBody) throw new Error('ptt upload response body missing');
if (uploadBody.respHead?.retCode && uploadBody.respHead.retCode !== 0) {
throw new Error(uploadBody.respHead.message ?? 'ptt upload failed');
}
return uploadBody.upload;
}
function finalizePttMsgInfo(upload: any): Uint8Array {
if (!upload?.msgInfo) throw new Error('ptt upload response missing msgInfo');
const msgInfoBody = (upload.msgInfo.msgInfoBody ?? []).map((b: any) => ({
index: b.index, picture: b.picture, fileExist: b.fileExist, hashSum: b.hashSum,
}));
const extBizInfo: any = {};
if (upload.msgInfo.extBizInfo?.pic) extBizInfo.pic = upload.msgInfo.extBizInfo.pic;
if (upload.msgInfo.extBizInfo?.video) extBizInfo.video = upload.msgInfo.extBizInfo.video;
if (upload.msgInfo.extBizInfo?.ptt) extBizInfo.ptt = upload.msgInfo.extBizInfo.ptt;
if (upload.msgInfo.extBizInfo?.busiType !== undefined) {
extBizInfo.busiType = upload.msgInfo.extBizInfo.busiType;
}
return protoEncode({ msgInfoBody, extBizInfo }, EncodableMediaMsgInfoSchema);
}
/**
* Upload a voice clip and return the encoded `MsgInfo` bytes that go inside
* a `commonElem { serviceType: 48, businessType: 22 }`.
*/
export async function uploadPttMsgInfo(
bridge: Bridge,
isGroup: boolean,
targetIdOrUid: string | number,
element: MessageElement,
): Promise<Uint8Array> {
const tempDir = defaultPttTempDir();
const ptt = await loadPtt(element, tempDir);
try {
const upload = await startPttUpload(bridge, isGroup, targetIdOrUid, ptt);
// Highway upload only happens when the server didn't fast-path the file
// (uKey present == "we want the bytes"). Otherwise we just embed the
// returned MsgInfo and trust the dedupe.
const uKey = upload?.uKey ?? '';
if (uKey && upload?.msgInfo) {
log.debug('highway upload: bytes=%d md5=%s scene=%s', ptt.bytes.length, ptt.md5Hex, isGroup ? 'group' : 'c2c');
const session = await fetchHighwaySession(bridge);
const extend = buildHighwayExtend(uKey, upload.msgInfo, upload.ipv4s ?? [], ptt.sha1);
const commandId = isGroup ? GROUP_PTT_CMD_ID : PRIVATE_PTT_CMD_ID;
await uploadHighwayHttp(bridge, session, commandId, ptt.bytes, ptt.md5, extend);
} else {
log.debug('ptt fast-uploaded (server already has md5=%s)', ptt.md5Hex);
}
return finalizePttMsgInfo(upload);
} finally {
for (const fn of ptt.cleanups) {
try { fn(); } catch { /* best-effort cleanup */ }
}
}
}
+1 -1
View File
@@ -59,7 +59,7 @@ export function buildApiContext(ref: OneBotInstanceContext): ApiActionContext {
getMessage: (messageId) => messageStore.findEvent(messageId),
getMessageMeta: (messageId) => messageStore.findMeta(messageId),
canSendImage: () => true,
canSendRecord: () => false,
canSendRecord: () => true,
sendPrivateMessage: (userId, message, autoEscape) => handleSendPrivate(ref, userId, message, autoEscape),
sendGroupMessage: (groupId, message, autoEscape) => handleSendGroup(ref, groupId, message, autoEscape),
deleteMessage: (messageId, meta) => handleDeleteMessage(bridge, meta),
+3 -1
View File
@@ -150,9 +150,11 @@ async function segmentToElement(type: string, data: Record<string, unknown>, opt
};
}
case 'record': {
const source = String(data.file ?? data.url ?? data.path ?? '');
if (!source) return null;
return {
type: 'record',
url: String(data.file ?? data.url ?? ''),
url: source,
};
}
case 'video': {
@@ -87,6 +87,30 @@ describe('parseMessage', () => {
expect(result[0].url).toBe('https://example.com/img.png');
});
it('parses record segment from data.file', async () => {
const result = await parseMessage(
[{ type: 'record', data: { file: 'file:///tmp/voice.amr' } }] as any,
false,
);
expect(result).toEqual([{ type: 'record', url: 'file:///tmp/voice.amr' }]);
});
it('parses record segment from data.path (NapCat parity)', async () => {
const result = await parseMessage(
[{ type: 'record', data: { path: 'C:\\voices\\hi.silk' } }] as any,
false,
);
expect(result).toEqual([{ type: 'record', url: 'C:\\voices\\hi.silk' }]);
});
it('drops record segment with empty source', async () => {
const result = await parseMessage(
[{ type: 'record', data: {} }] as any,
false,
);
expect(result).toEqual([]);
});
it('parses at segment', async () => {
const result = await parseMessage(
[{ type: 'at', data: { qq: 12345 } }] as any,
@@ -144,6 +168,24 @@ describe('parseMessage', () => {
expect(protoElems[0].text?.str).toBe('@User ');
});
it('skips record send when SendContext is absent (graceful no-bridge path)', async () => {
// Without a SendContext, buildSendElems can't run highway upload —
// it should warn and drop the element instead of throwing into the
// ffmpegAddon (which would explode for callers that don't actually
// need to send).
const elements = await parseMessage(
[
{ type: 'text', data: { text: 'hi ' } },
{ type: 'record', data: { file: '/tmp/x.silk' } },
] as any,
false,
);
const protoElems = await buildSendElems(elements);
// Only the leading text should make it through.
expect(protoElems).toHaveLength(1);
expect(protoElems[0].text?.str).toBe('hi ');
});
it('parses multiple segments', async () => {
const result = await parseMessage(
[
+16 -1
View File
@@ -26,7 +26,7 @@ const nativeSrc = toPosix(nativeDir);
// Target selection: `SNOWLUMA_TARGET=<platform>-<arch>` overrides the host
// detection, enabling cross-target packaging on CI.
const targetTriple = process.env.SNOWLUMA_TARGET ?? `${process.platform}-${process.arch}`;
const targetPlatform = targetTriple.split('-')[0];
const [targetPlatform, targetArch] = targetTriple.split('-');
// Runtime scaffolding files copied into dist/. The NTQQ hook is Windows-only,
// so its launcher/shell script differ per target.
@@ -47,12 +47,22 @@ const nativeFiles = [
: []),
];
// FFmpeg native addon ported from NapCat. Uses NapCat's
// `<platform>.<arch>` naming so the prebuilt binaries can be vendored
// as-is without renaming. Lives under `native/ffmpeg/` to keep it
// separate from the SnowLuma flat-naming addons above.
const ffmpegAddonFile = `ffmpegAddon.${targetPlatform}.${targetArch}.node`;
const ffmpegSrcDir = path.resolve(nativeDir, 'ffmpeg');
// Fail fast if any expected native binary is missing from packages/runtime/native/.
// vite-plugin-cp only emits a warning on missing source files; we want a hard
// error so CI can't accidentally ship an incomplete archive.
const missingNatives = nativeFiles.filter(
(f) => !fs.existsSync(path.join(nativeDir, f)),
);
if (!fs.existsSync(path.join(ffmpegSrcDir, ffmpegAddonFile))) {
missingNatives.push(`ffmpeg/${ffmpegAddonFile}`);
}
if (missingNatives.length > 0) {
throw new Error(
`Missing native binaries for target ${targetTriple}:\n` +
@@ -73,6 +83,11 @@ const BaseConfigPlugin: PluginOption[] = [
dest: path.join(distDir, 'native'),
flatten: true,
})),
{
src: `${toPosix(ffmpegSrcDir)}/${ffmpegAddonFile}`,
dest: path.join(distDir, 'native', 'ffmpeg'),
flatten: true,
},
]
})
];