@meframe/server 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -153,6 +153,10 @@
153
153
  - 必须在目标机型与微信版本上做真实验证。
154
154
  - 若验证失败,建议由上层应用接入可选后处理(例如 remux 成 moov 前置 MP4),而不是把 ffmpeg 作为 `@meframe/server` 的强依赖。
155
155
 
156
+ 补充:Linux 上 WebCodecs AAC 编码不可用时的“video-only 降级 + FFmpeg 后处理混音”的背景与完整方案见:
157
+
158
+ - `packages/server/docs/AUDIO_MIX_FFMPEG_PLAN.md`
159
+
156
160
  ### 10. 实现顺序建议(可按里程碑推进)
157
161
 
158
162
  - **M1:core 增加流式 mux 输出模式**(保留 Blob 模式,确保回归风险可控)。
@@ -0,0 +1,145 @@
1
+ ## Linux 导出 MP4 无音频:背景与 FFmpeg 后处理方案(规划)
2
+
3
+ ### 1. 背景:为什么会导出“无声 MP4”
4
+
5
+ `@meframe/server` 的导出本质是在 **真实 Chromium** 中运行 `@meframe/core` 的浏览器导出链路(WebAudio 混音 + WebCodecs 编码 + MP4 mux)。
6
+
7
+ 在部分 Linux 环境(即使使用 `google-chrome-stable`),WebCodecs 的 **AAC 音频编码**可能不可用:
8
+
9
+ - `AudioEncoder.isConfigSupported({ codec: 'mp4a.40.2', ... })` 返回 `supported: false`
10
+ - `@meframe/core` 在创建音频编码器(AAC)时会失败
11
+
12
+ 为了避免任务直接失败、至少产出可播放的视频文件,`@meframe/core` 已实现**能力探测与降级**:
13
+
14
+ - AAC 支持不足时:导出 **video-only MP4**(不创建音轨、不启动音频编码)
15
+ - 并通过导出进度 message 给出原因(可观测)
16
+
17
+ 这解决了 “Linux 导出直接报错” 的大问题,但也带来一个必然结果:**导出 MP4 没有声音**。
18
+
19
+ ### 2. 为什么不能简单“把源视频的音轨 copy 回去”
20
+
21
+ 在业务真实使用里,音频通常来自**时间轴混音**,而不是单一源视频的原声。
22
+
23
+ `packages/server/examples/local/video-model.json` 展示了典型结构:
24
+
25
+ - `tracks[].kind === 'audio'`:
26
+ - `speech`:多个语音片段(多段 mp3),每段有 `startUs` / `durationUs` / `audioConfig.volume`
27
+ - `bgm`:通常为覆盖全片的背景音乐(单段 mp3),有 `audioConfig.volume`
28
+ - `tracks[].kind === 'video'` 的每个 clip 也可能带 `audioConfig`:这代表**视频原声是否参与混音**(需求侧需明确,一般是“要”)
29
+
30
+ 因此,“直接复用某一个源文件音轨”无法得到正确结果。
31
+
32
+ ### 3. 目标:在 Linux 环境产出“含混音音频的 MP4(H.264 + AAC)”
33
+
34
+ 目标产物:
35
+
36
+ - 容器:MP4(移动端原生播放器覆盖率最高)
37
+ - 视频:保持 `@meframe/core` 输出的 H.264(`-c:v copy`,避免二次转码)
38
+ - 音频:服务端用 FFmpeg 生成混音音轨并编码 AAC(`-c:a aac`)
39
+
40
+ ### 4. 总体方案:Core 降级 + Server 后处理(FFmpeg)
41
+
42
+ #### 4.1 Core 行为(已完成)
43
+
44
+ - 若 WebCodecs AAC 不支持:导出 video-only MP4(仍可流式 multipart upload)
45
+
46
+ #### 4.2 Server 后处理(规划)
47
+
48
+ 当检测到导出为 video-only(或业务强制要求音频)时,在 `@meframe/server` 增加一个“可选后处理”步骤:
49
+
50
+ 1. 从 store 获取已上传的 **video-only MP4** 的可下载 URL(通常是 presigned GET URL)
51
+ 2. 从 `video-model.json` 中解析所有参与混音的音频来源:
52
+ - speech track clips(多个资源)
53
+ - bgm track clips(可能是 loop/长段)
54
+ - video track clips 的原声(从对应视频资源 URL 提取音频)
55
+ 3. 生成 FFmpeg `-filter_complex`:
56
+ - 对每个 clip 执行:
57
+ - `atrim`:按 `trimStartUs`/`durationUs` 截取
58
+ - `adelay`:按 `startUs` 对齐到时间轴
59
+ - `volume`:按 `audioConfig.volume` 调整
60
+ - muted:直接跳过
61
+ - 把所有 clip 进行 `amix` 叠加为单一混音流
62
+ 4. 将混音流编码为 AAC,并与 video-only MP4 mux 成最终 MP4:
63
+ - 视频:`-map 0:v:0 -c:v copy`
64
+ - 音频:`-map [mixed] -c:a aac -b:a 128k`(比特率可配置)
65
+ 5. 将最终 MP4 上传回 store(覆盖原 key 或写入新 key)
66
+
67
+ > 注意:此方案把“混音 + AAC 编码”的工作完全交给 server/FFmpeg,绕过 WebCodecs AAC 的平台差异。
68
+
69
+ ### 5. FFmpeg filtergraph 生成规则(从 model 到命令)
70
+
71
+ #### 5.1 输入实体
72
+
73
+ - `model.durationUs`
74
+ - `model.tracks[]`:
75
+ - `track.kind in ['audio', 'video']`
76
+ - `clip.startUs`, `clip.durationUs`, `clip.trimStartUs?`
77
+ - `clip.audioConfig.muted`, `clip.audioConfig.volume`
78
+ - `model.resources[resourceId].uri`
79
+
80
+ #### 5.2 clip 处理(概念)
81
+
82
+ 对每个 clip 生成一个音频分支:
83
+
84
+ - **选择输入**:
85
+ - speech/bgm clip:输入为对应 `resources[resourceId].uri`(音频文件)
86
+ - video clip 原声:输入为对应视频 `resources[resourceId].uri`,取其音轨
87
+ - **裁剪**:
88
+ - `trimStartUs` 用 `-ss` 或 `atrim=start=...`
89
+ - `durationUs` 用 `-t` 或 `atrim=duration=...`
90
+ - **对齐**:
91
+ - `adelay=${startMs}|${startMs}`(双声道示例)
92
+ - **音量**:
93
+ - `volume=${volume}`
94
+
95
+ 最后:
96
+
97
+ - `amix=inputs=N:normalize=0` 叠加所有分支
98
+ - 视需求可加 `alimiter` 避免削波
99
+
100
+ #### 5.3 时间单位换算
101
+
102
+ - `startUs` / `durationUs` / `trimStartUs`:microseconds
103
+ - `adelay`:milliseconds
104
+ - `atrim`:seconds(可用小数)
105
+
106
+ ### 6. 资源 URL 读取
107
+
108
+ FFmpeg 可以直接读取 `https://...` URL(需要 server 侧网络可达)。
109
+
110
+ 约束/注意:
111
+
112
+ - 若资源需要鉴权 header(例如 token),FFmpeg 需通过 `-headers` 传入;因此 “resolveResourceUrl” 应支持返回 headers。
113
+ - 若资源服务端不允许被 FFmpeg 直连(内网限制、短时签名、SNI/TLS 限制),则需要先下载到本地临时文件再处理(这会改变内存/磁盘策略)。
114
+
115
+ ### 7. 与 @meframe/server 的集成点(建议设计)
116
+
117
+ 当前 `@meframe/server` 的对外接口 `ServerExporterOptions` 只有 multipart store、浏览器配置、进度回调等。
118
+
119
+ 建议保持默认行为不变,新增一个**可选**后处理开关(规划):
120
+
121
+ - `postProcess.ffmpegAudioMix.enabled`
122
+ - `postProcess.ffmpegAudioMix.resolveUploadedUrl(key)`:获取导出后 video-only MP4 的可下载 URL(presigned GET)
123
+ - `postProcess.ffmpegAudioMix.outputKey`:最终 MP4 输出 key(可覆盖原 key 或写新 key)
124
+ - `ffmpegPath` / `audioBitrate` 等可配
125
+
126
+ 实现上,后处理可放在:
127
+
128
+ - `ServerExporterBase.exportToStore()` 完成 multipart upload 之后、返回结果之前
129
+
130
+ ### 8. 可观测性与失败策略
131
+
132
+ - 导出阶段:
133
+ - 若 core 降级为 video-only,应通过 progress message 记录原因(已在 core 侧实现)
134
+ - 后处理阶段(FFmpeg):
135
+ - 应单独上报阶段(例如 `stage: 'postprocess'`,`progress` 可粗粒度)
136
+ - FFmpeg 失败时:
137
+ - 可配置策略:失败则返回无声视频(保持可用)或失败则 job 失败(更严格)
138
+
139
+ ### 9. 为什么“不建议先导出 WebM 再转 MP4”
140
+
141
+ 从架构复杂度与成本看:
142
+
143
+ - `@meframe/core` 当前没有 WebM mux 实现(需要新增 WebM mux 与相关测试)
144
+ - WebM 常见需要 VP8/VP9/AV1 + Opus;转 MP4 通常意味着**视频也要重编码**(画质/性能代价大)
145
+ - 而本方案可以 **视频 copy**,只让 FFmpeg 处理音频,整体更可控、改动更小
@@ -18,6 +18,10 @@
18
18
 
19
19
  其他(draft 获取方式、存储 SDK、配置系统、队列/超时/重试)都由上层应用自行决定。
20
20
 
21
+ > 可选但强烈建议(生产环境常见需求):
22
+ >
23
+ > - **FFmpeg(用于音频后处理兜底)**:当浏览器运行时不支持 AAC 编码时,`@meframe/core` 会跳过音频导出;此时可由 `@meframe/server` 触发 FFmpeg 后处理,把音频混合并 remux 回 MP4。
24
+
21
25
  ### 1. 安装依赖
22
26
 
23
27
  ```bash
@@ -56,6 +60,16 @@ WebCodecs 等 API 需要 secure/trustworthy origin。`exportToStore({ pageUrl })
56
60
 
57
61
  > 核心原则:`CompositionModelData.resources[*].uri`、`workerPath` 指向的文件,都必须能在 Chromium 里直接 `fetch()`/加载到。
58
62
 
63
+ #### 2.4 音频(AAC)能力说明(强烈建议阅读)
64
+
65
+ 当前 `mp4` 输出的音频轨 **只能是 AAC**(对应 WebCodecs `AudioEncoder` 的 `codec: 'mp4a.40.2'`)。
66
+
67
+ - 在某些 Linux Chrome/Chromium 运行时(尤其是容器/精简发行版),**可能存在 `AudioEncoder` 可用但 AAC encode 不支持** 的情况。
68
+ - 这会导致 `@meframe/core` 在导出阶段 emit 一条包含 `Audio skipped:` 的 progress message,并继续导出 **video-only MP4**。
69
+ - 如果你的业务 **必须输出带音频的 MP4**,建议启用 `@meframe/server` 的 **FFmpeg 音频后处理兜底**(见下文 5.x)。
70
+
71
+ > 重要:这是运行时能力差异,不是业务代码“参数写错”。在不支持 AAC encode 的环境里,单纯升/降 Chrome 版本通常不可靠;生产上更稳的是启用 FFmpeg 兜底。
72
+
59
73
  ### 3. 用 `@meframe/adapter-medeo`:Draft → `CompositionModelData`
60
74
 
61
75
  `@meframe/server` 不关心你的业务数据长什么样,它只吃 `CompositionModelData`。
@@ -345,6 +359,41 @@ export async function exportMedeoDraftToMp4(input: {
345
359
  partSizeBytes: 16 * 1024 * 1024,
346
360
  contentType: 'video/mp4',
347
361
  },
362
+ // 可选:FFmpeg 音频后处理兜底(建议生产开启,尤其是 Linux 运行时)
363
+ //
364
+ // 触发条件:
365
+ // - 浏览器不支持 AAC encode → @meframe/core 会 emit `Audio skipped: ...`
366
+ // - @meframe/server 捕获到该 message 后,在 MP4 已上传完成的基础上运行 FFmpeg
367
+ //
368
+ // 返回值/时序:
369
+ // - 默认会等待后处理完成后才 resolve 并返回最终 key
370
+ // - 如果后处理失败:
371
+ // - onFailure='fallback'(默认):返回原始 video-only MP4 的 key
372
+ // - onFailure='throw':直接 fail 整个任务
373
+ //
374
+ // 依赖:
375
+ // - 运行环境需要可执行的 ffmpeg/ffprobe(默认从 PATH 解析)
376
+ postProcess: {
377
+ ffmpegAudioMix: {
378
+ enabled: true,
379
+ // 可选:覆盖输出 key(默认覆盖原 key)
380
+ // outputKey: outputKey.replace(/\.mp4$/, '.with-audio.mp4'),
381
+ // 可选:失败策略(默认 fallback)
382
+ // onFailure: 'fallback',
383
+ // 可选:ffmpeg/ffprobe 路径
384
+ // ffmpegPath: 'ffmpeg',
385
+ // ffprobePath: 'ffprobe',
386
+ // 必填:提供一种方式让 FFmpeg 能“读回”刚上传的 video-only MP4
387
+ // - 推荐:返回一个可直连的 GET URL(例如 S3 presigned GET)
388
+ // - 或者:如果你的存储可以落盘,返回本地 filePath
389
+ resolveUploadedInput: async ({ key }) => {
390
+ // 示例(伪代码):生成一个供 FFmpeg 使用的 presigned GET URL
391
+ // const url = await presignGetObjectUrl({ key, expiresInSeconds: 3600 });
392
+ // return { kind: 'url', url };
393
+ throw new Error(`resolveUploadedInput is not implemented for key: ${key}`);
394
+ },
395
+ },
396
+ },
348
397
  onProgress: (evt) => {
349
398
  // evt: { jobId, progress, stage, timeUs? }
350
399
  // 这里建议你把进度转发到任务系统/日志/指标
@@ -366,6 +415,23 @@ export async function exportMedeoDraftToMp4(input: {
366
415
  }
367
416
  ```
368
417
 
418
+ #### 5.1 `resolveUploadedInput` 的设计建议(生产强相关)
419
+
420
+ `ffmpegAudioMix.resolveUploadedInput()` 的目标是:**让 FFmpeg/FFprobe 能读取到已经上传完成的 video-only MP4**。
421
+
422
+ 推荐两种实现:
423
+
424
+ - **URL 方式(推荐)**:返回 `{ kind: 'url', url, headers? }`
425
+ - 典型实现:对象存储的 presigned GET URL(必要时附带 headers)
426
+ - 优点:不依赖本地磁盘、适合多机/容器部署
427
+ - **文件方式**:返回 `{ kind: 'file', filePath }`
428
+ - 适合你本来就会把导出结果落盘的场景
429
+
430
+ 注意:
431
+
432
+ - 这是 Node 侧的读取路径,与 runner 的 multipart PUT 上传是两条独立链路
433
+ - 对 URL 方式,确保运行环境网络可达,且必要时带上鉴权 headers
434
+
369
435
  ### 6. 取消与超时(建议)
370
436
 
371
437
  `exportToStore` 支持 `AbortSignal`(best-effort):
@@ -400,3 +466,16 @@ multipart 上传必须满足:
400
466
  - 每个 part 都完整、连续覆盖;runner 已做 range 覆盖校验
401
467
  - `ETag` 必须正确回传给 Node 并用于 complete
402
468
  - 对象存储端的 `CompleteMultipartUpload` parts 顺序必须正确(按 partNumber)
469
+
470
+ #### 7.4 “导出成功但没声音”(Linux 上常见)
471
+
472
+ 如果你看到进度里出现类似 message:
473
+
474
+ - `Audio skipped: WebCodecs AAC (mp4a.40.2) AudioEncoder is not supported in this browser/runtime.`
475
+
476
+ 那么这是运行时不支持 AAC encode 导致的正常行为,结果会是 **video-only MP4**。
477
+
478
+ 解决建议:
479
+
480
+ - **推荐**:启用 `postProcess.ffmpegAudioMix`,用 FFmpeg 生成 AAC 并 remux(见 5.x)
481
+ - 如果你不想引入 FFmpeg:只能更换运行时/镜像/发行版,确保 WebCodecs AAC encode 可用(不建议依赖“碰巧某个版本可用”)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meframe/server",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "Server-side exporter for @meframe/core (browser-driven, multipart upload via injected store)",
5
5
  "type": "module",
6
6
  "main": "./dist/esm/index.js",
@@ -20,7 +20,7 @@
20
20
  ],
21
21
  "dependencies": {
22
22
  "puppeteer-core": "^23.5.0",
23
- "@meframe/core": "0.2.9"
23
+ "@meframe/core": "0.3.1"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@aws-sdk/client-s3": "^3.758.0",
@@ -30,7 +30,7 @@
30
30
  "vite": "^5.4.20",
31
31
  "typescript": "^5.3.3",
32
32
  "vitest": "^1.3.1",
33
- "@meframe/adapter-medeo": "0.0.2"
33
+ "@meframe/adapter-medeo": "0.0.5"
34
34
  },
35
35
  "engines": {
36
36
  "node": ">=18.0.0"