mediac 1.7.5 → 1.7.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.
package/cmd/cmd_ffmpeg.js CHANGED
@@ -8,37 +8,34 @@
8
8
  import chalk from 'chalk'
9
9
  import dayjs from 'dayjs'
10
10
  import { execa } from 'execa'
11
- import { fileTypeFromFile } from 'file-type'
12
11
  import fs from 'fs-extra'
13
12
  import iconv from "iconv-lite"
14
13
  import inquirer from "inquirer"
15
14
  import mm from 'music-metadata'
16
15
  import { cpus } from "os"
17
16
  import pMap from 'p-map'
18
- import path, { format } from "path"
17
+ import path from "path"
19
18
  import which from "which"
20
19
  import * as core from '../lib/core.js'
21
20
  import { asyncFilter, formatArgs } from '../lib/core.js'
22
21
  import * as log from '../lib/debug.js'
23
22
  import * as enc from '../lib/encoding.js'
23
+ import presets from '../lib/ffmpeg_presets.js'
24
24
  import { getMediaInfo } from '../lib/ffprobe.js'
25
25
  import * as mf from '../lib/file.js'
26
26
  import * as helper from '../lib/helper.js'
27
- import { applyFileNameRules } from './cmd_shared.js'
27
+ import { FFMPEG_BINARY } from '../lib/shared.js'
28
+ import { addEntryProps, applyFileNameRules } from './cmd_shared.js'
28
29
 
29
30
  const LOG_TAG = "FFConv"
30
-
31
- const PRESET_NAMES = []
32
- const PRESET_MAP = new Map()
33
-
34
31
  // ===========================================
35
32
  // 命令内容执行
36
33
  // ===========================================
37
34
 
38
35
 
39
36
  export { aliases, builder, command, describe, handler }
40
-
41
- const command = "ffmpeg <input> [options]"
37
+ // directories 表示额外输入文件,用于支持多个目录
38
+ const command = "ffmpeg <input> [directories...]"
42
39
  const aliases = ["transcode", "aconv", "vconv", "avconv"]
43
40
  const describe = 'convert audio or video files using ffmpeg.'
44
41
 
@@ -55,12 +52,17 @@ const builder = function addOptions(ya, helpOrVersionSet) {
55
52
  describe: "Folder store ouput files",
56
53
  type: "string",
57
54
  })
55
+ // 复杂字符串参数,单独解析
56
+ .option("params", {
57
+ describe: "complex combined string parameters for parse",
58
+ type: "string",
59
+ })
58
60
  // 保持源文件目录结构
59
61
  .option("output-tree", {
60
62
  alias: 'otree',
61
63
  describe: "keep folder tree structure in output folder",
62
64
  type: "boolean",
63
- default: false,
65
+ default: true,
64
66
  })
65
67
  // 正则,包含文件名规则
66
68
  .option("include", {
@@ -70,17 +72,18 @@ const builder = function addOptions(ya, helpOrVersionSet) {
70
72
  })
71
73
  //字符串或正则,不包含文件名规则
72
74
  // 如果是正则的话需要转义
73
- // 默认排除 [SHANA] 开头的文件
75
+ // 默认排除含shana的文件和.m4a文件
74
76
  .option("exclude", {
75
77
  alias: "E",
76
78
  type: "string",
77
- default: '[SHANA]',
79
+ default: 'shana|.m4a',
78
80
  description: "filename exclude pattern ",
79
81
  })
80
- // 默认使用字符串模式,可启用正则模式
82
+ // 默认启用正则模式,禁用则为字符串模式
81
83
  .option("regex", {
82
84
  alias: 're',
83
85
  type: "boolean",
86
+ default: true,
84
87
  description: "match filenames by regex pattern",
85
88
  })
86
89
  // 需要处理的扩展名列表,默认为常见视频文件
@@ -92,7 +95,7 @@ const builder = function addOptions(ya, helpOrVersionSet) {
92
95
  // 选择预设,从预设列表中选一个,预设等于一堆预定义参数
93
96
  .option("preset", {
94
97
  type: "choices",
95
- choices: PRESET_NAMES,
98
+ choices: presets.getAllNames(),
96
99
  default: 'hevc_2k',
97
100
  describe: "convert preset args for ffmpeg command",
98
101
  })
@@ -130,10 +133,17 @@ const builder = function addOptions(ya, helpOrVersionSet) {
130
133
  default: 0,
131
134
  describe: "chang max side for video",
132
135
  })
136
+ // 视频帧率,FPS
137
+ .option("fps", {
138
+ alias: 'framerate',
139
+ type: "number",
140
+ default: 0,
141
+ describe: "output framerate value",
142
+ })
133
143
  // 视频加速减速,默认不改动,范围0.25-4.0
134
144
  .option("speed", {
135
145
  type: "number",
136
- default: 1,
146
+ default: 0,
137
147
  describe: "chang speed for video and audio",
138
148
  })
139
149
  // 视频选项
@@ -198,6 +208,12 @@ const builder = function addOptions(ya, helpOrVersionSet) {
198
208
  describe: "Write error logs to file [json or text]",
199
209
  type: "string",
200
210
  })
211
+ // 硬件加速方式
212
+ .option("hwaccel", {
213
+ alias: "hw",
214
+ describe: "hardware acceleration for video decode and encode",
215
+ type: "string",
216
+ })
201
217
  // 并行操作限制,并发数,默认为 CPU 核心数
202
218
  .option("jobs", {
203
219
  alias: "j",
@@ -213,13 +229,14 @@ const builder = function addOptions(ya, helpOrVersionSet) {
213
229
  })
214
230
  }
215
231
 
232
+ //todo 实现一种更方便的传参数的方法,比如逗号分割 -preset ab=128,aq=3,ac=aac,ap=aac_he;vb=1536,vq=23,vc=hevc_nvenc,vs=1280*720,vw=1280,vh=720;vf=xxx,cf=xxx,
216
233
 
217
234
  const handler = cmdConvert
218
235
 
219
236
  async function cmdConvert(argv) {
220
237
  // 显示预设列表
221
238
  if (argv.showPresets) {
222
- for (const [key, value] of PRESET_MAP) {
239
+ for (const [key, value] of presets.getAllPresets()) {
223
240
  log.show(key, core.pickTrueValues(value))
224
241
  }
225
242
  return
@@ -240,14 +257,31 @@ async function cmdConvert(argv) {
240
257
  log.fileLog(`Preset: ${JSON.stringify(preset)}`, 'FFConv')
241
258
  }
242
259
  // 首先找到所有的视频和音频文件
243
- let fileEntries = await mf.walk(root, {
260
+ const walkOpts = {
244
261
  withFiles: true,
245
262
  needStats: true,
246
263
  entryFilter: (e) => e.isFile && helper.isMediaFile(e.name)
247
- })
264
+ }
265
+ let fileEntries = await mf.walk(root, walkOpts)
266
+ // 处理额外目录参数
267
+ if (argv.directories?.length > 0) {
268
+ const extraDirs = new Set(argv.directories.map(d => path.resolve(d)))
269
+ for (const dirPath of extraDirs) {
270
+ const st = await fs.stat(dirPath)
271
+ if (st.isDirectory()) {
272
+ const dirFiles = await mf.walk(dirPath, walkOpts)
273
+ if (dirFiles.length > 0) {
274
+ log.show(logTag, `Add ${dirFiles.length} extra files from ${dirPath}`)
275
+ fileEntries = fileEntries.concat(dirFiles)
276
+ }
277
+ }
278
+ }
279
+ }
280
+ // 根据完整路径去重
281
+ fileEntries = core.uniqueByFields(fileEntries, 'path')
248
282
  log.show(logTag, `Total ${fileEntries.length} files found [${preset.name}] (${helper.humanTime(startMs)})`)
249
283
  // 再根据preset过滤找到的文件
250
- if (preset.type === 'video' || preset.name === PRESET_AUDIO_EXTRACT.name) {
284
+ if (preset.type === 'video' || presets.isAudioExtract(preset)) {
251
285
  // 视频转换模式,保留视频文件
252
286
  // 提取音频模式,保留视频文件
253
287
  fileEntries = fileEntries.filter(e => helper.isVideoFile(e.name))
@@ -259,7 +293,7 @@ async function cmdConvert(argv) {
259
293
  // 应用文件名过滤规则
260
294
  fileEntries = await applyFileNameRules(fileEntries, argv)
261
295
  // 提取音频时不判断文件名
262
- if (!preset.name === PRESET_AUDIO_EXTRACT.name) {
296
+ if (!presets.isAudioExtract(preset)) {
263
297
  // 过滤掉压缩过的文件 关键词 shana tmp m4a
264
298
  fileEntries = fileEntries.filter(entry => !/tmp|\.m4a/i.test(entry.name))
265
299
  }
@@ -285,14 +319,15 @@ async function cmdConvert(argv) {
285
319
  }
286
320
  }
287
321
  startMs = Date.now()
288
- fileEntries = fileEntries.map((f, i) => {
322
+ addEntryProps(fileEntries)
323
+ fileEntries = fileEntries.map((entry, index) => {
289
324
  return {
290
- ...f,
325
+ ...entry,
291
326
  argv,
292
327
  preset,
293
- startMs: startMs,
294
- index: i,
295
- total: fileEntries.length,
328
+ // startMs: startMs,
329
+ // index: index,
330
+ // total: fileEntries.length,
296
331
  errorFile: argv.errorFile,
297
332
  testMode: testMode
298
333
  }
@@ -348,11 +383,7 @@ async function cmdConvert(argv) {
348
383
  }
349
384
  // 记录开始时间
350
385
  startMs = Date.now()
351
- tasks.forEach((t, i) => {
352
- t.startMs = startMs
353
- t.index = i
354
- t.total = tasks.length
355
- })
386
+ addEntryProps(tasks)
356
387
  // 先写入一次LOG
357
388
  await log.flushFileLog()
358
389
  // 并发数视频1,音频4,或者参数指定
@@ -368,25 +399,25 @@ function fixEncoding(str = '') {
368
399
  }
369
400
 
370
401
  async function runFFmpegCmd(entry) {
371
- const ipx = `${entry.index}/${entry.total}`
402
+ const ipx = `${entry.index + 1}/${entry.total}`
372
403
  const logTag = chalk.green('FFCMD')
373
404
  const ffmpegArgs = entry.ffmpegArgs
374
405
 
375
- log.show(logTag, `(${ipx}) Processing ${helper.pathShort(entry.path, 72)}`, helper.humanTime(entry.startMs))
406
+ log.show(logTag, `${ipx} Processing ${helper.pathShort(entry.path, 72)}`, helper.humanSize(entry.size), chalk.yellow(getDurationInfo(entry)), helper.humanTime(entry.startMs))
376
407
  log.showGray(logTag, getEntryShowInfo(entry), chalk.yellow(entry.preset.name), helper.humanSize(entry.size))
377
408
 
378
-
379
409
  log.showGray(logTag, 'ffmpeg', createFFmpegArgs(entry, true).join(' '))
380
- const exePath = await which("ffmpeg")
410
+ const exePath = await which(FFMPEG_BINARY)
381
411
  if (entry.testMode) {
382
412
  // 测试模式跳过
383
- log.show(logTag, `(${ipx}) Skipped ${entry.path} (${helper.humanSize(entry.size)}) [TestMode]`)
413
+ log.show(logTag, `${ipx} Skipped ${entry.path} (${helper.humanSize(entry.size)}) [TestMode]`)
384
414
  return
385
415
  }
386
416
 
387
417
  // 创建输出目录
388
418
  await fs.mkdirp(entry.fileDstDir)
389
419
  await fs.remove(entry.fileDstTemp)
420
+ const ffmpegStartMs = Date.now()
390
421
  try {
391
422
  // https://2ality.com/2022/07/nodejs-child-process.html
392
423
  // Windows下 { shell: true } 必须,否则报错
@@ -396,22 +427,27 @@ async function runFFmpegCmd(entry) {
396
427
  const { stdout, stderr } = await ffmpegProcess
397
428
  // const stdoutFixed = fixEncoding(stdout || "")
398
429
  // const stderrFixed = fixEncoding(stderr || "")
430
+ if (await fs.pathExists(entry.fileDst)) {
431
+ log.showYellow(logTag, `${ipx} DstExists ${entry.fileDst}`, helper.humanSize(entry.size), chalk.yellow(entry.preset.name), helper.humanTime(ffmpegStartMs))
432
+ await fs.remove(entry.fileDstTemp)
433
+ return
434
+ }
399
435
  if (await fs.pathExists(entry.fileDstTemp)) {
400
436
  const dstSize = (await fs.stat(entry.fileDstTemp))?.size || 0
401
437
  if (dstSize > 20 * mf.FILE_SIZE_1K) {
402
438
  await fs.move(entry.fileDstTemp, entry.fileDst)
403
- log.showGreen(logTag, `(${ipx}) Done ${entry.fileDst}`, helper.humanSize(entry.size), chalk.cyan(`${Math.round(entry.srcAudioBitrate / 1024)}k:${Math.round(entry.srcVideoBitrate / 1024)}k`), chalk.yellow(entry.preset.name), helper.humanTime(entry.startMs))
404
- log.fileLog(`(${ipx}) Done <${entry.fileDst}> [${entry.preset.name}] (${helper.humanSize(dstSize)})`, 'FFCMD')
439
+ log.showGreen(logTag, `${ipx} Done ${entry.fileDst}`, chalk.cyan(`${helper.humanSize(entry.size)}=>${helper.humanSize(dstSize)}`), chalk.yellow(entry.preset.name), helper.humanTime(ffmpegStartMs))
440
+ log.fileLog(`${ipx} Done <${entry.fileDst}> [${entry.preset.name}] (${helper.humanSize(dstSize)})`, 'FFCMD')
405
441
  entry.ok = true
406
442
  return entry
407
443
  } else {
408
444
  // 转换失败,删除临时文件
409
445
  }
410
446
  }
411
- log.showYellow(logTag, `Failed(${ipx}) ${entry.path}`, entry.preset.name, helper.humanSize(dstSize))
412
- log.fileLog(`Failed(${ipx}) <${entry.path}> [${entry.dstAudioBitrate || entry.preset.name}]`, 'FFCMD')
447
+ log.showYellow(logTag, `${ipx} Failed ${entry.path}`, entry.preset.name, helper.humanSize(dstSize))
448
+ log.fileLog(`${ipx} Failed <${entry.path}> [${entry.dstAudioBitrate || entry.preset.name}]`, 'FFCMD')
413
449
  } catch (error) {
414
- const errMsg = error.stderr || error.message?.substring(0, 240) || '[ERROR]'
450
+ const errMsg = (error.stderr || error.message || '[Unknown]').substring(0, 240)
415
451
  log.showRed(logTag, `Error(${ipx}) ${errMsg}`)
416
452
  log.fileLog(`Error(${ipx}) <${entry.path}> [${entry.preset.name}] ${errMsg}`, 'FFCMD')
417
453
  await writeErrorFile(entry, error)
@@ -440,7 +476,7 @@ async function writeErrorFile(entry, error) {
440
476
 
441
477
  async function prepareFFmpegCmd(entry) {
442
478
  const logTag = chalk.green('Prepare')
443
- const ipx = `${entry.index}/${entry.total}`
479
+ const ipx = `${entry.index + 1}/${entry.total}`
444
480
  log.info(logTag, `Processing(${ipx}) file: ${entry.path}`)
445
481
  const isAudio = helper.isAudioFile(entry.path)
446
482
  const [srcDir, srcBase, srcExt] = helper.pathSplit(entry.path)
@@ -449,9 +485,9 @@ async function prepareFFmpegCmd(entry) {
449
485
  const dstExt = preset.format || srcExt
450
486
  let fileDstDir
451
487
  // 命令行参数指定输出目录
452
- if (preset.output) {
488
+ if (argv.output) {
453
489
  // 默认true 保留目录结构,可以防止文件名冲突
454
- if (argv.ouputTree) {
490
+ if (argv.outputTree) {
455
491
  // 如果要保持源文件目录结构
456
492
  fileDstDir = helper.pathRewrite(srcDir, preset.output)
457
493
  } else {
@@ -462,25 +498,25 @@ async function prepareFFmpegCmd(entry) {
462
498
  // 如果没有指定输出目录,直接输出在原文件同目录
463
499
  fileDstDir = path.resolve(srcDir)
464
500
  }
465
- if (isAudio || preset.name === PRESET_AUDIO_EXTRACT.name) {
501
+ if (isAudio || presets.isAudioExtract(preset)) {
466
502
  // 不带后缀只改扩展名的m4a文件,如果存在也需要首先忽略
467
503
  // 可能是其它压缩工具生成的文件,不需要重复压缩
468
504
  // 检查输出目录
469
- const fileDstNoSuffix = path.join(fileDstDir, `${srcBase}${dstExt}`)
470
- if (await fs.pathExists(fileDstNoSuffix)) {
471
- log.info(
472
- logTag,
473
- `(${ipx}) Skip[DstOut]: ${helper.pathShort(entry.path)} (${helper.humanSize(entry.size)})`)
474
- return false
475
- }
505
+ // const fileDstNoSuffix = path.join(fileDstDir, `${srcBase}${dstExt}`)
506
+ // if (await fs.pathExists(fileDstNoSuffix)) {
507
+ // log.info(
508
+ // logTag,
509
+ // `${ipx} Skip[DstM4A]: ${helper.pathShort(entry.path)} (${helper.humanSize(entry.size)})`)
510
+ // return false
511
+ // }
476
512
  // 检查源文件同目录
477
- const fileDstSameDirNoSuffix = path.join(srcDir, `${srcBase}${dstExt}`)
478
- if (await fs.pathExists(fileDstSameDirNoSuffix)) {
479
- log.info(
480
- logTag,
481
- `(${ipx}) Skip[DstSame]: ${helper.pathShort(entry.path)} (${helper.humanSize(entry.size)})`)
482
- return false
483
- }
513
+ // const fileDstSameDirNoSuffix = path.join(srcDir, `${srcBase}${dstExt}`)
514
+ // if (await fs.pathExists(fileDstSameDirNoSuffix)) {
515
+ // log.info(
516
+ // logTag,
517
+ // `${ipx} Skip[DstSame]: ${helper.pathShort(entry.path)} (${helper.humanSize(entry.size)})`)
518
+ // return false
519
+ // }
484
520
  }
485
521
  try {
486
522
  // 使用ffprobe读取媒体信息,速度较慢
@@ -489,8 +525,8 @@ async function prepareFFmpegCmd(entry) {
489
525
 
490
526
  // ffprobe无法读取时长和比特率,可以认为文件损坏,或不支持的格式,跳过
491
527
  if (!(entry.info?.format?.duration || entry.info?.format?.bit_rate)) {
492
- log.showYellow('Prepare', `(${ipx}) Skip[Corrupted]: ${entry.path} (${helper.humanSize(entry.size)})`)
493
- log.fileLog(`(${ipx}) Skip[Corrupted]: <${entry.path}> (${helper.humanSize(entry.size)})`, 'Prepare')
528
+ log.showYellow(logTag, `${ipx} Skip[Corrupted]: ${entry.path} (${helper.humanSize(entry.size)})`)
529
+ log.fileLog(`${ipx} Skip[Corrupted]: <${entry.path}> (${helper.humanSize(entry.size)})`, 'Prepare')
494
530
  return false
495
531
  }
496
532
  const audioCodec = entry.info?.audio?.codec_name
@@ -508,8 +544,8 @@ async function prepareFFmpegCmd(entry) {
508
544
  // 可以读取码率,文件未损坏
509
545
  } else {
510
546
  // 如果无法获取元数据,认为不是合法的音频或视频文件,忽略
511
- log.showYellow('Prepare', `(${ipx}) Skip[Invalid]: ${entry.path} (${helper.humanSize(entry.size)})`)
512
- log.fileLog(`(${ipx}) Skip[Invalid]: <${entry.path}> (${helper.humanSize(entry.size)})`, 'Prepare')
547
+ log.showYellow(logTag, `${ipx} Skip[Invalid]: ${entry.path} (${helper.humanSize(entry.size)})`)
548
+ log.fileLog(`${ipx} Skip[Invalid]: <${entry.path}> (${helper.humanSize(entry.size)})`, 'Prepare')
513
549
  return false
514
550
  }
515
551
  }
@@ -523,14 +559,14 @@ async function prepareFFmpegCmd(entry) {
523
559
  log.info(logTag, entry.path, mediaBitrate)
524
560
  // 如果转换目标是音频,但是源文件不含音频流,忽略
525
561
  if (entry.preset.type === 'audio' && !audioCodec) {
526
- log.showYellow('Prepare', `(${ipx}) Skip[NoAudio]: ${entry.path}`, getEntryShowInfo(entry), helper.humanSize(entry.size))
527
- log.fileLog(`(${ipx}) Skip[NoAudio]: <${entry.path}> (${helper.humanSize(entry.size)})`, 'Prepare')
562
+ log.showYellow(logTag, `${ipx} Skip[NoAudio]: ${entry.path}`, getEntryShowInfo(entry), helper.humanSize(entry.size))
563
+ log.fileLog(`${ipx} Skip[NoAudio]: <${entry.path}> (${helper.humanSize(entry.size)})`, 'Prepare')
528
564
  return false
529
565
  }
530
566
  // 如果转换目标是视频,但是源文件不含视频流,忽略
531
567
  if (entry.preset.type === 'video' && !videoCodec) {
532
- log.showYellow('Prepare', `(${ipx}) Skip[NoVideo]: ${entry.path}`, getEntryShowInfo(entry), helper.humanSize(entry.size))
533
- log.fileLog(`(${ipx}) Skip[NoVideo]: <${entry.path}> (${helper.humanSize(entry.size)})`, 'Prepare')
568
+ log.showYellow(logTag, `${ipx} Skip[NoVideo]: ${entry.path}`, getEntryShowInfo(entry), helper.humanSize(entry.size))
569
+ log.fileLog(`${ipx} Skip[NoVideo]: <${entry.path}> (${helper.humanSize(entry.size)})`, 'Prepare')
534
570
  return false
535
571
  }
536
572
  // 输出文件名基本名,含前后缀,不含扩展名
@@ -545,21 +581,21 @@ async function prepareFFmpegCmd(entry) {
545
581
  if (await fs.pathExists(fileDst)) {
546
582
  log.info(
547
583
  logTag,
548
- `(${ipx}) Skip[Dst1]: ${helper.pathShort(entry.path)} (${helper.humanSize(entry.size)})`)
584
+ `${ipx} Skip[Dst1]: ${helper.pathShort(entry.path)} (${helper.humanSize(entry.size)})`)
549
585
  return false
550
586
  }
551
587
  if (await fs.pathExists(fileDstSameDir)) {
552
588
  log.info(
553
589
  logTag,
554
- `(${ipx}) Skip[Dst2]: ${helper.pathShort(entry.path)} (${helper.humanSize(entry.size)})`)
590
+ `${ipx} Skip[Dst2]: ${helper.pathShort(entry.path)} (${helper.humanSize(entry.size)})`)
555
591
  return false
556
592
  }
557
593
  if (await fs.pathExists(fileDstTemp)) {
558
594
  await fs.remove(fileDstTemp)
559
595
  }
560
- log.show(logTag, `(${ipx}) Task ${helper.pathShort(entry.path, 72)}`, helper.humanTime(entry.startMs))
561
- log.showGray(logTag, getEntryShowInfo(entry), chalk.yellow(entry.preset.name), helper.humanSize(entry.size))
562
- log.info(logTag, `(${ipx}) Task DST:${fileDst}`)
596
+ log.show(logTag, `${ipx} TaskSRC: ${helper.pathShort(entry.path, 72)}`, helper.humanTime(entry.startMs))
597
+ log.info(logTag, `${ipx} TaskDST:`, `${fileDst}`)
598
+ log.showGray(logTag, `${ipx} Streams:`, getEntryShowInfo(entry), chalk.yellow(entry.preset.name), helper.humanSize(entry.size))
563
599
  // log.show(logTag, `Entry(${ipx})`, entry)
564
600
  const newEntry = {
565
601
  ...entry,
@@ -572,7 +608,7 @@ async function prepareFFmpegCmd(entry) {
572
608
  // log.info(logTag, 'ffmpeg', ffmpegArgs.join(' '))
573
609
  return newEntry
574
610
  } catch (error) {
575
- log.warn(logTag, `(${ipx}) Skip[Error]: ${entry.path}`, error)
611
+ log.warn(logTag, `${ipx} Skip[Error]: ${entry.path}`, error)
576
612
  }
577
613
  }
578
614
 
@@ -605,12 +641,17 @@ function getEntryShowInfo(entry) {
605
641
  const duration = entry?.info?.audio?.duration
606
642
  || entry?.info?.video?.duration
607
643
  || entry?.info?.format?.duration || 0
644
+ const fps = entry.info?.video?.r_frame_rate || 0
608
645
  const showInfo = []
609
646
  if (ac) showInfo.push(`audio|${ac}|${Math.round(entry.srcAudioBitrate / 1024)}K=>${getBestAudioBitrate(entry)}K|${helper.humanDuration(duration * 1000)}`)
610
- if (vc) showInfo.push(`video|${vc}|${Math.round(entry.srcVideoBitrate / 1024)}K=>${getBestVideoBitrate(entry)}K|${helper.humanDuration(duration * 1000)}`,)
647
+ if (vc) showInfo.push(`video|${vc}|${Math.round(entry.srcVideoBitrate / 1024)}K=>${getBestVideoBitrate(entry)}K|${helper.humanDuration(duration * 1000)}|${fps}`,)
611
648
  return showInfo.join(', ')
612
649
  }
613
650
 
651
+ function getDurationInfo(entry) {
652
+ return helper.humanDuration((entry?.info?.audio?.duration || entry?.info?.video?.duration || entry?.info?.format?.duration || 0) * 1000)
653
+ }
654
+
614
655
  // 读取单个音频文件的元数据
615
656
  async function readMusicMeta(entry) {
616
657
  try {
@@ -648,9 +689,11 @@ function calculateBitrate(entry) {
648
689
  // 这个是文件整体码率,如果是是视频文件,等于是视频和音频的码率相加
649
690
  const fileBitrate = entry.info?.format?.bit_rate || 0
650
691
  let srcAudioBitrate = 0
692
+ let dstAudioBitrate = 0
651
693
  let srcVideoBitrate = 0
652
694
  let dstVideoBitrate = 0
653
695
  if (helper.isAudioFile(entry.path)) {
696
+ // 对于音频文件,音频格式转换
654
697
  if (entry.format?.lossless || helper.isAudioLossless(entry.path)) {
655
698
  // 无损音频,设置默认值
656
699
  srcAudioBitrate = srcAudioBitrate > 320 ? srcAudioBitrate : 999 * 1024
@@ -659,25 +702,35 @@ function calculateBitrate(entry) {
659
702
  srcAudioBitrate = entry.format?.bitrate
660
703
  || entry.info?.audio?.bit_rate
661
704
  || fileBitrate || 0
705
+ // 有的文件无法获取音频码率,如opus,此时srcAudioBitrate=0
706
+ // opus用于极低码率音频,此时 dstAudioBitrate=48 可以接受
707
+ dstAudioBitrate = bitrateMap.find(br => srcAudioBitrate > br.threshold)?.value || 48
708
+ // 忽略计算结果,直接使用预设数值
709
+ if (!entry.preset.smartBitrate) {
710
+ dstAudioBitrate = entry.preset.audioBitrate
711
+ }
662
712
  } else {
663
- // video file
713
+ // 对于视频文件
714
+ // 计算码率原则
715
+ // 计算出的音频码率不高于源文件的音频码率
716
+ // 计算出的音频码率不高于预设指定的码率
664
717
  srcAudioBitrate = entry.info?.audio?.bit_rate || 0
718
+ dstAudioBitrate = Math.min(Math.round(srcAudioBitrate / 1024), entry.preset.audioBitrate)
719
+
720
+ // 计算出的视频码率不高于源文件的视频码率
665
721
  // 减去音频的码率,估算为48k
666
722
  srcVideoBitrate = entry.info?.video?.bit_rate
667
723
  || fileBitrate - 48 * 1024 || 0
668
724
  // 压缩后的视频比特率不能高于源文件比特率
669
- if (entry.preset.videoBitrate * 1024 > srcVideoBitrate) {
670
- dstVideoBitrate = Math.round(srcVideoBitrate / 1024)
671
- } else {
725
+ dstVideoBitrate = Math.min(Math.round(srcVideoBitrate / 1024), entry.preset.videoBitrate)
726
+ // 忽略计算结果,直接使用预设数值
727
+ if (!entry.preset.smartBitrate) {
728
+ dstAudioBitrate = entry.preset.audioBitrate
672
729
  dstVideoBitrate = entry.preset.videoBitrate
673
730
  }
674
731
  }
675
- // 有的文件无法获取音频码率,如opus,此时srcAudioBitrate=0
676
- // opus用于极低码率音频,此时 dstAudioBitrate=48 可以接受
677
- const dstAudioBitrate = bitrateMap.find(br => srcAudioBitrate > br.threshold)?.value || 48
678
- const result = { srcAudioBitrate, dstAudioBitrate, srcVideoBitrate, dstVideoBitrate }
679
- // log.showRed(entry.path, result)
680
- return result
732
+
733
+ return { srcAudioBitrate, dstAudioBitrate, srcVideoBitrate, dstVideoBitrate }
681
734
  }
682
735
 
683
736
  // 选择最佳视频码率
@@ -716,10 +769,46 @@ function getBestAudioQuality(entry) {
716
769
  // 此函数仅读取参数,不修改preset对象
717
770
  function createFFmpegArgs(entry, forDisplay = false) {
718
771
  const preset = entry.preset
772
+ // 用于模板字符串的模板参数,针对当前文件
773
+ const entryPreset = {
774
+ ...preset,
775
+ // 这几个会覆盖preset的预设数值
776
+ videoBitrate: getBestVideoBitrate(entry),
777
+ videoQuality: getBestVideoQuality(entry),
778
+ audioBitrate: getBestAudioBitrate(entry),
779
+ audioQuality: getBestAudioQuality(entry),
780
+ // 下面的是源文件参数
781
+ srcAudioBitrate: entry.srcAudioBitrate,
782
+ srcVideoBitrate: entry.srcVideoBitrate,
783
+ srcWidth: entry.info.video?.width || 0,
784
+ srcHeight: entry.info.video?.height || 0,
785
+ srcFrameRate: entry.info.video?.r_frame_rate || 0,
786
+ srcDuration: entry.info.format?.duration || 0,
787
+ srcSize: entry.info.format?.size || 0,
788
+ srcVideoCodec: entry.info.video?.codec_name,
789
+ srcAudioCodec: entry.info.audio?.codec_name,
790
+ srcFormat: entry.info.format?.format_name,
791
+ }
792
+ // 几种ffmpeg参数设置的时间和功耗
793
+ // ffmpeg -hide_banner -n -v error -stats -i
794
+ // 32s 110w
795
+ // ffmpeg -hide_banner -n -v error -stats -hwaccel auto -i
796
+ // 34s 56w
797
+ // ffmpeg -hide_banner -n -v error -stats -hwaccel d3d11va -hwaccel_output_format d3d11
798
+ // 27s 45w rm格式死机蓝屏
799
+ // ffmpeg -hide_banner -n -v error -stats -hwaccel cuda -hwaccel_output_format cuda
800
+ // 27s 41w
801
+ // ffmpeg -hide_banner -n -v error -stats -hwaccel cuda -i
802
+ // 31s 60w
719
803
  // 显示详细信息
720
804
  // let args = "-hide_banner -n -loglevel repeat+level+info -stats".split(" ")
721
805
  // 只显示进度和错误
722
- let args = "-hide_banner -n -v error -stats".split(" ")
806
+ let args = "-hide_banner -n -v error".split(" ")
807
+ // 输出视频时才需要cuda加速,音频用cpu就行
808
+ if (preset.type === 'video') {
809
+ // -hwaccel cuda -hwaccel_output_format cuda
810
+ args = args.concat(["-stats", "-hwaccel", "cuda", "-hwaccel_output_format", "cuda"])
811
+ }
723
812
  // 输入参数在输入文件前面,顺序重要
724
813
  if (preset.inputArgs?.length > 0) {
725
814
  args = args.concat(this.preset.inputArgs.split(' '))
@@ -732,16 +821,13 @@ function createFFmpegArgs(entry, forDisplay = false) {
732
821
  if (preset.complexFilter?.length > 0) {
733
822
  args.push('-filter_complex')
734
823
  args.push(`"${formatArgs(preset.complexFilter, preset)}"`)
735
- } else if (preset.filters?.length > 0) {
824
+ } else if (preset.filters.length > 0) {
736
825
  args.push('-vf')
737
826
  args.push(formatArgs(preset.filters, preset))
738
827
  }
828
+
739
829
  if (preset.videoArgs?.length > 0) {
740
- const va = formatArgs(preset.videoArgs, {
741
- ...preset,
742
- videoBitrate: getBestVideoBitrate(entry),
743
- videoQuality: getBestVideoQuality(entry)
744
- })
830
+ const va = formatArgs(preset.videoArgs, entryPreset)
745
831
  args = args.concat(va.split(' '))
746
832
  }
747
833
  if (preset.audioArgs?.length > 0) {
@@ -750,7 +836,7 @@ function createFFmpegArgs(entry, forDisplay = false) {
750
836
  // audioArgsCopy: '-c:a copy',
751
837
  // audioArgsEncode: '-c:a libfdk_aac -b:a {audioBitrate}k',
752
838
  let audioArgsFixed = preset.audioArgs
753
- if (preset.name === PRESET_AUDIO_EXTRACT.name) {
839
+ if (presets.isAudioExtract(preset)) {
754
840
  if (entry.info?.audio?.codec_name === 'aac') {
755
841
  audioArgsFixed = '-c:a copy'
756
842
  } else {
@@ -759,11 +845,7 @@ function createFFmpegArgs(entry, forDisplay = false) {
759
845
  } else {
760
846
  audioArgsFixed = preset.audioArgs
761
847
  }
762
- const aa = formatArgs(audioArgsFixed, {
763
- ...preset,
764
- audioBitrate: getBestAudioBitrate(entry),
765
- audioQuality: getBestAudioQuality(entry)
766
- })
848
+ const aa = formatArgs(audioArgsFixed, entryPreset)
767
849
  args = args.concat(aa.split(' '))
768
850
  }
769
851
  // 在输入文件后面
@@ -775,18 +857,21 @@ function createFFmpegArgs(entry, forDisplay = false) {
775
857
  let extraArgsArray = []
776
858
  // 添加自定义metadata字段
777
859
  //description, comment, copyright
860
+
778
861
  extraArgsArray.push(`-metadata`, `description="encoder=mediac-ffmpeg"`)
779
862
  extraArgsArray.push(`-metadata`, `copyright="name=${entry.name}"`)
780
- extraArgsArray.push(`-metadata`, `comment="preset=${preset.name},vargs=${formatArgs(preset.videoArgs, preset)},aargs=${formatArgs(preset.audioArgs, preset)}"`)
863
+ extraArgsArray.push(`-metadata`, `comment="preset=${preset.name},vargs=${formatArgs(preset.videoArgs, entryPreset)},aargs=${formatArgs(preset.audioArgs, entryPreset)}"`)
781
864
  // 检查源文件元数据
782
865
  if (entry.tags?.title) {
783
866
  const KEY_LIST = ['title', 'artist', 'album', 'albumartist', 'year']
784
867
  // 验证 非空值,无乱码,值为字符串或数字
785
868
  const validTags = core.filterFields(entry.tags, (key, value) => {
786
869
  return KEY_LIST.includes(key)
787
- && !enc.hasBadCJKChar(value)
870
+ && Boolean(value)
788
871
  && ((typeof value === 'string' && value.length > 0)
789
872
  || typeof value === 'number')
873
+ && !enc.hasBadCJKChar(value)
874
+ && !enc.hasBadUnicode(value)
790
875
  })
791
876
  extraArgsArray = extraArgsArray.concat(...Object.entries(validTags)
792
877
  .map(([key, value]) => [`-metadata`, `${key}="${value}"`]))
@@ -812,242 +897,12 @@ function createFFmpegArgs(entry, forDisplay = false) {
812
897
  return args
813
898
  }
814
899
 
815
- // ===========================================
816
- // 数据类定义
817
- // ===========================================
818
-
819
- // ffmpeg命令参数预设类
820
- class Preset {
821
- constructor(name, {
822
- format,
823
- type,
824
- prefix,
825
- suffix,
826
- videoArgs,
827
- audioArgs,
828
- inputArgs,
829
- streamArgs,
830
- extraArgs,
831
- outputArgs,
832
- filters,
833
- complexFilter,
834
- output,
835
- videoBitrate = 0,
836
- videoQuality = 0,
837
- audioBitrate = 0,
838
- audioQuality = 0,
839
- dimension = 0,
840
- speed = 1
841
- } = {}) {
842
- this.name = name
843
- this.format = format
844
- this.type = type
845
- this.prefix = prefix
846
- this.suffix = suffix
847
- this.videoArgs = videoArgs
848
- this.audioArgs = audioArgs
849
- this.inputArgs = inputArgs
850
- this.streamArgs = streamArgs
851
- this.extraArgs = extraArgs
852
- this.outputArgs = outputArgs
853
- this.filters = filters
854
- this.complexFilter = complexFilter
855
- // 输出目录
856
- this.output = output
857
- // 视频码率和质量
858
- this.videoBitrate = videoBitrate
859
- this.videoQuality = videoQuality
860
- // 音频码率和质量
861
- this.audioBitrate = audioBitrate
862
- this.audioQuality = audioQuality
863
- // 视频尺寸
864
- this.dimension = dimension
865
- // 视频加速
866
- this.speed = speed
867
- // 元数据参数
868
- // 用户从命令行设定的参数
869
- // 优先级最高
870
- this.userVideoBitrate = 0
871
- this.userVideoQuality = 0
872
- this.userAudioBitrate = 0
873
- this.userAudioQuality = 0
874
- }
875
-
876
- update(source) {
877
- for (const key in source) {
878
- this[key] = source[key]
879
- }
880
- return this
881
- }
882
-
883
- // 构造函数,参数为另一个 Preset 对象
884
- static fromPreset(preset) {
885
- return new Preset(preset.name, preset)
886
- }
887
-
888
- }
889
-
890
- // videoArgs = { args,codec,quality,bitrate,filters}
891
- // audioOptons = {args,codec, quality,bitrate,filters}
892
- // audioArgs = {prefix,suffix}
893
-
894
- // HEVC基础参数
895
- const HEVC_BASE = new Preset('hevc-base', {
896
- format: '.mp4',
897
- type: 'video',
898
- intro: 'hevc|hevc_nvenc|libfdk_aac',
899
- prefix: '[SHANA] ',
900
- suffix: '_{preset}_{videoBitrate}k',
901
- description: 'HEVC_BASE',
902
- // 视频参数说明
903
- // video_codec block '-c:v hevc_nvenc -profile:v main -tune:v hq'
904
- // video_quality block '-cq {quality} -bufsize {bitrate} -maxrate {bitrate}'
905
- videoArgs: '-c:v hevc_nvenc -profile:v main -tune:v hq -cq {videoQuality} -bufsize {videoBitrate}k -maxrate {videoBitrate}k',
906
- // 音频参数说明
907
- // audio_codec block '-c:a libfdk_aac'
908
- // audio_quality block '-b:a {bitrate}'
909
- audioArgs: '-c:a libfdk_aac -b:a {audioBitrate}k',
910
- inputArgs: '',
911
- streamArgs: '-map_metadata 0 -map_metadata:s:v 0:s:v',
912
- // 快速读取和播放
913
- outputArgs: '-movflags +faststart -movflags use_metadata_tags',
914
- filters: "scale='if(gte(iw,ih),min({dimension},iw),-2)':'if(lt(iw,ih),min({dimension},ih),-2)'",
915
- complexFilter: '',
916
- })
917
-
918
- // 音频AAC基础参数
919
- const AAC_BASE = new Preset('aac_base', {
920
- format: '.m4a',
921
- type: 'audio',
922
- intro: 'aac|libfdk_aac',
923
- prefix: '',
924
- suffix: '_{preset}_{audioBitrate}k',
925
- description: 'AAC_BASE',
926
- videoArgs: '',
927
- // 音频参数说明
928
- // audio_codec block '-c:a libfdk_aac'
929
- // audio_quality block '-b:a {bitrate}'
930
- audioArgs: '-map 0:a -c:a libfdk_aac -b:a {audioBitrate}k',
931
- inputArgs: '',
932
- streamArgs: '-map_metadata 0 -map_metadata:s:a 0:s:a',
933
- outputArgs: '-movflags +faststart -movflags use_metadata_tags',
934
- })
935
-
936
-
937
- const PRESET_AUDIO_EXTRACT = Preset.fromPreset(AAC_BASE).update({
938
- name: 'audio_extract',
939
- intro: 'aac|extract',
940
- audioArgs: '-c:a copy',
941
- streamArgs: '-vn -map 0:a:0 -map_metadata 0 -map_metadata:s:a 0:s:a'
942
- })
943
-
944
- function initializePresets() {
945
-
946
- const PRESET_HEVC_ULTRA = Preset.fromPreset(HEVC_BASE).update({
947
- name: 'hevc_ultra',
948
- videoQuality: 20,
949
- videoBitrate: 20480,
950
- audioBitrate: 320,
951
- dimension: 3840
952
- })
953
-
954
- const PRESET_HEVC_4K = Preset.fromPreset(HEVC_BASE).update({
955
- name: 'hevc_4k',
956
- videoQuality: 22,
957
- videoBitrate: 10240,
958
- audioBitrate: 256,
959
- dimension: 3840
960
- })
961
-
962
- const PRESET_HEVC_2K = Preset.fromPreset(HEVC_BASE).update({
963
- name: 'hevc_2k',
964
- videoQuality: 22,
965
- videoBitrate: 4096,
966
- audioBitrate: 192,
967
- dimension: 1920
968
- })
969
-
970
- const PRESET_HEVC_MEDIUM = Preset.fromPreset(HEVC_BASE).update({
971
- name: 'hevc_medium',
972
- videoQuality: 24,
973
- videoBitrate: 2048,
974
- audioBitrate: 128,
975
- dimension: 1920
976
- })
977
-
978
- const PRESET_HEVC_LOW = Preset.fromPreset(HEVC_BASE).update({
979
- name: 'hevc_low',
980
- videoQuality: 26,
981
- videoBitrate: 1536,
982
- audioBitrate: 96,
983
- dimension: 1920
984
- })
985
-
986
- const PRESET_HEVC_LOWEST = Preset.fromPreset(HEVC_BASE).update({
987
- name: 'hevc_lowest',
988
- videoQuality: 26,
989
- videoBitrate: 512,
990
- audioBitrate: 48,
991
- dimension: 1920,
992
- streamArgs: '-map [v] -map [a]',
993
- // 音频参数说明
994
- // audio_codec block '-c:a libfdk_aac -profile:a aac_he'
995
- // audio_quality block '-b:a 48k'
996
- audioArgs: '-c:a libfdk_aac -profile:a aac_he -b:a {audioBitrate}k',
997
- // filters 和 complexFilter 不能共存,此预设使用 complexFilter
998
- filters: '',
999
- // 这里单引号必须,否则逗号需要转义,Windows太多坑
1000
- complexFilter: "[0:v]setpts=PTS/{speed},scale='if(gt(iw,1280),min(1280,iw),-2)':'if(gt(ih,1280),min(1280,ih),-2)'[v];[0:a]atempo={speed}[a]"
1001
- })
1002
-
1003
- const presets = {
1004
- //4K超高码率和质量
1005
- PRESET_HEVC_ULTRA: PRESET_HEVC_ULTRA,
1006
- //4k高码率和质量
1007
- PRESET_HEVC_4K: PRESET_HEVC_4K,
1008
- // 2K高码率和质量
1009
- PRESET_HEVC_2K: PRESET_HEVC_2K,
1010
- // 2K中码率和质量
1011
- PRESET_HEVC_MEDIUM: PRESET_HEVC_MEDIUM,
1012
- // 2K低码率和质量
1013
- PRESET_HEVC_LOW: PRESET_HEVC_LOW,
1014
- // 极低画质和码率,适用于教程类视频
1015
- PRESET_HEVC_LOWEST: PRESET_HEVC_LOWEST,
1016
- // 提取视频中的音频,复制或转换为AAC格式
1017
- PRESET_AUDIO_EXTRACT: PRESET_AUDIO_EXTRACT,
1018
- //音频AAC最高码率
1019
- PRESET_AAC_HIGH: Preset.fromPreset(AAC_BASE).update({
1020
- name: 'aac_high',
1021
- audioBitrate: 320,
1022
- }),
1023
- //音频AAC中码率
1024
- PRESET_AAC_MEDIUM: Preset.fromPreset(AAC_BASE).update({
1025
- name: 'aac_medium',
1026
- audioBitrate: 192,
1027
- }),
1028
- // 音频AAC低码率
1029
- PRESET_AAC_LOW: Preset.fromPreset(AAC_BASE).update({
1030
- name: 'aac_low',
1031
- audioBitrate: 128,
1032
- }),
1033
- // 音频AAC极低码率,适用人声
1034
- PRESET_AAC_VOICE: Preset.fromPreset(AAC_BASE).update({
1035
- name: 'aac_voice',
1036
- audioBitrate: 48,
1037
- audioArgs: '-c:a libfdk_aac -profile:a aac_he -b:a {audioBitrate}k',
1038
- })
1039
- }
1040
-
1041
- core.modifyObjectWithKeyField(presets, 'description')
1042
- for (const [key, preset] of Object.entries(presets)) {
1043
- PRESET_NAMES.push(preset.name)
1044
- PRESET_MAP.set(preset.name, preset)
1045
- }
1046
- }
1047
-
1048
900
  function preparePreset(argv) {
1049
901
  // 参数中指定的preset
1050
- let preset = PRESET_MAP.get(argv.preset)
902
+ let preset = presets.getPreset(argv.preset)
903
+
904
+ // log.show('ARGV', argv)
905
+ // log.show('P1', preset)
1051
906
  // 克隆对象,不修改Map中的内容
1052
907
  preset = structuredClone(preset)
1053
908
  // 保存argv方便调试
@@ -1084,6 +939,17 @@ function preparePreset(argv) {
1084
939
  if (argv.speed > 0) {
1085
940
  preset.speed = argv.speed
1086
941
  }
942
+ // 视频帧率
943
+ if (argv.framerate > 0) {
944
+ preset.framerate = argv.framerate
945
+ }
946
+ if (preset.framerate > 0) {
947
+ if (preset.filters?.length > 0) {
948
+ preset.filters += ',fps={framerate}'
949
+ } else {
950
+ preset.filters = 'fps={framerate}'
951
+ }
952
+ }
1087
953
  // 视频码率,用户指定,优先级最高
1088
954
  if (argv.videoBitrate > 0) {
1089
955
  preset.userVideoBitrate = argv.videoBitrate
@@ -1098,12 +964,6 @@ function preparePreset(argv) {
1098
964
  if (argv.audioQuality > 0) {
1099
965
  preset.userAudioQuality = argv.audioQuality
1100
966
  }
1101
-
967
+ // log.show('P2', preset)
1102
968
  return preset
1103
- }
1104
-
1105
-
1106
-
1107
-
1108
- // 初始化调用
1109
- initializePresets()
969
+ }