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 +30 -25
- package/cmd/cmd_compress.js +3 -4
- package/cmd/cmd_dcim.js +9 -2
- package/cmd/cmd_ffmpeg.js +222 -94
- package/cmd/cmd_move.js +375 -0
- package/cmd/cmd_moveup.js +9 -1
- package/cmd/cmd_rename.js +10 -7
- package/cmd/cmd_shared.js +12 -4
- package/lib/core.js +46 -15
- package/lib/exif.js +79 -43
- package/lib/ffmpeg_presets.js +1 -1
- package/lib/file.js +2 -1
- package/lib/helper.js +7 -1
- package/lib/media_parser.js +58 -9
- package/lib/mediainfo.js +12 -3
- package/lib/tools.js +88 -2
- package/package.json +10 -9
- package/scripts/media_cli.js +38 -38
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
|
|
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
|
|
26
|
+
media_cli.js test Test command, do nothing
|
|
26
27
|
[default] [aliases: tt]
|
|
27
|
-
media_cli.js dcimr <input> [options]
|
|
28
|
-
|
|
29
|
-
media_cli.js organize <input> [output]
|
|
30
|
-
|
|
31
|
-
media_cli.js lrmove <input> [output]
|
|
32
|
-
|
|
33
|
-
media_cli.js
|
|
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
|
|
38
|
-
|
|
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]
|
|
41
|
-
|
|
42
|
-
media_cli.js
|
|
43
|
-
|
|
44
|
-
media_cli.js
|
|
45
|
-
|
|
46
|
-
media_cli.js
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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-
|
|
61
|
+
Copyright 2021-2026 @ Zhang Xiaoke
|
|
57
62
|
|
|
58
63
|
```
|
|
59
64
|
|
|
60
65
|
## License
|
|
61
66
|
|
|
62
|
-
Copyright 2021-
|
|
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.
|
package/cmd/cmd_compress.js
CHANGED
|
@@ -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}
|
|
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
|
-
|
|
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-
|
|
62
|
-
alias: '
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
default:
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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.
|
|
433
|
-
log.
|
|
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
|
-
|
|
478
|
-
|
|
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.
|
|
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
|
|
506
|
-
|
|
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),
|
|
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.
|
|
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,
|
|
531
|
-
log.showRed(logTag, `Error(${ipx})
|
|
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
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
722
|
-
|
|
723
|
-
}
|
|
724
|
-
log.
|
|
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
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
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 (
|
|
800
|
-
showText.push(`
|
|
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 (
|
|
803
|
-
showText.push(`${
|
|
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
|
-
|
|
935
|
-
|
|
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 (
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
1231
|
+
if (entry.useCPUDecode) {
|
|
1104
1232
|
tempFilters = 'hwupload_cuda,' + tempFilters
|
|
1105
1233
|
}
|
|
1106
1234
|
middleArgs.push('-vf')
|