mediac 1.8.5 → 2.0.0

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  MediaCli is a multimedia file processing tool that utilizes ffmpeg and exiftool, among others, to compress/convert/rename/delete/organize media files, including images, videos, and audio.
4
4
 
5
- created at 2021.07, updated at 2024.04.07
5
+ created at 2021.07, updated at 2026.01.16
6
6
 
7
7
  ## Installation
8
8
 
@@ -19,47 +19,52 @@ mediac --help
19
19
  ## Command Line
20
20
 
21
21
  ```
22
+ ==============================================================
22
23
  Usage: media_cli.js <command> <input> [options]
23
24
 
24
25
  Commands:
25
- media_cli.js test Test command, do nothing
26
+ media_cli.js test Test command, do nothing
26
27
  [default] [aliases: tt]
27
- media_cli.js dcimr <input> [options] Rename media files by exif metadata eg
28
- . date [aliases: dm, dcim]
29
- media_cli.js organize <input> [output] Organize pictures by file modified dat
30
- e [aliases: oz]
31
- media_cli.js lrmove <input> [output] Move JPEG output of RAW files to other
32
- folder [aliases: lv]
33
- media_cli.js thumbs <input> [output] Make thumbs for input images
34
- [aliases: tb]
35
- media_cli.js compress <input> [output] Compress input images to target size
28
+ media_cli.js dcimr <input> [options] Rename media files by exif metadata
29
+ eg. date [aliases: dm, dcim]
30
+ media_cli.js organize <input> [output] Organize pictures by file modified d
31
+ ate [aliases: oz]
32
+ media_cli.js lrmove <input> [output] Move JPEG output of RAW files to oth
33
+ er folder [aliases: lv]
34
+ media_cli.js compress <input> [output] Compress input images to target size
36
35
  [aliases: cs, cps]
37
- media_cli.js remove <input> [output] Remove files by given size/width-heigh
38
- t/name-pattern/file-list
36
+ media_cli.js remove [input] [directories Remove files by given size/width-hei
37
+ ...] ght/name-pattern/file-list
39
38
  [aliases: rm, rmf]
40
- media_cli.js moveup <input> [output] Move files to sub top folder or top fo
41
- lder [aliases: mu]
42
- media_cli.js prefix <input> [output] Rename files by append dir name or str
43
- ing [aliases: pf, px]
44
- media_cli.js fixname <input> [output] Fix filenames (fix messy, clean, conve
45
- rt tc to sc) [aliases: fn, fxn]
46
- media_cli.js zipu <input> [output] Smart unzip command (auto detect encod
47
- ing) [aliases: zipunicode]
48
- media_cli.js decode <strings...> Decode text with messy or invalid char
49
- s [aliases: dc]
39
+ media_cli.js moveup <input> [output] Move files to sub top folder or top
40
+ folder [aliases: mp]
41
+ media_cli.js move <input> [output] Move files to folders by filename da
42
+ te patterns [aliases: md]
43
+ media_cli.js prefix <input> [output] Rename files by append dir name or s
44
+ tring [aliases: pf, px]
45
+ media_cli.js rename <input> Reanme files: fix encoding, replace
46
+ by regex, clean chars, from tc to sc
47
+ . [aliases: fn, fxn]
48
+ media_cli.js zipu <input> [output] Smart unzip command (auto detect enc
49
+ oding) [aliases: zipunicode]
50
+ media_cli.js decode <strings...> Decode text with messy or invalid ch
51
+ ars [aliases: dc]
52
+ media_cli.js ffmpeg [input] [directories convert audio or video files using f
53
+ ...] fmpeg.
54
+ [aliases: transcode, aconv, vconv, avconv]
50
55
 
51
56
  Options:
52
57
  --version Show version number [boolean]
53
58
  -h, --help Show help [boolean]
54
59
 
55
60
  MediaCli is a multimedia file processing tool.
56
- Copyright 2021-2025 @ Zhang Xiaoke
61
+ Copyright 2021-2026 @ Zhang Xiaoke
57
62
 
58
63
  ```
59
64
 
60
65
  ## License
61
66
 
62
- Copyright 2021-2025 github@mcxiaoke.com
67
+ Copyright 2021-2026 github@mcxiaoke.com
63
68
 
64
69
  Licensed under the Apache License, Version 2.0 (the "License");
65
70
  you may not use this file except in compliance with the License.
@@ -219,11 +219,11 @@ async function cmdCompress(argv) {
219
219
  needBar && bar1.stop()
220
220
  log.info(logTag, "before filter: ", tasks.length)
221
221
  const total = tasks.length
222
- tasks = tasks.filter((t) => t?.dst)
222
+ tasks = tasks.filter((t) => t?.dst && t.tmpDst && !t?.shouldSkip)
223
223
  const skipped = total - tasks.length
224
224
  log.info(logTag, "after filter: ", tasks.length)
225
225
  if (skipped > 0) {
226
- log.showYellow(logTag, `${skipped} thumbs skipped`)
226
+ log.showYellow(logTag, `${skipped} image files skipped`)
227
227
  }
228
228
  if (tasks.length === 0) {
229
229
  log.showYellow("Nothing to do, abort.")
@@ -303,7 +303,7 @@ async function preCompress(f) {
303
303
 
304
304
  let fileDstDir = f.output ? helper.pathRewrite(f.root, dir, f.output, false) : dir
305
305
  const tempSuffix = `_tmp@${helper.textHash(fileSrc)}@tmp_`
306
- const fileDstTmp = path.join(fileDstDir, `${base}${suffix}${tempSuffix}.jpg`)
306
+ const fileDstTmp = path.resolve(path.join(fileDstDir, `${base}${suffix}${tempSuffix}.jpg`))
307
307
  // 构建目标文件路径,添加压缩后的文件名后缀
308
308
  let fileDst = path.join(fileDstDir, `${base}${suffix}.jpg`)
309
309
 
@@ -325,7 +325,6 @@ async function preCompress(f) {
325
325
  height: 0,
326
326
  src: fileSrc,
327
327
  dst: fileDst,
328
- tmpDst: fileDstTmp,
329
328
  dstExists: true,
330
329
  shouldSkip: true,
331
330
  skipReason: 'DST EXISTS',
package/cmd/cmd_dcim.js CHANGED
@@ -86,7 +86,10 @@ const handler = async function cmdRename(argv) {
86
86
  let files = await exif.listMedia(root)
87
87
  const fileCount = files.length
88
88
  log.show(LOG_TAG, `Total ${files.length} media files found`)
89
-
89
+ if (files.length === 0) {
90
+ log.showYellow(LOG_TAG, "No files found, exit now.")
91
+ return
92
+ }
90
93
  const confirmFiles = await inquirer.prompt([
91
94
  {
92
95
  type: "confirm",
@@ -142,7 +145,11 @@ const handler = async function cmdRename(argv) {
142
145
  `Total ${files.length} media files ready to rename by exif`,
143
146
  fastMode ? "(FastMode)" : ""
144
147
  )
145
- log.show(LOG_TAG, `task sample:`, files.slice(-2))
148
+
149
+ log.show(LOG_TAG, `task sample list:`)
150
+ for (const f of files.slice(-20)) {
151
+ log.show(path.basename(f.path), f.outName, f.date)
152
+ }
146
153
  log.info(LOG_TAG, argv)
147
154
  testMode && log.showYellow("++++++++++ TEST MODE (DRY RUN) ++++++++++")
148
155
  const answer = await inquirer.prompt([
package/cmd/cmd_ffmpeg.js CHANGED
@@ -58,11 +58,24 @@ const builder = function addOptions(ya, helpOrVersionSet) {
58
58
  type: "string",
59
59
  })
60
60
  // 保持源文件目录结构
61
- .option("output-tree", {
62
- alias: 'otree',
63
- describe: "keep folder tree structure in output folder",
64
- type: "boolean",
65
- default: true,
61
+ .option("output-mode", {
62
+ alias: 'om',
63
+ type: "choices",
64
+ choices: ['tree', 'dir', 'file'],
65
+ default: 'dir',
66
+ describe: "Output mode: keep folder tree/keep parent dir/ flatten files",
67
+ })
68
+ // 列表处理,起始索引
69
+ .option('start', {
70
+ type: 'number',
71
+ default: 0,
72
+ description: 'start index of file list to process'
73
+ })
74
+ // 列表处理,每次数目
75
+ .option('count', {
76
+ type: 'number',
77
+ default: 99999,
78
+ description: 'group size of file list to process'
66
79
  })
67
80
  // 正则,包含文件名规则
68
81
  .option("include", {
@@ -226,6 +239,13 @@ const builder = function addOptions(ya, helpOrVersionSet) {
226
239
  describe: "hardware acceleration for video decode and encode",
227
240
  type: "string",
228
241
  })
242
+ // 仅使用硬件解码
243
+ .option("decode-mode", {
244
+ type: "choices",
245
+ choices: ['auto', 'gpu', 'cpu'],
246
+ default: 'auto',
247
+ describe: "video decode mode: auto/gpu/cpu",
248
+ })
229
249
  // 并行操作限制,并发数,默认为 CPU 核心数
230
250
  .option("jobs", {
231
251
  alias: "j",
@@ -265,7 +285,8 @@ async function cmdConvert(argv) {
265
285
  // 显示预设列表
266
286
  if (argv.showPresets) {
267
287
  for (const [key, value] of presets.getAllPresets()) {
268
- log.show(core.pick(value, 'name', 'type', 'format'))
288
+ const data = core.pick(value, 'name', 'type', 'format', 'videoBitrate', 'dimension')
289
+ log.show(JSON.stringify(data))
269
290
  }
270
291
  return
271
292
  }
@@ -300,7 +321,7 @@ async function cmdConvert(argv) {
300
321
  const walkOpts = {
301
322
  withFiles: true,
302
323
  needStats: true,
303
- entryFilter: (e) => e.isFile && helper.isMediaFile(e.name)
324
+ entryFilter: (e) => e.isFile && helper.isVideoFile(e.name)
304
325
  }
305
326
  let fileEntries = await mf.walk(root, walkOpts)
306
327
  // 处理额外目录参数
@@ -337,12 +358,17 @@ async function cmdConvert(argv) {
337
358
  log.showYellow(logTag, 'No files left after rules, nothing to do.')
338
359
  return
339
360
  }
361
+
362
+ // 如果指定了start和count,截取列表部分
363
+ fileEntries = fileEntries.slice(argv.start, argv.start + argv.count)
364
+ log.show(logTag, `Total ${fileEntries.length} files left in (${argv.start}-${argv.start + argv.count})`)
365
+
340
366
  // 仅显示视频文件参数,不进行转换操作
341
367
  if (argv.info) {
342
368
  for (const entry of fileEntries) {
343
369
  log.showGreen(logTag, `${entry.path}`)
344
370
  const info = await getMediaInfo(entry.path)
345
- log.show(logTag, JSON.stringify(info))
371
+ log.show(logTag, info)
346
372
  }
347
373
  return
348
374
  }
@@ -394,7 +420,7 @@ async function cmdConvert(argv) {
394
420
  return
395
421
  }
396
422
  log.showGreen(logTag, 'Now Preparing task files and ffmpeg cmd args...')
397
- let tasks = await pMap(fileEntries, prepareFFmpegCmd, { concurrency: argv.jobs || (core.isUNCPath(root) ? 4 : cpus().length) })
423
+ let tasks = await pMap(fileEntries, prepareFFmpegCmd, { concurrency: argv.jobs || (core.isUNCPath(root) ? 4 : cpus().length - 2) })
398
424
 
399
425
  // 如果选择了清理源文件
400
426
  if (argv.deleteSourceFiles) {
@@ -429,8 +455,8 @@ async function cmdConvert(argv) {
429
455
  const lastTask = tasks.slice(-1)[0]
430
456
  !testMode && log.fileLog(`ffmpegArgs:`, lastTask.ffmpegArgs.flat(), 'FFConv')
431
457
  log.show('-----------------------------------------------------------')
432
- log.showYellow(logTag, 'PRESET:', lastTask.debugPreset)
433
- log.showCyan(logTag, 'CMD: ffmpeg', lastTask.ffmpegArgs.flat().join(' '))
458
+ log.show(logTag, chalk.cyan('PRESET:'), lastTask.debugPreset)
459
+ log.show(logTag, chalk.cyan('CMD:'), 'ffmpeg', lastTask.ffmpegArgs.flat().join(' '))
434
460
  const totalDuration = tasks.reduce((acc, t) => acc + t.info?.duration || 0, 0)
435
461
  log.show('-----------------------------------------------------------')
436
462
  testMode && log.showYellow('++++++++++ TEST MODE (DRY RUN) ++++++++++')
@@ -466,21 +492,54 @@ async function cmdConvert(argv) {
466
492
  tasks = core.takeEveryNth(tasks, Math.floor(tasks.length / 10))
467
493
  }
468
494
  const results = await pMap(tasks, runFFmpegCmd, { concurrency: jobCount })
495
+ let failedTasks = results.filter(r => r && r.ffmpegFailed && !r.retryOnFailed)
496
+ let rOKCount = 0
497
+ if (failedTasks.length > 0) {
498
+ const answer = await inquirer.prompt([
499
+ {
500
+ type: 'confirm',
501
+ name: 'yes',
502
+ default: false,
503
+ message: chalk.bold.red(
504
+ `${failedTasks.length} tasks failed, do you want to retry these tasks?`
505
+ )
506
+ }
507
+ ])
508
+ if (answer.yes) {
509
+ for (const ft of failedTasks) {
510
+ log.showYellow(logTag, `Retrying task: ${ft.path}`
511
+ )
512
+ let newFT = core.omit(ft, 'ffmpegArgs', 'info')
513
+ // 强制使用CPU解码f
514
+ newFT.argv.decodeMode = 'cpu'
515
+ newFT.retryOnFailed = true
516
+ const task = await prepareFFmpegCmd(newFT)
517
+ const rt = await runFFmpegCmd(task)
518
+ if (rt && rt.ok) {
519
+ rOKCount++
520
+ }
521
+ }
522
+ }
523
+ }
524
+
469
525
  // const results = await core.asyncMapGroup(tasks, runFFmpegCmd, jobCount)
470
526
  testMode && log.showYellow(logTag, 'NO file processed in TEST MODE.')
471
527
  const okResults = results.filter(r => r && r.ok)
472
- !testMode && log.showGreen(logTag, `Total ${okResults.length} files processed in ${helper.humanTime(startMs)}`)
528
+ !testMode && log.showGreen(logTag, `Total ${okResults.length + rOKCount} files processed in ${helper.humanTime(startMs)}`)
473
529
  }
474
530
 
475
531
  async function runFFmpegCmd(entry) {
476
532
  const ipx = `${entry.index + 1}/${entry.total}`
477
- const logTag = chalk.green('FFCMD')
478
- log.show(logTag, `${ipx} Processing ${helper.pathShort(entry.path, 72)}`, helper.humanSize(entry.size), chalk.green(helper.humanSeconds(entry.dstArgs.srcDuration)), chalk.yellow(entry.preset.name), helper.humanTime(entry.startMs))
533
+ let logTag = chalk.green('FFCMD') + chalk.cyanBright(entry.useCPUDecode ? '[SW]' : '[HW]')
534
+ if (entry.retryOnFailed) {
535
+ logTag += chalk.red('(R)')
536
+ }
537
+ log.show(logTag, chalk.yellow(ipx), chalk.cyan(`Processing`), `${helper.pathShort(entry.path, 72)}`, helper.humanSize(entry.size), chalk.yellow(helper.humanSeconds(entry.dstArgs.srcDuration)), entry.preset.name, helper.humanTime(entry.startMs))
479
538
 
480
539
  // 每10个输出一次ffmpeg详细信息,避免干扰
481
540
  // if (entry.index % 10 === 0) {
482
- log.showCyan(logTag, getEntryShowInfo(entry))
483
- log.showGray(logTag, `ffmpeg`, entry.ffmpegArgs.flat().join(' '))
541
+ log.showGray(logTag, ipx, getEntryShowInfo(entry))
542
+ log.showGray(logTag, ipx, `ffmpeg`, entry.ffmpegArgs.flat().join(' '))
484
543
  // }
485
544
  const exePath = await which('ffmpeg')
486
545
  if (entry.testMode) {
@@ -502,13 +561,15 @@ async function runFFmpegCmd(entry) {
502
561
  // https://2ality.com/2022/07/nodejs-child-process.html
503
562
  // Windows下 { shell: true } 必须,否则报错
504
563
  const ffmpegProcess = execa(exePath, ffmpegArgs, { shell: true, encoding: 'binary' })
505
- ffmpegProcess.pipeStdout(process.stdout)
506
- ffmpegProcess.pipeStderr(process.stderr)
564
+ if (ffmpegProcess?.hasOwnProperty('pipeStdout')) {
565
+ ffmpegProcess.pipeStdout(process.stdout)
566
+ ffmpegProcess.pipeStderr(process.stderr)
567
+ }
507
568
  const { stdout, stderr } = await ffmpegProcess
508
569
  // const stdoutFixed = fixEncoding(stdout || "")
509
570
  // const stderrFixed = fixEncoding(stderr || "")
510
571
  if (await fs.pathExists(entry.fileDst)) {
511
- log.showYellow(logTag, `${ipx} DstExists ${entry.fileDst}`, helper.humanSize(entry.size), chalk.yellow(entry.preset.name), helper.humanTime(ffmpegStartMs))
572
+ log.showYellow(logTag, `${ipx} DstExists ${entry.fileDst}`, helper.humanSize(entry.size), entry.preset.name, helper.humanTime(ffmpegStartMs))
512
573
  await fs.remove(entry.fileDstTemp)
513
574
  return
514
575
  }
@@ -516,7 +577,7 @@ async function runFFmpegCmd(entry) {
516
577
  const dstSize = (await fs.stat(entry.fileDstTemp))?.size || 0
517
578
  if (dstSize > 20 * mf.FILE_SIZE_1K) {
518
579
  await fs.move(entry.fileDstTemp, entry.fileDst)
519
- log.showGreen(logTag, `${ipx} Done ${entry.fileDst}`, chalk.cyan(`${helper.humanSize(entry.size)}=>${helper.humanSize(dstSize)}`), chalk.yellow(entry.preset.name), helper.humanTime(ffmpegStartMs))
580
+ log.show(logTag, chalk.yellow(ipx), chalk.green('Done'), `${entry.fileDst}`, chalk.cyan(`${helper.humanSize(entry.size)}=>${helper.humanSize(dstSize)}`), entry.preset.name, helper.humanTime(ffmpegStartMs))
520
581
  log.fileLog(`${ipx} Done <${entry.fileDst}> [${entry.preset.name}] (${helper.humanSize(dstSize)})`, 'FFCMD')
521
582
  entry.ok = true
522
583
  return entry
@@ -527,10 +588,15 @@ async function runFFmpegCmd(entry) {
527
588
  log.showYellow(logTag, `${ipx} Failed ${entry.path}`, entry.preset.name, helper.humanSize(dstSize))
528
589
  log.fileLog(`${ipx} Failed <${entry.path}> [${entry.dstAudioBitrate || entry.preset.name}]`, 'FFCMD')
529
590
  } catch (error) {
530
- const errMsg = (error.stderr || error.message || '[Unknown]').substring(0, 360)
531
- log.showRed(logTag, `Error(${ipx}) ${errMsg}`)
591
+ const errMsg = (error.stderr || error.message || '[Unknown]').substring(0, 160)
592
+ log.showRed(logTag, `Error(${ipx}) <${entry.path}>`, errMsg)
593
+ log.showYellow(logTag, `Media(${ipx}) <${entry.path}>`, JSON.stringify(entry.info?.video || entry.info?.audio))
532
594
  log.fileLog(`Error(${ipx}) <${entry.path}> [${entry.preset.name}] ${errMsg}`, 'FFCMD')
533
595
  await writeErrorFile(entry, error)
596
+ // 转换失败需要重试,使用CPUDecode
597
+ entry.ffmpegFailed = true
598
+ entry.ffmpegError = errMsg
599
+ return entry
534
600
  } finally {
535
601
  await fs.remove(entry.fileDstTemp)
536
602
  }
@@ -561,24 +627,36 @@ async function writeErrorFile(entry, error) {
561
627
  }
562
628
 
563
629
  async function prepareFFmpegCmd(entry) {
564
- const logTag = chalk.green('Prepare')
630
+ const preset = entry.preset
631
+ const argv = entry.argv
632
+ let logTag = chalk.green(`Prepare[${entry.argv.decodeMode.toUpperCase()}]`)
633
+ if (entry.retryOnFailed) {
634
+ logTag += chalk.red('(R)')
635
+ }
565
636
  const ipx = `${entry.index + 1}/${entry.total}`
566
637
  log.info(logTag, `Processing(${ipx}) file: ${entry.path}`)
567
638
  const isAudio = helper.isAudioFile(entry.path)
639
+ const isVideo = helper.isVideoFile(entry.path)
568
640
  const [srcDir, srcBase, srcExt] = helper.pathSplit(entry.path)
569
- const preset = entry.preset
570
- const argv = entry.argv
571
641
  const dstExt = preset.format || srcExt
572
642
  let fileDstDir
573
643
  // 命令行参数指定输出目录
574
644
  if (argv.output) {
575
- // 默认true 保留目录结构,可以防止文件名冲突
576
- if (argv.outputTree) {
577
- // 如果要保持源文件目录结构
578
- fileDstDir = helper.pathRewrite(entry.root, srcDir, preset.output)
579
- } else {
645
+ switch (argv.outputMode) {
646
+ case 'tree':
647
+ // 如果要保持源文件目录结构
648
+ fileDstDir = helper.pathRewrite(entry.root, srcDir, preset.output)
649
+ break
650
+ case 'file':
651
+ // 不保留目录结构,直接输出文件
652
+ fileDstDir = path.resolve(preset.output)
653
+ break
580
654
  // 不保留源文件目录结构,只保留源文件父目录
581
- fileDstDir = path.join(preset.output, path.basename(srcDir))
655
+ case 'dir':
656
+ fileDstDir = path.join(preset.output, path.basename(srcDir))
657
+ break
658
+ default:
659
+ throw new Error(`Unknown output mode: ${argv.outputMode}`)
582
660
  }
583
661
  } else {
584
662
  // 如果没有指定输出目录,直接输出在原文件同目录
@@ -665,7 +743,7 @@ async function prepareFFmpegCmd(entry) {
665
743
  const fileDstSameDir = path.join(srcDir, `${fileDstName}`)
666
744
 
667
745
  if (await fs.pathExists(fileDst)) {
668
- log.info(
746
+ log.showYellow(
669
747
  logTag,
670
748
  `${ipx} Skip[Dst1]: ${entry.path} (${helper.humanSize(entry.size)})`)
671
749
  return {
@@ -678,7 +756,7 @@ async function prepareFFmpegCmd(entry) {
678
756
  if (prefix || suffix) {
679
757
  // if (fileDstName !== entry.name) {
680
758
  if (await fs.pathExists(fileDstSameDir)) {
681
- log.info(
759
+ log.showYellow(
682
760
  logTag,
683
761
  `${ipx} Skip[Dst2]: ${entry.path} (${helper.humanSize(entry.size)})`)
684
762
  return {
@@ -688,20 +766,42 @@ async function prepareFFmpegCmd(entry) {
688
766
  }
689
767
  }
690
768
 
691
- if (!isAudio) {
692
- // https://developer.nvidia.com/video-encode-and-decode-gpu-support-matrix-new
693
- // H264 10Bit Nvidia和Intel都不支持硬解,直接跳过
694
- // H264 High-L5以上也不支持
695
- if (entry.info?.video?.format === 'h264'
696
- && (entry.info?.video?.bitDepth === 10
697
- || (entry.info?.video?.profile?.includes('High') && entry.info?.video?.level >= 50))) {
698
- log.warn(logTag, `${ipx} useCPUDecode ${entry.path} ${entry.info?.video?.pixelFormat}`, helper.humanSize(entry.size), chalk.white(JSON.stringify(entry.info.video)))
699
- log.fileLog(`${ipx} useCPUDecode <${entry.path}> (${helper.humanSize(entry.size)})`, 'Prepare')
700
- // 添加标志,使用软解,替换解码参数
701
- // 在组装ffmpeg参数时判断和替换
702
- // 解码和滤镜参数都需要修改
703
- entry.info.useCPUDecode = true
704
- // return false
769
+ const duration = newEntry.info?.duration || ivideo?.duration || iaudio?.duration || 0
770
+ // 跳过过短的文件
771
+ if (duration < 5) {
772
+ log.showYellow(
773
+ logTag,
774
+ `$${ipx} Skip[Short]: ${entry.path} (${helper.humanSize(entry.size)}) Duration=($ {duration}s)`)
775
+ return false
776
+ }
777
+
778
+ const ivideo = newEntry.info?.video
779
+ const iaudio = newEntry.info?.audio
780
+ if (isVideo) {
781
+ switch (argv.decodeMode) {
782
+ case 'cpu':
783
+ newEntry.useCPUDecode = true
784
+ break
785
+ case 'gpu':
786
+ newEntry.useCPUDecode = false
787
+ break
788
+ case 'auto':
789
+ default:
790
+ {
791
+ // https://developer.nvidia.com/video-encode-and-decode-gpu-support-matrix-new
792
+ // H264 10Bit Nvidia和Intel都不支持硬解,直接跳过
793
+ // H264 High L5以上可能也不支持
794
+ const isH264 = ivideo?.format === 'h264' || ivideo?.format === 'avc'
795
+ const isHigh50 = ivideo?.profile?.includes('High') && ivideo?.level > 4.2
796
+ if (isH264 && (ivideo?.bitDepth === 10 || isHigh50)) {
797
+ // 添加标志,使用软解,替换解码参数
798
+ // 在组装ffmpeg参数时判断和替换
799
+ // 解码和滤镜参数都需要修改
800
+ // 尝试使用CPU解码
801
+ newEntry.useCPUDecode = true
802
+ }
803
+ }
804
+ break
705
805
  }
706
806
  }
707
807
 
@@ -718,12 +818,10 @@ async function prepareFFmpegCmd(entry) {
718
818
  subtitles.push(sub2)
719
819
  }
720
820
  }
721
- if (subtitles.length > 0) {
722
- log.showCyan(logTag, `${ipx} SubTitles:`, subtitles.join(' '))
723
- }
724
- log.show(logTag, `${ipx} FR: ${helper.pathShort(entry.path, 80)}`, chalk.yellow(entry.preset.name), helper.humanTime(entry.startMs))
725
- log.showGray(logTag, `${ipx} TO:`, fileDst)
726
- log.showGray(logTag, getEntryShowInfo(newEntry))
821
+ const codecInfo = isAudio ? `${iaudio?.format}(${iaudio?.sampleRate},${iaudio?.bitrate},${iaudio.duration})` : `${ivideo?.format}(${ivideo?.profile}@${ivideo?.level},${ivideo?.bitDepth})`
822
+ log.show(logTag, chalk.cyan(`${ipx} SRC`), chalk.yellow(newEntry.useCPUDecode ? `SW` : `HW`), `"${helper.pathShort(entry.path, 80)}"`, subtitles.length > 0 ? "(SUBS)" : "", codecInfo, helper.humanSize(entry.size), chalk.yellow(entry.preset.name), helper.humanTime(entry.startMs))
823
+ log.showGray(logTag, `${ipx} DST`, fileDst)
824
+ log.showGray(logTag, `${ipx}`, getEntryShowInfo(newEntry))
727
825
  newEntry = {
728
826
  ...newEntry,
729
827
  fileDstDir,
@@ -767,42 +865,50 @@ function kNum(value) {
767
865
 
768
866
  // 显示媒体编码和码率信息,调试用
769
867
  function getEntryShowInfo(entry) {
868
+ const ia = entry.info?.audio
869
+ const iv = entry.info?.video
870
+ const is = entry.info?.subtitles
770
871
  const args = { ...entry, ...entry.dstArgs }
771
872
  const ac = args.srcAudioCodec
772
873
  const vc = args.srcVideoCodec
773
874
  const showText = []
774
- showText.push(`pt:${entry.preset.name}`)
875
+ // showText.push(`pt:${entry.preset.name}`)
775
876
  showText.push(`sz:${helper.humanSize(args.size)}`)
776
877
  showText.push(`ts:${helper.humanSeconds(args.srcDuration)}`)
777
-
778
- showText.push(`a:${ac}`)
779
- if (args.dstAudioBitrate !== args.srcAudioBitrate) {
780
- showText.push(`ab:${kNum(args.srcAudioBitrate)}=>${kNum(args.dstAudioBitrate)}`)
781
- } else {
782
- showText.push(`ab:${kNum(args.srcAudioBitrate)}`)
783
- }
784
- if (args.dstAudioQuality > 0) {
785
- showText.push(`aq:${args.dstAudioQuality}`)
786
- }
787
-
788
- showText.push(`v:${vc}`)
789
- if (args.dstVideoBitrate !== args.srcVideoBitrate) {
790
- showText.push(`vb:${kNum(args.srcVideoBitrate)}=>${kNum(args.dstVideoBitrate)}`)
791
- } else {
792
- showText.push(`vb:${kNum(args.srcVideoBitrate)}`)
793
- }
794
- if (args.dstFrameRate > 0 && args.dstFrameRate !== args.srcFrameRate) {
795
- showText.push(`fps:${args.srcFrameRate}=>${args.dstFrameRate}`)
796
- } else {
797
- showText.push(`fps:${args.srcFrameRate}`)
878
+ if (ia?.duration) {
879
+ showText.push(`a:${ac}`)
880
+ if (args.dstAudioBitrate !== args.srcAudioBitrate) {
881
+ showText.push(`ab:${kNum(args.srcAudioBitrate)}=>${kNum(args.dstAudioBitrate)}`)
882
+ } else {
883
+ showText.push(`ab:${kNum(args.srcAudioBitrate)}`)
884
+ }
885
+ if (args.dstAudioQuality > 0) {
886
+ showText.push(`aq:${args.dstAudioQuality}`)
887
+ }
798
888
  }
799
- if (args.speed > 0) {
800
- showText.push(`sp:${args.speed}`)
889
+ if (iv?.duration) {
890
+ showText.push(`v:${vc}(${iv.profile}@${iv.level})`)
891
+ if (args.dstVideoBitrate !== args.srcVideoBitrate) {
892
+ showText.push(`vb:${kNum(args.srcVideoBitrate)}=>${kNum(args.dstVideoBitrate)}`)
893
+ } else {
894
+ showText.push(`vb:${kNum(args.srcVideoBitrate)}`)
895
+ }
896
+ if (args.dstFrameRate > 0 && args.dstFrameRate !== args.srcFrameRate) {
897
+ showText.push(`fps:${args.srcFrameRate}=>${args.dstFrameRate}`)
898
+ } else {
899
+ showText.push(`fps:${args.srcFrameRate}`)
900
+ }
901
+ if (args.speed > 0) {
902
+ showText.push(`sp:${args.speed}`)
903
+ }
904
+ if (args.srcWidth !== args.dstWidth || args.srcHeight !== args.dstHeight) {
905
+ showText.push(`${args.srcWidth}x${args.srcHeight}=>${args.dstWidth}x${args.dstHeight}`)
906
+ } else {
907
+ showText.push(`${args.srcWidth}x${args.srcHeight}`)
908
+ }
801
909
  }
802
- if (args.srcWidth !== args.dstWidth || args.srcHeight !== args.dstHeight) {
803
- showText.push(`${args.srcWidth}x${args.srcHeight}=>${args.dstWidth}x${args.dstHeight}`)
804
- } else {
805
- showText.push(`${args.srcWidth}x${args.srcHeight}`)
910
+ if (is?.length > 0) {
911
+ showText.push(is.map(s => `${s.format}-${s.language}`).join('|'))
806
912
  }
807
913
  return showText.join(',')
808
914
  }
@@ -917,6 +1023,7 @@ function calculateDstArgs(entry) {
917
1023
  const dstWH = calculateScale(srcWidth, srcHeight, dstDimension)
918
1024
  dstWidth = dstWH.dstWidth
919
1025
  dstHeight = dstWH.dstHeight
1026
+ const bigSide = Math.max(dstWidth, dstHeight)
920
1027
  const srcPixels = srcWidth * srcHeight
921
1028
  const dstPixels = dstWidth * dstHeight
922
1029
  // 这个是文件整体码率,如果是是视频文件,等于是视频和音频的码率相加
@@ -931,27 +1038,36 @@ function calculateDstArgs(entry) {
931
1038
  // 音频和视频码率都不能高于原码率
932
1039
  dstAudioBitrate = minNoZero(srcAudioBitrate, reqAudioBitrate)
933
1040
  // 如果源文件不是1080p,这里码率需要考虑分辨率
934
- const pixelsScale = PIXELS_1080P / srcPixels
935
- dstVideoBitrate = minNoZero(srcVideoBitrate * pixelsScale, reqVideoBitrate)
1041
+ let pixelsScale = 1
1042
+ if (dstDimension > bigSide) {
1043
+ // 如果使用4KPreset压缩1080P视频,需要缩放码率
1044
+ pixelsScale = bigSide / (dstDimension * 1.2)
1045
+ } else if (dstDimension < bigSide) {
1046
+ pixelsScale = PIXELS_1080P / srcPixels
1047
+ } else {
1048
+ pixelsScale = 1
1049
+ }
1050
+ dstVideoBitrate = reqVideoBitrate * pixelsScale
936
1051
 
937
- log.info(entry.name, "fileBitrate", fileBitrate, "srcVideoBitrate", srcVideoBitrate, "reqVideoBitrate", reqVideoBitrate, "dstVideoBitrate", dstVideoBitrate, "pixelsScale", pixelsScale)
1052
+ log.info("calculateDstArgs", entry.name, "fileBitrate", fileBitrate, "srcVideoBitrate", srcVideoBitrate, "reqVideoBitrate", reqVideoBitrate, "dstVideoBitrate", dstVideoBitrate, "pixelsScale", pixelsScale, "bigSide", bigSide, "dstDimension", dstDimension)
938
1053
  // 小于1080p分辨率,码率也需要缩放
939
- if (dstPixels > 0 && dstPixels < PIXELS_1080P) {
1054
+ if (bigSide < 1920) {
940
1055
  let scaleFactor = dstPixels / PIXELS_1080P
941
- // 如果目标码率是4K,暂时吧不考虑
1056
+ // 如果目标码率是4K,暂时不考虑
942
1057
  // 如果目标码率不是1080p,根据分辨率智能缩放
943
1058
  // 示例 辨率1920*1080的目标码率是 1600k
944
1059
  // 1280*720码率 960k
945
1060
  // scaleFactor = Math.sqrt(scaleFactor)
946
1061
  // 缩放码率,平滑系数
947
- scaleFactor = core.smoothChange(scaleFactor, 1, 0.2)
1062
+ scaleFactor = core.smoothChange(scaleFactor, 1, 0.3)
948
1063
  // log.info('scaleFactor', scaleFactor)
949
1064
  dstVideoBitrate = Math.round(dstVideoBitrate * scaleFactor)
950
- // 目标分辨率,不能大于源文件分辨率
951
- dstVideoBitrate = minNoZero(dstVideoBitrate, srcVideoBitrate)
952
- // 取整
953
- // dstVideoBitrate = Math.floor(dstVideoBitrate / 1000) * 1000
1065
+
954
1066
  }
1067
+ // 目标分辨率,不能大于源文件分辨率
1068
+ dstVideoBitrate = minNoZero(dstVideoBitrate, srcVideoBitrate)
1069
+ // 取整
1070
+ // dstVideoBitrate = Math.floor(dstVideoBitrate / 1000) * 1000
955
1071
  }
956
1072
 
957
1073
  // 如果目标帧率大于原帧率,就将目标帧率设置为0,即让ffmpeg自动处理,不添加帧率参数
@@ -1054,7 +1170,7 @@ function createFFmpegArgs(entry, forDisplay = false) {
1054
1170
  if (tempPreset.type === 'video') {
1055
1171
  inputArgs.push("-stats")
1056
1172
  // 使用cuda硬件解码
1057
- if (!entry.info.useCPUDecode) {
1173
+ if (!entry.useCPUDecode) {
1058
1174
  inputArgs.push("-hwaccel", "cuda", "-hwaccel_output_format", "cuda")
1059
1175
  }
1060
1176
  // 不支持硬解的格式,如H264-10bit,使用软解
@@ -1075,8 +1191,20 @@ function createFFmpegArgs(entry, forDisplay = false) {
1075
1191
  })
1076
1192
  const subArgs = '-c:s mov_text -metadata:s:s:0 language=chi -disposition:s:0 default'
1077
1193
  inputArgs = inputArgs.concat(subArgs.split(' '))
1194
+ // 使用提供的字幕,忽略MKV内置字幕文件
1195
+ inputArgs = inputArgs.concat('-map 0:v -map 0:a -map 1'.split(' '))
1078
1196
  } else {
1079
- inputArgs.push('-c:s mov_text')
1197
+ // MP4格式仅支持tx3g格式字幕
1198
+ const subs = entry.info?.subtitles
1199
+ if (subs?.length > 0) {
1200
+ const isAllTextSubs = subs?.every(e => e.codec === 'tx3g')
1201
+ if (isAllTextSubs) {
1202
+ inputArgs = inputArgs.concat('-c:s mov_text'.split(' '))
1203
+ } else {
1204
+ // 不支持的字幕直接忽略
1205
+ inputArgs.push('-sn')
1206
+ }
1207
+ }
1080
1208
  }
1081
1209
  //
1082
1210
  //===============================================================
@@ -1100,7 +1228,7 @@ function createFFmpegArgs(entry, forDisplay = false) {
1100
1228
  } else if (tempPreset.filters?.length > 0) {
1101
1229
  let tempFilters = tempPreset.filters
1102
1230
  // 使用软解时,需要传输数据道GPU
1103
- if (entry.info.useCPUDecode) {
1231
+ if (entry.useCPUDecode) {
1104
1232
  tempFilters = 'hwupload_cuda,' + tempFilters
1105
1233
  }
1106
1234
  middleArgs.push('-vf')