mediac 1.8.0 → 1.8.2
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_compress.js +2 -2
- package/cmd/cmd_ffmpeg.js +75 -54
- package/cmd/cmd_remove.js +10 -1
- package/cmd/cmd_rename.js +3 -4
- package/lib/core.js +19 -0
- package/lib/ffmpeg_presets.js +39 -10
- package/lib/file.js +3 -3
- package/lib/media_parser.js +278 -0
- package/lib/mediainfo.js +99 -0
- package/lib/shared.js +0 -2
- package/lib/tryfp.js +5 -7
- package/package.json +1 -1
- package/lib/ffprobe.js +0 -205
package/cmd/cmd_compress.js
CHANGED
|
@@ -24,8 +24,8 @@ import * as core from '../lib/core.js'
|
|
|
24
24
|
import * as log from '../lib/debug.js'
|
|
25
25
|
import * as mf from '../lib/file.js'
|
|
26
26
|
import * as helper from '../lib/helper.js'
|
|
27
|
-
import tryfp from '../lib/tryfp.js'
|
|
28
|
-
import {
|
|
27
|
+
import * as tryfp from '../lib/tryfp.js'
|
|
28
|
+
import { compressImage } from "./cmd_shared.js"
|
|
29
29
|
|
|
30
30
|
//
|
|
31
31
|
export { aliases, builder, command, describe, handler }
|
package/cmd/cmd_ffmpeg.js
CHANGED
|
@@ -22,10 +22,9 @@ import { asyncFilter, formatArgs } from '../lib/core.js'
|
|
|
22
22
|
import * as log from '../lib/debug.js'
|
|
23
23
|
import * as enc from '../lib/encoding.js'
|
|
24
24
|
import presets from '../lib/ffmpeg_presets.js'
|
|
25
|
-
import { getMediaInfo } from '../lib/ffprobe.js'
|
|
26
25
|
import * as mf from '../lib/file.js'
|
|
27
26
|
import * as helper from '../lib/helper.js'
|
|
28
|
-
import {
|
|
27
|
+
import { getMediaInfo } from '../lib/mediainfo.js'
|
|
29
28
|
import { addEntryProps, applyFileNameRules, calculateScale } from './cmd_shared.js'
|
|
30
29
|
|
|
31
30
|
const LOG_TAG = "FFConv"
|
|
@@ -157,7 +156,7 @@ const builder = function addOptions(ya, helpOrVersionSet) {
|
|
|
157
156
|
})
|
|
158
157
|
// 视频选项,指定码率
|
|
159
158
|
.option("video-bitrate", {
|
|
160
|
-
alias: "
|
|
159
|
+
alias: "vb",
|
|
161
160
|
type: "number",
|
|
162
161
|
default: 0,
|
|
163
162
|
describe: "Set video bitrate (in kbytes) in ffmpeg command",
|
|
@@ -179,7 +178,7 @@ const builder = function addOptions(ya, helpOrVersionSet) {
|
|
|
179
178
|
})
|
|
180
179
|
// 音频选项,指定码率
|
|
181
180
|
.option("audio-bitrate", {
|
|
182
|
-
alias: "
|
|
181
|
+
alias: "ab",
|
|
183
182
|
type: "number",
|
|
184
183
|
default: 0,
|
|
185
184
|
describe: "Set audio bitrate (in kbytes) in ffmpeg command",
|
|
@@ -356,8 +355,8 @@ async function cmdConvert(argv) {
|
|
|
356
355
|
testMode: testMode
|
|
357
356
|
}
|
|
358
357
|
})
|
|
359
|
-
log.showYellow(logTag, 'ARGV:',
|
|
360
|
-
log.showYellow(logTag, 'PRESET:',
|
|
358
|
+
log.showYellow(logTag, 'ARGV:', argv)
|
|
359
|
+
log.showYellow(logTag, 'PRESET:', preset)
|
|
361
360
|
const prepareAnswer = await inquirer.prompt([
|
|
362
361
|
{
|
|
363
362
|
type: 'confirm',
|
|
@@ -408,9 +407,9 @@ async function cmdConvert(argv) {
|
|
|
408
407
|
const lastTask = tasks.slice(-1)[0]
|
|
409
408
|
!testMode && log.fileLog(`ffmpegArgs:`, lastTask.ffmpegArgs.flat(), 'FFConv')
|
|
410
409
|
log.show('-----------------------------------------------------------')
|
|
411
|
-
log.showYellow(logTag, 'PRESET:',
|
|
410
|
+
log.showYellow(logTag, 'PRESET:', lastTask.debugPreset)
|
|
412
411
|
log.showCyan(logTag, 'CMD: ffmpeg', lastTask.ffmpegArgs.flat().join(' '))
|
|
413
|
-
const totalDuration = tasks.reduce((acc, t) => acc + t.info?.
|
|
412
|
+
const totalDuration = tasks.reduce((acc, t) => acc + t.info?.duration || 0, 0)
|
|
414
413
|
log.show('-----------------------------------------------------------')
|
|
415
414
|
testMode && log.showYellow('++++++++++ TEST MODE (DRY RUN) ++++++++++')
|
|
416
415
|
log.showYellow(logTag, 'Please CHECK above details BEFORE continue!')
|
|
@@ -461,7 +460,7 @@ async function runFFmpegCmd(entry) {
|
|
|
461
460
|
log.showGray(logTag, getEntryShowInfo(entry))
|
|
462
461
|
log.showGray(logTag, `ffmpeg`, entry.ffmpegArgs.flat().join(' '))
|
|
463
462
|
// }
|
|
464
|
-
const exePath = await which(
|
|
463
|
+
const exePath = await which('ffmpeg')
|
|
465
464
|
if (entry.testMode) {
|
|
466
465
|
// 测试模式跳过
|
|
467
466
|
log.show(logTag, `${ipx} Skipped ${entry.path} (${helper.humanSize(entry.size)}) [TestMode]`)
|
|
@@ -563,39 +562,19 @@ async function prepareFFmpegCmd(entry) {
|
|
|
563
562
|
// 如果没有指定输出目录,直接输出在原文件同目录
|
|
564
563
|
fileDstDir = path.resolve(srcDir)
|
|
565
564
|
}
|
|
566
|
-
if (isAudio || presets.isAudioExtract(preset)) {
|
|
567
|
-
// 不带后缀只改扩展名的m4a文件,如果存在也需要首先忽略
|
|
568
|
-
// 可能是其它压缩工具生成的文件,不需要重复压缩
|
|
569
|
-
// 检查输出目录
|
|
570
|
-
// const fileDstNoSuffix = path.join(fileDstDir, `${srcBase}${dstExt}`)
|
|
571
|
-
// if (await fs.pathExists(fileDstNoSuffix)) {
|
|
572
|
-
// log.info(
|
|
573
|
-
// logTag,
|
|
574
|
-
// `${ipx} Skip[DstM4A]: ${helper.pathShort(entry.path)} (${helper.humanSize(entry.size)})`)
|
|
575
|
-
// return false
|
|
576
|
-
// }
|
|
577
|
-
// 检查源文件同目录
|
|
578
|
-
// const fileDstSameDirNoSuffix = path.join(srcDir, `${srcBase}${dstExt}`)
|
|
579
|
-
// if (await fs.pathExists(fileDstSameDirNoSuffix)) {
|
|
580
|
-
// log.info(
|
|
581
|
-
// logTag,
|
|
582
|
-
// `${ipx} Skip[DstSame]: ${helper.pathShort(entry.path)} (${helper.humanSize(entry.size)})`)
|
|
583
|
-
// return false
|
|
584
|
-
// }
|
|
585
|
-
}
|
|
586
565
|
try {
|
|
587
566
|
// 使用ffprobe读取媒体信息,速度较慢
|
|
588
|
-
// 注意flac和ape格式的stream里没有
|
|
567
|
+
// 注意flac和ape格式的stream里没有bitrate字段 format里有
|
|
589
568
|
entry.info = await getMediaInfo(entry.path, { audio: isAudio })
|
|
590
569
|
|
|
591
570
|
// ffprobe无法读取时长和比特率,可以认为文件损坏,或不支持的格式,跳过
|
|
592
|
-
if (!(entry.info?.
|
|
571
|
+
if (!(entry.info?.duration && entry.info?.bitrate)) {
|
|
593
572
|
log.showYellow(logTag, `${ipx} Skip[BadFormat]: ${entry.path} (${helper.humanSize(entry.size)})`)
|
|
594
573
|
log.fileLog(`${ipx} Skip[BadFormat]: <${entry.path}> (${helper.humanSize(entry.size)})`, 'Prepare')
|
|
595
574
|
return false
|
|
596
575
|
}
|
|
597
|
-
const audioCodec = entry.info?.audio?.
|
|
598
|
-
const videoCodec = entry.info?.video?.
|
|
576
|
+
const audioCodec = entry.info?.audio?.format
|
|
577
|
+
const videoCodec = entry.info?.video?.format
|
|
599
578
|
if (isAudio) {
|
|
600
579
|
// 检查音频文件
|
|
601
580
|
// 放前面,因为 dstAudioBitrate 会用于前缀后缀参数
|
|
@@ -605,7 +584,7 @@ async function prepareFFmpegCmd(entry) {
|
|
|
605
584
|
entry.tags = meta?.tags
|
|
606
585
|
// 如果ffprobe或music-metadata获取的数据中有比特率数据
|
|
607
586
|
log.info(entry.name, preset.name)
|
|
608
|
-
if (entry.format?.bitrate || entry.info?.audio.
|
|
587
|
+
if (entry.format?.bitrate || entry.info?.audio.bitrate || entry.info?.bitrate) {
|
|
609
588
|
// 可以读取码率,文件未损坏
|
|
610
589
|
} else {
|
|
611
590
|
// 如果无法获取元数据,认为不是合法的音频或视频文件,忽略
|
|
@@ -613,11 +592,24 @@ async function prepareFFmpegCmd(entry) {
|
|
|
613
592
|
log.fileLog(`${ipx} Skip[Invalid]: <${entry.path}> (${helper.humanSize(entry.size)})`, 'Prepare')
|
|
614
593
|
return false
|
|
615
594
|
}
|
|
595
|
+
} else {
|
|
596
|
+
// H264 10Bit Nvidia和Intel都不支持硬解,直接跳过
|
|
597
|
+
if (entry.info?.video?.format === 'h264'
|
|
598
|
+
&& entry.info?.video.bitDepth === 10
|
|
599
|
+
&& entry.info?.video.profile?.includes('10')) {
|
|
600
|
+
log.showYellow(logTag, `${ipx} Skip[H264_10Bit]: ${entry.path} ${entry.info?.video?.pixelFormat} (${helper.humanSize(entry.size)})`)
|
|
601
|
+
log.fileLog(`${ipx} Skip[H264_10Bit]: <${entry.path}> (${helper.humanSize(entry.size)})`, 'Prepare')
|
|
602
|
+
return false
|
|
603
|
+
}
|
|
604
|
+
|
|
616
605
|
}
|
|
617
606
|
// 获取原始音频码率,计算目标音频码率
|
|
618
607
|
// vp9视频和opus音频无法获取码率
|
|
619
608
|
const dstArgs = calculateDstArgs(entry)
|
|
620
609
|
|
|
610
|
+
log.info('>>>', entry.name)
|
|
611
|
+
log.info(dstArgs)
|
|
612
|
+
|
|
621
613
|
// 计算后的视频和音频码率,关联文件
|
|
622
614
|
// 与预设独立,优先级高于预设
|
|
623
615
|
// srcXX单位为bytes dstXXX单位为kbytes
|
|
@@ -628,13 +620,13 @@ async function prepareFFmpegCmd(entry) {
|
|
|
628
620
|
log.info(logTag, entry.path, dstArgs)
|
|
629
621
|
// 如果转换目标是音频,但是源文件不含音频流,忽略
|
|
630
622
|
if (entry.preset.type === 'audio' && !audioCodec) {
|
|
631
|
-
log.showYellow(logTag, `${ipx} Skip[NoAudio]: ${entry.path}
|
|
623
|
+
log.showYellow(logTag, `${ipx} Skip[NoAudio]: ${entry.path} (${helper.humanSize(entry.size)})`)
|
|
632
624
|
log.fileLog(`${ipx} Skip[NoAudio]: <${entry.path}> (${helper.humanSize(entry.size)})`, 'Prepare')
|
|
633
625
|
return false
|
|
634
626
|
}
|
|
635
627
|
// 如果转换目标是视频,但是源文件不含视频流,忽略
|
|
636
628
|
if (entry.preset.type === 'video' && !videoCodec) {
|
|
637
|
-
log.showYellow(logTag, `${ipx} Skip[NoVideo]: ${entry.path}
|
|
629
|
+
log.showYellow(logTag, `${ipx} Skip[NoVideo]: ${entry.path} (${helper.humanSize(entry.size)})`)
|
|
638
630
|
log.fileLog(`${ipx} Skip[NoVideo]: <${entry.path}> (${helper.humanSize(entry.size)})`, 'Prepare')
|
|
639
631
|
return false
|
|
640
632
|
}
|
|
@@ -676,8 +668,6 @@ async function prepareFFmpegCmd(entry) {
|
|
|
676
668
|
let fileSubtitle = path.join(srcDir, `${srcBase}.ass`)
|
|
677
669
|
if (!(await fs.pathExists(fileSubtitle))) {
|
|
678
670
|
fileSubtitle = null
|
|
679
|
-
} else {
|
|
680
|
-
fileSubtitle = path.basename(fileSubtitle)
|
|
681
671
|
}
|
|
682
672
|
log.show(logTag, `${ipx} FR: ${helper.pathShort(entry.path, 80)}`, chalk.yellow(entry.preset.name), helper.humanTime(entry.startMs))
|
|
683
673
|
log.showGray(logTag, `${ipx} TO:`, fileDst)
|
|
@@ -798,10 +788,11 @@ function minNoZero(...numbers) {
|
|
|
798
788
|
return Math.min(...fNumbers)
|
|
799
789
|
}
|
|
800
790
|
|
|
801
|
-
|
|
791
|
+
const PIXELS_1080P = 1920 * 1080
|
|
792
|
+
// 计算视频和音频码率等各种目标文件数据
|
|
802
793
|
function calculateDstArgs(entry) {
|
|
803
794
|
const ep = entry.preset
|
|
804
|
-
const
|
|
795
|
+
const info = entry.info
|
|
805
796
|
const ivideo = entry.info?.video
|
|
806
797
|
const iaudio = entry.info?.audio
|
|
807
798
|
|
|
@@ -810,10 +801,13 @@ function calculateDstArgs(entry) {
|
|
|
810
801
|
let dstAudioBitrate = 0
|
|
811
802
|
let srcVideoBitrate = 0
|
|
812
803
|
let dstVideoBitrate = 0
|
|
804
|
+
|
|
805
|
+
const reqAudioBitrate = ep.userArgs.audioBitrate || ep.audioBitrate
|
|
806
|
+
const reqVideoBitrate = ep.userArgs.videoBitrate || ep.videoBitrate
|
|
813
807
|
if (helper.isAudioFile(entry.path)) {
|
|
814
808
|
// 音频文件
|
|
815
809
|
// 文件信息中的码率值
|
|
816
|
-
const fileBitrate = entry.format?.bitrate ||
|
|
810
|
+
const fileBitrate = entry.format?.bitrate || info?.bitrate || iaudio?.bitrate || 0
|
|
817
811
|
if (fileBitrate > 0) {
|
|
818
812
|
srcAudioBitrate = fileBitrate
|
|
819
813
|
} else {
|
|
@@ -833,7 +827,7 @@ function calculateDstArgs(entry) {
|
|
|
833
827
|
dstAudioBitrate = bitrateMap.find(br => srcAudioBitrate > br.threshold)?.value || 48 * 1000
|
|
834
828
|
} else {
|
|
835
829
|
// 智能码率关闭,直接使用用户值或预设值
|
|
836
|
-
dstAudioBitrate =
|
|
830
|
+
dstAudioBitrate = reqAudioBitrate
|
|
837
831
|
}
|
|
838
832
|
} else {
|
|
839
833
|
// 有的文件无法获取音频码率,如opus,此时srcAudioBitrate=0
|
|
@@ -845,23 +839,24 @@ function calculateDstArgs(entry) {
|
|
|
845
839
|
} else {
|
|
846
840
|
// 视频文件
|
|
847
841
|
// 这个是文件整体码率,如果是是视频文件,等于是视频和音频的码率相加
|
|
848
|
-
const fileBitrate =
|
|
849
|
-
srcAudioBitrate = iaudio?.
|
|
842
|
+
const fileBitrate = info?.bitrate || 0
|
|
843
|
+
srcAudioBitrate = iaudio?.bitrate || 0
|
|
850
844
|
// 计算出的视频码率不高于源文件的视频码率
|
|
851
845
|
// 减去音频的码率,估算为48k
|
|
852
|
-
srcVideoBitrate = ivideo?.
|
|
846
|
+
srcVideoBitrate = ivideo?.bitrate
|
|
853
847
|
|| fileBitrate - 48 * 1000 || 0
|
|
854
848
|
|
|
855
849
|
// 音频和视频码率 用户指定>预设
|
|
856
850
|
// 音频和视频码率都不能高于原码率
|
|
857
|
-
const reqAudioBitrate = ep.userArgs.audioBitrate || ep.audioBitrate
|
|
858
851
|
dstAudioBitrate = minNoZero(srcAudioBitrate, reqAudioBitrate)
|
|
859
|
-
const reqVideoBitrate = ep.userArgs.videoBitrate || ep.videoBitrate
|
|
860
852
|
dstVideoBitrate = minNoZero(srcVideoBitrate, reqVideoBitrate)
|
|
853
|
+
|
|
854
|
+
log.info(entry.name, "fileBitrate", fileBitrate, "srcVideoBitrate", srcVideoBitrate, "reqVideoBitrate", reqVideoBitrate, "dstVideoBitrate", dstVideoBitrate)
|
|
855
|
+
|
|
861
856
|
}
|
|
862
857
|
|
|
863
858
|
// 源文件时长
|
|
864
|
-
const srcDuration =
|
|
859
|
+
const srcDuration = info?.duration
|
|
865
860
|
|| ivideo?.duration
|
|
866
861
|
|| iaudio?.duration || 0
|
|
867
862
|
// 如果目标帧率大于原帧率,就将目标帧率设置为0,即让ffmpeg自动处理,不添加帧率参数
|
|
@@ -878,6 +873,26 @@ function calculateDstArgs(entry) {
|
|
|
878
873
|
const dstDimension = ep.userArgs.dimension || ep.dimension
|
|
879
874
|
const { dstWidth, dstHeight } = calculateScale(ivideo?.width || 0, ivideo?.height || 0, dstDimension)
|
|
880
875
|
|
|
876
|
+
const dstPixels = dstWidth * dstHeight
|
|
877
|
+
const dstVideoBitrateFixed = dstVideoBitrate
|
|
878
|
+
if (dstPixels > 0) {
|
|
879
|
+
let scaleFactor = dstPixels / PIXELS_1080P
|
|
880
|
+
// dstVideoBitrate针对目标是1080p的数据
|
|
881
|
+
// 如果目标码率不是1080p,根据分辨率智能缩放
|
|
882
|
+
// 示例 辨率1920*1080的目标码率是 1600k
|
|
883
|
+
// 1280*720码率 960k
|
|
884
|
+
// 3840*2160码率 2560k
|
|
885
|
+
// 0.9~1.1 之间不缩放
|
|
886
|
+
if (scaleFactor > 1.1) {
|
|
887
|
+
scaleFactor *= 0.4
|
|
888
|
+
} else if (scaleFactor < 0.9) {
|
|
889
|
+
scaleFactor *= 1.6
|
|
890
|
+
} else {
|
|
891
|
+
scaleFactor = 1
|
|
892
|
+
}
|
|
893
|
+
dstVideoBitrate = Math.round(dstVideoBitrate * scaleFactor)
|
|
894
|
+
}
|
|
895
|
+
|
|
881
896
|
const dstSpeed = ep.userArgs.speed || ep.speed
|
|
882
897
|
|
|
883
898
|
// 用于模板字符串的模板参数,针对当前文件
|
|
@@ -891,11 +906,11 @@ function calculateDstArgs(entry) {
|
|
|
891
906
|
srcDuration,
|
|
892
907
|
srcWidth: ivideo?.width || 0,
|
|
893
908
|
srcHeight: ivideo?.height || 0,
|
|
894
|
-
srcDuration:
|
|
895
|
-
srcSize:
|
|
896
|
-
srcVideoCodec: ivideo?.
|
|
897
|
-
srcAudioCodec: iaudio?.
|
|
898
|
-
srcFormat:
|
|
909
|
+
srcDuration: info?.duration || 0,
|
|
910
|
+
srcSize: info?.size || 0,
|
|
911
|
+
srcVideoCodec: ivideo?.format,
|
|
912
|
+
srcAudioCodec: iaudio?.format,
|
|
913
|
+
srcFormat: info?.format,
|
|
899
914
|
// 计算出来的参数
|
|
900
915
|
dstAudioBitrate,
|
|
901
916
|
dstVideoBitrate,
|
|
@@ -906,6 +921,9 @@ function calculateDstArgs(entry) {
|
|
|
906
921
|
dstWidth,
|
|
907
922
|
dstHeight,
|
|
908
923
|
dstSpeed,
|
|
924
|
+
// 码率智能缩放
|
|
925
|
+
audioBitrateScaled: dstAudioBitrate !== reqAudioBitrate,
|
|
926
|
+
videoBitrateScaled: dstVideoBitrate !== dstVideoBitrateFixed,
|
|
909
927
|
// 会覆盖preset的同名预设值
|
|
910
928
|
// videoBitrate: dstVideoBitrate,
|
|
911
929
|
videoBitrateK: `${Math.round(dstVideoBitrate / 1000)}K`,
|
|
@@ -927,6 +945,9 @@ function createFFmpegArgs(entry, forDisplay = false) {
|
|
|
927
945
|
// 不要使用 entry.perset,下面复制一份针对每个entry
|
|
928
946
|
const tempPreset = { ...entry.preset, ...entry.dstArgs, }
|
|
929
947
|
|
|
948
|
+
log.info('>>>>', entry.name)
|
|
949
|
+
log.info(tempPreset)
|
|
950
|
+
|
|
930
951
|
// 输入参数
|
|
931
952
|
let inputArgs = []
|
|
932
953
|
|
|
@@ -939,7 +960,7 @@ function createFFmpegArgs(entry, forDisplay = false) {
|
|
|
939
960
|
}
|
|
940
961
|
}
|
|
941
962
|
|
|
942
|
-
log.info('createFFmpegArgs', 'tempPreset', entry.name,
|
|
963
|
+
log.info('createFFmpegArgs', 'tempPreset', entry.name, tempPreset)
|
|
943
964
|
|
|
944
965
|
// 几种ffmpeg参数设置的时间和功耗
|
|
945
966
|
// ffmpeg -hide_banner -n -v error -stats -i
|
package/cmd/cmd_remove.js
CHANGED
|
@@ -21,9 +21,9 @@ import { promisify } from 'util'
|
|
|
21
21
|
import { comparePathSmartBy } from "../lib/core.js"
|
|
22
22
|
import * as log from '../lib/debug.js'
|
|
23
23
|
import * as enc from '../lib/encoding.js'
|
|
24
|
-
import { getMediaInfo } from '../lib/ffprobe.js'
|
|
25
24
|
import * as mf from '../lib/file.js'
|
|
26
25
|
import * as helper from '../lib/helper.js'
|
|
26
|
+
import { getMediaInfo } from '../lib/mediainfo.js'
|
|
27
27
|
import { addEntryProps, applyFileNameRules } from './cmd_shared.js'
|
|
28
28
|
|
|
29
29
|
// a = all, f = files, d = directories
|
|
@@ -115,6 +115,15 @@ const builder = function addOptions(ya, helpOrVersionSet) {
|
|
|
115
115
|
// 文件名列表文本文件,或者一个目录,里面包含的文件作为文件名列表来源
|
|
116
116
|
description: "File name list file, or dir contains files for file name",
|
|
117
117
|
})
|
|
118
|
+
// 视频模式,按照视频文件的元数据删除
|
|
119
|
+
// duration,dimension(width,height),bitrate
|
|
120
|
+
// 参数格式 缩写 du=xx,w=xx,h=xx,dm=xx,bit=xx
|
|
121
|
+
// duration=xx,width=xx,height=xx,bitrate=xx
|
|
122
|
+
.option("video", {
|
|
123
|
+
alias: "vdm",
|
|
124
|
+
type: "string",
|
|
125
|
+
description: "Remove files by video metadata",
|
|
126
|
+
})
|
|
118
127
|
// 要处理的文件类型 文件或目录或所有,默认只处理文件
|
|
119
128
|
.option("type", {
|
|
120
129
|
type: "choices",
|
package/cmd/cmd_rename.js
CHANGED
|
@@ -16,9 +16,9 @@ import argparser from '../lib/argparser.js'
|
|
|
16
16
|
import * as core from '../lib/core.js'
|
|
17
17
|
import * as log from '../lib/debug.js'
|
|
18
18
|
import * as enc from '../lib/encoding.js'
|
|
19
|
-
import { getMediaInfo } from '../lib/ffprobe.js'
|
|
20
19
|
import * as mf from '../lib/file.js'
|
|
21
20
|
import * as helper from '../lib/helper.js'
|
|
21
|
+
import { getMediaInfo } from '../lib/mediainfo.js'
|
|
22
22
|
import { mergePath } from '../lib/path-merge.js'
|
|
23
23
|
import { applyFileNameRules, cleanFileName, renameFiles } from "./cmd_shared.js"
|
|
24
24
|
|
|
@@ -407,9 +407,8 @@ async function preRename(f) {
|
|
|
407
407
|
let tplValues = isAudio ? info.audio : info.video
|
|
408
408
|
tplValues = {
|
|
409
409
|
...tplValues,
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
bitrate_k: Math.floor(bitrate / 1000),
|
|
410
|
+
duration: `${helper.humanSeconds(duration)}`,
|
|
411
|
+
bitrate: `${Math.floor(bitrate / 1000)}K`,
|
|
413
412
|
}
|
|
414
413
|
// 替换模板字符串
|
|
415
414
|
const base = tmpNewBase || oldBase
|
package/lib/core.js
CHANGED
|
@@ -345,6 +345,25 @@ export function copyFields(source, target, ignoreKeys = []) {
|
|
|
345
345
|
}
|
|
346
346
|
}
|
|
347
347
|
|
|
348
|
+
// 直接修改原对象, 删除某些键值
|
|
349
|
+
export function removeFieldsIn(obj, removeKeys = []) {
|
|
350
|
+
for (const key in obj) {
|
|
351
|
+
if (obj.hasOwnProperty(key)
|
|
352
|
+
&& removeKeys.includes(key)) {
|
|
353
|
+
delete obj[key]
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// 直接修改原对象,删除符合条件的键值
|
|
359
|
+
export function removeFieldsBy(obj, filterFunc) {
|
|
360
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
361
|
+
// 如果提供了过滤函数,并且该键值对不符合过滤函数的条件,则跳过该键值对
|
|
362
|
+
if (filterFunc && filterFunc(key, value)) {
|
|
363
|
+
delete obj[key]
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
348
367
|
|
|
349
368
|
// 去掉一个object所有值不是基本类型的字段
|
|
350
369
|
// 保留值类型为字符串、数字、布尔值的字段
|
package/lib/ffmpeg_presets.js
CHANGED
|
@@ -24,6 +24,7 @@ const BIT_RATE_1600K = UNIT_KB * 1600
|
|
|
24
24
|
const BIT_RATE_2000K = UNIT_KB * 2000
|
|
25
25
|
const BIT_RATE_4000K = UNIT_KB * 4000
|
|
26
26
|
const BIT_RATE_6000K = UNIT_KB * 6000
|
|
27
|
+
const BIT_RATE_8M = UNIT_MB * 8
|
|
27
28
|
const BIT_RATE_10M = UNIT_MB * 10
|
|
28
29
|
const BIT_RATE_16M = UNIT_MB * 16
|
|
29
30
|
|
|
@@ -279,6 +280,8 @@ const HEVC_BASE = new FFmpegPreset('hevc-base', {
|
|
|
279
280
|
// 快速读取和播放
|
|
280
281
|
outputArgs: '-movflags +faststart -movflags use_metadata_tags',
|
|
281
282
|
// -vf 'scale=if(gte(iw\,ih)\,min(1280\,iw)\,-2):if(lt(iw\,ih)\,min(1280\,ih)\,-2)'
|
|
283
|
+
// 前面如果不加 hwupload_cuda 某些10bit视频会报错
|
|
284
|
+
// 查了半天,发现Nvidia和Intel都不支持H264-10bit的硬解,但是HEVC-10bit可以
|
|
282
285
|
filters: "scale_cuda='if(gte(iw,ih),min({dimension},iw),-2)':'if(lt(iw,ih),min({dimension},ih),-2)':interp_algo=lanczos",
|
|
283
286
|
complexFilter: '',
|
|
284
287
|
})
|
|
@@ -299,7 +302,31 @@ const PRESET_HEVC_4K = FFmpegPreset.fromPreset(HEVC_BASE).update({
|
|
|
299
302
|
dimension: 3840
|
|
300
303
|
})
|
|
301
304
|
|
|
302
|
-
const
|
|
305
|
+
const PRESET_HEVC_4K_LOW = FFmpegPreset.fromPreset(HEVC_BASE).update({
|
|
306
|
+
name: 'hevc_4kl',
|
|
307
|
+
videoQuality: 24,
|
|
308
|
+
videoBitrate: BIT_RATE_6000K,
|
|
309
|
+
audioBitrate: BIT_RATE_256K,
|
|
310
|
+
dimension: 3840
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
const PRESET_HEVC_4K_LOWEST = FFmpegPreset.fromPreset(HEVC_BASE).update({
|
|
314
|
+
name: 'hevc_4kt',
|
|
315
|
+
videoQuality: 26,
|
|
316
|
+
videoBitrate: BIT_RATE_4000K,
|
|
317
|
+
audioBitrate: BIT_RATE_192K,
|
|
318
|
+
dimension: 3840
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
const PRESET_HEVC_2K_ULTRA = FFmpegPreset.fromPreset(HEVC_BASE).update({
|
|
322
|
+
name: 'hevc_2ku',
|
|
323
|
+
videoQuality: 22,
|
|
324
|
+
videoBitrate: BIT_RATE_8M,
|
|
325
|
+
audioBitrate: BIT_RATE_256K,
|
|
326
|
+
dimension: 1920
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
const PRESET_HEVC_2K_HIGH = FFmpegPreset.fromPreset(HEVC_BASE).update({
|
|
303
330
|
name: 'hevc_2kh',
|
|
304
331
|
videoQuality: 22,
|
|
305
332
|
videoBitrate: BIT_RATE_6000K,
|
|
@@ -315,7 +342,7 @@ const PRESET_HEVC_2K = FFmpegPreset.fromPreset(HEVC_BASE).update({
|
|
|
315
342
|
dimension: 1920
|
|
316
343
|
})
|
|
317
344
|
|
|
318
|
-
const
|
|
345
|
+
const PRESET_HEVC_2K_MEDIUM = FFmpegPreset.fromPreset(HEVC_BASE).update({
|
|
319
346
|
name: 'hevc_2km',
|
|
320
347
|
videoQuality: 26,
|
|
321
348
|
videoBitrate: BIT_RATE_2000K,
|
|
@@ -323,7 +350,7 @@ const PRESET_HEVC_MEDIUM = FFmpegPreset.fromPreset(HEVC_BASE).update({
|
|
|
323
350
|
dimension: 1920
|
|
324
351
|
})
|
|
325
352
|
|
|
326
|
-
const
|
|
353
|
+
const PRESET_HEVC_2K_LOW = FFmpegPreset.fromPreset(HEVC_BASE).update({
|
|
327
354
|
name: 'hevc_2kl',
|
|
328
355
|
videoQuality: 26,
|
|
329
356
|
videoBitrate: BIT_RATE_1600K,
|
|
@@ -331,7 +358,7 @@ const PRESET_HEVC_LOW = FFmpegPreset.fromPreset(HEVC_BASE).update({
|
|
|
331
358
|
dimension: 1920
|
|
332
359
|
})
|
|
333
360
|
|
|
334
|
-
const
|
|
361
|
+
const PRESET_HEVC_2K_LOWEST = FFmpegPreset.fromPreset(HEVC_BASE).update({
|
|
335
362
|
name: 'hevc_2kt',
|
|
336
363
|
videoQuality: 28,
|
|
337
364
|
videoBitrate: BIT_RATE_1200K,
|
|
@@ -485,23 +512,25 @@ function initPresets() {
|
|
|
485
512
|
PRESET_H264_MEDIUM: PRESET_H264_MEDIUM,
|
|
486
513
|
// H264 LOW
|
|
487
514
|
PRESET_H264_LOW: PRESET_H264_LOW,
|
|
488
|
-
//
|
|
489
|
-
PRESET_HEVC_ULTRA: PRESET_HEVC_ULTRA,
|
|
490
515
|
//4K超高码率和质量
|
|
491
516
|
//4K超高码率和质量
|
|
492
517
|
PRESET_HEVC_ULTRA: PRESET_HEVC_ULTRA,
|
|
493
518
|
//4k高码率和质量
|
|
494
519
|
PRESET_HEVC_4K: PRESET_HEVC_4K,
|
|
520
|
+
PRESET_HEVC_4K_LOW: PRESET_HEVC_4K_LOW,
|
|
521
|
+
PRESET_HEVC_4K_LOWEST: PRESET_HEVC_4K_LOWEST,
|
|
522
|
+
// 2K极致质量
|
|
523
|
+
PRESET_HEVC_2K_ULTRA: PRESET_HEVC_2K_ULTRA,
|
|
495
524
|
// 2K高码率和质量
|
|
496
|
-
PRESET_HEVC_HIGH:
|
|
525
|
+
PRESET_HEVC_HIGH: PRESET_HEVC_2K_HIGH,
|
|
497
526
|
// 2K默认码率和质量
|
|
498
527
|
PRESET_HEVC_2K: PRESET_HEVC_2K,
|
|
499
528
|
// 2K中码率和质量
|
|
500
|
-
PRESET_HEVC_MEDIUM:
|
|
529
|
+
PRESET_HEVC_MEDIUM: PRESET_HEVC_2K_MEDIUM,
|
|
501
530
|
// 2K低码率和质量
|
|
502
|
-
PRESET_HEVC_LOW:
|
|
531
|
+
PRESET_HEVC_LOW: PRESET_HEVC_2K_LOW,
|
|
503
532
|
// 极低画质和码率
|
|
504
|
-
PRESET_HEVC_LOWEST:
|
|
533
|
+
PRESET_HEVC_LOWEST: PRESET_HEVC_2K_LOWEST,
|
|
505
534
|
// 极速模式,适用于视频教程
|
|
506
535
|
PRESET_HEVC_SPEED: PRESET_HEVC_SPEED,
|
|
507
536
|
// 提取视频中的音频,复制或转换为AAC格式
|
package/lib/file.js
CHANGED
|
@@ -80,8 +80,8 @@ export async function walk(root, options = {}) {
|
|
|
80
80
|
ctime: st.ctime,
|
|
81
81
|
mtime: st.mtime,
|
|
82
82
|
size: st.size || 0,
|
|
83
|
-
isDir: st
|
|
84
|
-
isFile: st
|
|
83
|
+
isDir: st?.isDirectory() || false,
|
|
84
|
+
isFile: st?.isFile() || false,
|
|
85
85
|
index,
|
|
86
86
|
}
|
|
87
87
|
log.debug(
|
|
@@ -97,7 +97,7 @@ export async function walk(root, options = {}) {
|
|
|
97
97
|
}
|
|
98
98
|
return entry
|
|
99
99
|
} catch (error) {
|
|
100
|
-
log.
|
|
100
|
+
log.error(
|
|
101
101
|
logTag,
|
|
102
102
|
error,
|
|
103
103
|
pathShort(fpath)
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Project: mediac
|
|
3
|
+
* Created: 2024-05-02 17:22:06
|
|
4
|
+
* Modified: 2024-05-02 17:22:06
|
|
5
|
+
* Author: mcxiaoke (github@mcxiaoke.com)
|
|
6
|
+
* License: Apache License 2.0
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { removeFieldsBy, roundNum } from './core.js'
|
|
10
|
+
|
|
11
|
+
class MediaStreamBase {
|
|
12
|
+
constructor(
|
|
13
|
+
{
|
|
14
|
+
type, // 媒体类型
|
|
15
|
+
format, // 格式名称
|
|
16
|
+
codec, // 编解码器名称
|
|
17
|
+
profile, // 编解码器配置概况
|
|
18
|
+
size, // 文件大小
|
|
19
|
+
duration, // 时长
|
|
20
|
+
bitrate, // 比特率
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
) {
|
|
24
|
+
this.type = type // 媒体类型
|
|
25
|
+
this.format = format // 格式名称
|
|
26
|
+
this.codec = codec // 编解码器名称
|
|
27
|
+
this.profile = profile // 编解码器配置概况
|
|
28
|
+
this.size = size || 0 // 文件大小
|
|
29
|
+
this.duration = duration // 时长
|
|
30
|
+
this.bitrate = bitrate // 比特率
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class Video extends MediaStreamBase {
|
|
35
|
+
constructor(
|
|
36
|
+
{
|
|
37
|
+
type, // 媒体类型
|
|
38
|
+
format, // 格式名称
|
|
39
|
+
codec, // 编解码器名称
|
|
40
|
+
profile, // 编解码器配置概况
|
|
41
|
+
size, // 文件大小
|
|
42
|
+
duration, // 时长
|
|
43
|
+
bitrate, // 比特率
|
|
44
|
+
|
|
45
|
+
framerate, // 帧率
|
|
46
|
+
bitDepth, // 位深 8bit or 10bit
|
|
47
|
+
width, // 视频宽度
|
|
48
|
+
height, // 视频高度
|
|
49
|
+
aspectRatio, // 宽高比
|
|
50
|
+
pixelFormat, // 像素格式
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
) {
|
|
54
|
+
super({ type, format, codec, profile, size, duration, bitrate })
|
|
55
|
+
this.framerate = framerate // 帧率
|
|
56
|
+
this.bitDepth = bitDepth // 位深 8bit or 10bit
|
|
57
|
+
this.width = width // 视频宽度
|
|
58
|
+
this.height = height // 视频高度
|
|
59
|
+
this.aspectRatio = aspectRatio
|
|
60
|
+
this.pixelFormat = pixelFormat
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
class Audio extends MediaStreamBase {
|
|
66
|
+
constructor(
|
|
67
|
+
{
|
|
68
|
+
type, // 媒体类型
|
|
69
|
+
format, // 格式名称
|
|
70
|
+
codec, // 编解码器名称
|
|
71
|
+
profile, // 编解码器配置概况
|
|
72
|
+
size, // 文件大小
|
|
73
|
+
duration, // 时长
|
|
74
|
+
bitrate, // 比特率
|
|
75
|
+
sampleRate, // 采样率
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
) {
|
|
79
|
+
super({ type, format, codec, profile, size, duration, bitrate })
|
|
80
|
+
this.sampleRate = sampleRate
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
class MediaInfo {
|
|
85
|
+
constructor(
|
|
86
|
+
{
|
|
87
|
+
provider, // 解析器
|
|
88
|
+
format, // 文件的格式类型(如:video/mp4)
|
|
89
|
+
size, // 文件大小,单位根据情况设定(如字节)
|
|
90
|
+
duration, // 时长,单位根据情况设定(如秒)
|
|
91
|
+
bitrate, // 平均比特率,单位根据情况设定(如bps)
|
|
92
|
+
createdAt, // 创建时间
|
|
93
|
+
audio, // audio stream 音频流
|
|
94
|
+
video, // video stream 视频流
|
|
95
|
+
}
|
|
96
|
+
) {
|
|
97
|
+
this.provider = provider
|
|
98
|
+
this.format = format
|
|
99
|
+
this.size = size
|
|
100
|
+
this.duration = duration
|
|
101
|
+
this.bitrate = bitrate
|
|
102
|
+
this.createdAt = createdAt
|
|
103
|
+
this.audio = audio
|
|
104
|
+
this.video = video
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function fromFFprobe(data) {
|
|
109
|
+
const obj = {
|
|
110
|
+
type: data['codec_type'], // 媒体类型
|
|
111
|
+
format: data['codec_name'], // 格式名称
|
|
112
|
+
codec: data['codec_tag_string'], // 编解码器名称
|
|
113
|
+
profile: data['profile'], // 编解码器配置概况
|
|
114
|
+
bitDepth: data['bits_per_raw_sample']
|
|
115
|
+
|| data['bits_per_raw_sample'], // 位深 8bit or 10bit
|
|
116
|
+
size: data['size']
|
|
117
|
+
|| data['tags']?.['NUMBER_OF_BYTES'], // 文件大小
|
|
118
|
+
duration: data['duration']
|
|
119
|
+
|| data['tags']?.['DURATION'], // 时长
|
|
120
|
+
bitrate: data['bit_rate']
|
|
121
|
+
|| data['tags']?.['BPS'], // 比特率
|
|
122
|
+
framerate: data['r_frame_rate'], // 帧率
|
|
123
|
+
pixelFormat: data['pix_fmt'],
|
|
124
|
+
width: data['width'], // 视频宽度
|
|
125
|
+
height: data['height'], // 视频高度
|
|
126
|
+
aspectRatio: data['display_aspect_ratio'],
|
|
127
|
+
sampleRate: data['sample_rate'],
|
|
128
|
+
language: data['tags']?.['language'], // 语言
|
|
129
|
+
}
|
|
130
|
+
return createStreamData(obj)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function fromMediaInfo(data) {
|
|
134
|
+
// console.log('fromMediaInfo', data)
|
|
135
|
+
const obj = {
|
|
136
|
+
type: data['@type'].toLowerCase(), // 媒体类型
|
|
137
|
+
format: data['Format'].toLowerCase(), // 格式名称
|
|
138
|
+
codec: data['CodecID'], // 编解码器名称
|
|
139
|
+
profile: data['Format_Profile'], // 编解码器配置概况
|
|
140
|
+
bitDepth: data['BitDepth'], // 位深 8bit or 10bit
|
|
141
|
+
size: data['StreamSize'], // 文件大小
|
|
142
|
+
duration: data['Duration'], // 时长
|
|
143
|
+
bitrate: data['BitRate'], // 比特率
|
|
144
|
+
framerate: data['FrameRate_Num'] || data['FrameRate'], // 帧率
|
|
145
|
+
pixelFormat: data['ColorSpace'] ?
|
|
146
|
+
data['ColorSpace'] + data['ChromaSubsampling'] : undefined,
|
|
147
|
+
width: data['Width'], // 视频宽度
|
|
148
|
+
height: data['Height'], // 视频高度
|
|
149
|
+
aspectRatio: data['DisplayAspectRatio'],
|
|
150
|
+
sampleRate: data['SamplingRate'],
|
|
151
|
+
language: data['language'], // 语言
|
|
152
|
+
}
|
|
153
|
+
return createStreamData(obj)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function createStreamData(obj) {
|
|
157
|
+
const isAudio = obj.type === 'audio'
|
|
158
|
+
const stream = isAudio ? (new Audio(obj)) : (new Video(obj))
|
|
159
|
+
removeFieldsBy(stream, (k, v) => v === undefined || v === null)
|
|
160
|
+
return stream
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 解析ffprobe json输出,返回MediaInfo
|
|
164
|
+
export function fromFFprobeJson(data) {
|
|
165
|
+
// console.log('MediaInfo.fromFFprobeJson', data)
|
|
166
|
+
const root = data.format
|
|
167
|
+
const ad = data.streams?.find(obj => obj.codec_type === "audio")
|
|
168
|
+
const vd = data.streams?.find(obj => obj.codec_type === "video")
|
|
169
|
+
const obj = {
|
|
170
|
+
provider: 'ffprobe',
|
|
171
|
+
format: root['format_long_name'],
|
|
172
|
+
size: root['size'],
|
|
173
|
+
duration: root['duration'],
|
|
174
|
+
bitrate: root['bit_rate'],
|
|
175
|
+
createdAt: root['tags']?.['creation_time'],
|
|
176
|
+
audio: ad && fromFFprobe(ad),
|
|
177
|
+
video: vd && fromFFprobe(vd),
|
|
178
|
+
}
|
|
179
|
+
const info = new MediaInfo(convertNumber(obj))
|
|
180
|
+
removeFieldsBy(info, (k, v) => v === undefined || v === null)
|
|
181
|
+
return info
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 解析mediainfo json输出,返回MediaInfo
|
|
185
|
+
export function fromMediaInfoJson(data) {
|
|
186
|
+
// console.log('MediaInfo.fromMediaInfoJson', data)
|
|
187
|
+
const root = data.media?.track?.find(o => o['@type'] === "General")
|
|
188
|
+
const ad = data.media?.track?.find(o => o['@type'] === "Audio")
|
|
189
|
+
const vd = data.media?.track?.find(o => o['@type'] === "Video")
|
|
190
|
+
const obj = {
|
|
191
|
+
provider: 'mediainfo',
|
|
192
|
+
format: root['Format']?.toLowerCase(),
|
|
193
|
+
size: root['FileSize'],
|
|
194
|
+
duration: root['Duration'],
|
|
195
|
+
bitrate: root['OverallBitRate'],
|
|
196
|
+
createdAt: root['Encoded_Date'],
|
|
197
|
+
audio: ad && fromMediaInfo(ad),
|
|
198
|
+
video: vd && fromMediaInfo(vd),
|
|
199
|
+
}
|
|
200
|
+
const info = new MediaInfo(convertNumber(obj))
|
|
201
|
+
removeFieldsBy(info, (k, v) => v === undefined || v === null)
|
|
202
|
+
return info
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 计算平均码率的方法
|
|
206
|
+
// 如果是单音频文件,还有一个码率计算方式
|
|
207
|
+
// fileSize / duration * 8 = bitrate
|
|
208
|
+
// 或者如果知道流大小,也可以计算出来
|
|
209
|
+
// streamSize / duration * 8 = bitrate
|
|
210
|
+
// 还可以用用ffmpeg读取元数据,需要解析
|
|
211
|
+
// ffmpeg -hide_banner -i video.mp4 -c copy -f null -
|
|
212
|
+
|
|
213
|
+
// "tags": {
|
|
214
|
+
// "title": "Encode By H-Enc",
|
|
215
|
+
// "BPS": "2430147",
|
|
216
|
+
// "DURATION": "00:16:44.003000000",
|
|
217
|
+
// }
|
|
218
|
+
// 提取tags里DURATION字段
|
|
219
|
+
// 示例 00:16:43.946000000
|
|
220
|
+
// 示例 00:16:44.003000000
|
|
221
|
+
// 分割 (00):(16):(44.003000000)
|
|
222
|
+
function extractDuration(timeString) {
|
|
223
|
+
// 使用 match 方法匹配时间字符串中的各个部分
|
|
224
|
+
const match = timeString.match(/(\d{2}):(\d{2}):(\d{2}(?:\.\d+))?/)
|
|
225
|
+
|
|
226
|
+
if (match) {
|
|
227
|
+
// log.showBlue(timeString, match)
|
|
228
|
+
// 解构赋值提取匹配到的时间部分
|
|
229
|
+
const [, hours, minutes, seconds] = match.map(Number)
|
|
230
|
+
// 将时间部分转换成秒
|
|
231
|
+
return roundNum(hours * 3600 + minutes * 60 + seconds)
|
|
232
|
+
} else {
|
|
233
|
+
return 0
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// 字符串值转为数字值,修改原对象
|
|
238
|
+
function convertNumber(obj) {
|
|
239
|
+
// 兼容空值
|
|
240
|
+
if (!obj) return obj
|
|
241
|
+
// 遍历对象的所有属性
|
|
242
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
243
|
+
// 匹配 DURATION
|
|
244
|
+
if (key === 'DURATION') {
|
|
245
|
+
obj[key] = extractDuration(value)
|
|
246
|
+
continue
|
|
247
|
+
}
|
|
248
|
+
// 对于对象类型,递归处理
|
|
249
|
+
if (typeof value === 'object') {
|
|
250
|
+
obj[key] = convertNumber(value)
|
|
251
|
+
continue
|
|
252
|
+
}
|
|
253
|
+
// 检查属性的值是否为字符串类型且可以转换为数字
|
|
254
|
+
else if (typeof value === 'string') {
|
|
255
|
+
// 匹配字符串数字
|
|
256
|
+
if (/^\d+(\.\d+)?$/.test(value)) {
|
|
257
|
+
// 解析字符串数字
|
|
258
|
+
// 如果可以转换为数字,则将其转换并更新对象的值
|
|
259
|
+
obj[key] = roundNum(parseFloat(value))
|
|
260
|
+
} else if (key.includes('frame_rate')) {
|
|
261
|
+
// 解析 '25/1' 这种 r_frame_rate 字段值
|
|
262
|
+
const regex = /(\d+)\/(\d+)/
|
|
263
|
+
const match = value.match(regex)
|
|
264
|
+
if (match) {
|
|
265
|
+
const numerator = parseInt(match[1])
|
|
266
|
+
const denominator = parseInt(match[2])
|
|
267
|
+
if (denominator !== 0) {
|
|
268
|
+
obj[key] = roundNum(numerator / denominator)
|
|
269
|
+
} else {
|
|
270
|
+
obj[key] = 0
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return obj
|
|
278
|
+
}
|
package/lib/mediainfo.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Project: mediac
|
|
3
|
+
* Created: 2024-04-20 17:00:36
|
|
4
|
+
* Modified: 2024-04-20 17:00:36
|
|
5
|
+
* Author: mcxiaoke (github@mcxiaoke.com)
|
|
6
|
+
* License: Apache License 2.0
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execa } from 'execa'
|
|
10
|
+
import iconv from 'iconv-lite'
|
|
11
|
+
import { roundNum } from './core.js'
|
|
12
|
+
import * as log from './debug.js'
|
|
13
|
+
import { fromFFprobeJson, fromMediaInfoJson } from './media_parser.js'
|
|
14
|
+
import { trySmartAsync } from './tryfp.js'
|
|
15
|
+
|
|
16
|
+
import which from 'which'
|
|
17
|
+
|
|
18
|
+
export const FFMPEG_BINARY = 'ffmpeg'
|
|
19
|
+
export const FFPROBE_BINARY = 'ffprobe'
|
|
20
|
+
export const MEDIAINFO_BINARY = 'mediainfo'
|
|
21
|
+
|
|
22
|
+
// 检测可执行文件是否存在
|
|
23
|
+
const HAS_FFPROBE_EXE = await which(FFPROBE_BINARY, { nothrow: true })
|
|
24
|
+
const HAS_MEDIAINFO_EXE = await which(MEDIAINFO_BINARY, { nothrow: true })
|
|
25
|
+
|
|
26
|
+
function fixEncoding(str = '') {
|
|
27
|
+
return iconv.decode(Buffer.from(str, 'binary'), 'gbk')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ffprobe -v error -show_entries 'stream=codec_name,codec_long_name,profile,codec_type,codec_tag_string,width,height,display_aspect_ratio,pix_fmt,duration,bit_rate,sample_rate,sample_fmt,time_base,r_frame_rate,channels,bits_per_sample:format=format_name,format_long_name,duration,size,bit_rate:format_tags:stream_tags' -of json
|
|
31
|
+
// 获取媒体文件信息 使用ffprobe
|
|
32
|
+
async function ffprobeCall(filePath) {
|
|
33
|
+
// 只选择需要的字段,避免乱码和非法JSON
|
|
34
|
+
// 有的文件视频和音频duration和bit_rate放在stream_tags里
|
|
35
|
+
const propsSelected = 'stream=codec_name,codec_long_name,profile,codec_type,codec_tag_string,width,height,display_aspect_ratio,pix_fmt,duration,bit_rate,sample_rate,sample_fmt,time_base,r_frame_rate,avg_frame_rate,channels,bits_per_sample,bits_per_raw_sample:format=format_name,format_long_name,duration,size,bit_rate:stream_tags:format_tags=creation_time'
|
|
36
|
+
const cmdArgs = ['-v', 'error']
|
|
37
|
+
cmdArgs.push('-show_entries', propsSelected)
|
|
38
|
+
cmdArgs.push('-of', 'json', filePath)
|
|
39
|
+
// 使用 execa 执行 ffprobe 命令
|
|
40
|
+
const { stdout, stderr } = await execa(FFPROBE_BINARY, cmdArgs, { encoding: 'binary' })
|
|
41
|
+
const info = JSON.parse(stdout)
|
|
42
|
+
log.debug('ffprobeCall', filePath, info)
|
|
43
|
+
return info
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 获取媒体文件信息 使用mediainfo
|
|
47
|
+
async function mediainfoCall(filePath) {
|
|
48
|
+
const cmdArgs = ['--Output=JSON', filePath]
|
|
49
|
+
// 使用 execa 执行 ffprobe 命令
|
|
50
|
+
const { stdout, stderr } = await execa(MEDIAINFO_BINARY, cmdArgs, { encoding: 'binary' })
|
|
51
|
+
log.showGray(stderr)
|
|
52
|
+
const result = JSON.parse(stdout)
|
|
53
|
+
log.debug('ffprobeCall', filePath, result)
|
|
54
|
+
return result
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function parseFFProbe(filePath) {
|
|
58
|
+
return fromFFprobeJson(await ffprobeCall(filePath))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function parseMediaInfo(filePath) {
|
|
62
|
+
return fromMediaInfoJson(await mediainfoCall(filePath))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
export async function getMediaInfo(filePath, options = { ffprobe: false }) {
|
|
67
|
+
if (!HAS_FFPROBE_EXE && !HAS_MEDIAINFO_EXE) {
|
|
68
|
+
throw new Error('both ffprobe and mediainfo binary not found')
|
|
69
|
+
}
|
|
70
|
+
let [err1, data1] = await trySmartAsync(options.ffprobe ? parseFFProbe : parseMediaInfo)(filePath)
|
|
71
|
+
if (err1) {
|
|
72
|
+
let [err2, data2] = await trySmartAsync(options.ffprobe ? parseMediaInfo : parseFFProbe)(filePath)
|
|
73
|
+
if (data2) {
|
|
74
|
+
return data2
|
|
75
|
+
}
|
|
76
|
+
err1 && log.error('getMediaInfo', fixEncoding(err1?.message))
|
|
77
|
+
err2 && log.error('getMediaInfo', fixEncoding(err2?.message))
|
|
78
|
+
} else {
|
|
79
|
+
return data1
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function getSimpleInfo(filePath, options = {}) {
|
|
84
|
+
const info = await getMediaInfo(filePath, options)
|
|
85
|
+
const arr = []
|
|
86
|
+
if (!info?.duration) {
|
|
87
|
+
arr.push('ERROR: failed to get media info!')
|
|
88
|
+
}
|
|
89
|
+
arr.push(`format=${info?.format},duration=${info?.duration}s,bitrate=${roundNum((info?.bitrate || 0) / 1000)}K`)
|
|
90
|
+
if (info?.audio) {
|
|
91
|
+
const a = info?.audio
|
|
92
|
+
arr.push(`a:codec=${a.format},bitrate=${roundNum(a.bitrate || 0 / 1000)}K`)
|
|
93
|
+
}
|
|
94
|
+
if (info?.video) {
|
|
95
|
+
const v = info?.video
|
|
96
|
+
arr.push(`v:codec=${v.format},bitrate=${roundNum(v.bitrate || 0 / 1000)}K,fps=${v.framerate},size=${v.width}x${v.height}`)
|
|
97
|
+
}
|
|
98
|
+
return arr
|
|
99
|
+
}
|
package/lib/shared.js
CHANGED
package/lib/tryfp.js
CHANGED
|
@@ -44,10 +44,8 @@ const trySmartAsync = tryWrapAsync(throwNativeErr)
|
|
|
44
44
|
const tryCatch = tryWrap()
|
|
45
45
|
const tryCatchAsync = tryWrapAsync()
|
|
46
46
|
|
|
47
|
-
export
|
|
48
|
-
setNoneValue,
|
|
49
|
-
trySmart,
|
|
50
|
-
trySmartAsync
|
|
51
|
-
|
|
52
|
-
tryCatchAsync,
|
|
53
|
-
}
|
|
47
|
+
export {
|
|
48
|
+
setNoneValue, tryCatch,
|
|
49
|
+
tryCatchAsync, trySmart,
|
|
50
|
+
trySmartAsync
|
|
51
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mediac",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "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.",
|
|
6
6
|
"main": "index.js",
|
package/lib/ffprobe.js
DELETED
|
@@ -1,205 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Project: mediac
|
|
3
|
-
* Created: 2024-04-20 17:00:36
|
|
4
|
-
* Modified: 2024-04-20 17:00:36
|
|
5
|
-
* Author: mcxiaoke (github@mcxiaoke.com)
|
|
6
|
-
* License: Apache License 2.0
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { execa } from 'execa'
|
|
10
|
-
import * as log from '../lib/debug.js'
|
|
11
|
-
import { roundNum } from './core.js'
|
|
12
|
-
import { FFPROBE_BINARY } from './shared.js'
|
|
13
|
-
|
|
14
|
-
// ffprobe -v error -show_entries 'stream=codec_name,codec_long_name,profile,codec_type,codec_tag_string,width,height,display_aspect_ratio,pix_fmt,duration,bit_rate,sample_rate,sample_fmt,time_base,r_frame_rate,channels,bits_per_sample:format=format_name,format_long_name,duration,size,bit_rate:format_tags:stream_tags' -of json
|
|
15
|
-
// 获取媒体文件信息的函数
|
|
16
|
-
export async function getMediaInfo(filePath, options = { video: false, audio: false, fullData: false }) {
|
|
17
|
-
// 只选择需要的字段,避免乱码和非法JSON
|
|
18
|
-
// 有的文件视频和音频duration和bit_rate放在stream_tags里
|
|
19
|
-
const propsSelected = 'stream=codec_name,codec_long_name,profile,codec_type,codec_tag_string,width,height,display_aspect_ratio,pix_fmt,duration,bit_rate,sample_rate,sample_fmt,time_base,r_frame_rate,avg_frame_rate,channels,bits_per_sample:format=format_name,format_long_name,duration,size,bit_rate:stream_tags'
|
|
20
|
-
const selectJsonArgs = ['-v', 'error']
|
|
21
|
-
// 只选择 audio stream
|
|
22
|
-
if (options.audio) {
|
|
23
|
-
selectJsonArgs.push('-select_streams', 'a:0')
|
|
24
|
-
}
|
|
25
|
-
if (options.video) {
|
|
26
|
-
//只选择 video stream
|
|
27
|
-
selectJsonArgs.push('-select_streams', 'v:0')
|
|
28
|
-
}
|
|
29
|
-
selectJsonArgs.push('-show_entries', propsSelected)
|
|
30
|
-
selectJsonArgs.push('-of', 'json', filePath)
|
|
31
|
-
try {
|
|
32
|
-
// 使用 execa 执行 ffprobe 命令
|
|
33
|
-
const { stdout, stderr } = await execa(FFPROBE_BINARY, selectJsonArgs)
|
|
34
|
-
// console.log(stdout)
|
|
35
|
-
// console.log(stderr)
|
|
36
|
-
// return parseFFProbeOutput(stdout)
|
|
37
|
-
const info = JSON.parse(stdout)
|
|
38
|
-
// 只返回stream第一项
|
|
39
|
-
// format作为子项
|
|
40
|
-
log.debug('getMediaInfo', filePath, info)
|
|
41
|
-
// 只返回第一条视频轨和第一条音轨
|
|
42
|
-
// 如果有fullData参数,返回全部streams数据
|
|
43
|
-
const result = {
|
|
44
|
-
video: convertNumber(info.streams.find(obj => obj.codec_type === "video")),
|
|
45
|
-
audio: convertNumber(info.streams.find(obj => obj.codec_type === "audio")),
|
|
46
|
-
format: convertNumber(info.format),
|
|
47
|
-
streams: options.fullData ? info.streams : undefined,
|
|
48
|
-
}
|
|
49
|
-
if (result.video) {
|
|
50
|
-
result.video.duration = result.video?.duration || result.video?.tags?.DURATION
|
|
51
|
-
result.video.bit_rate = result.video?.bit_rate || result.video?.tags?.BPS
|
|
52
|
-
result.video.framerate = result.video?.r_frame_rate || result.video?.avg_frame_rate
|
|
53
|
-
}
|
|
54
|
-
// 特殊处理,有些视频文件的时长和码率参数放在stream_tags里
|
|
55
|
-
if (result.audio) {
|
|
56
|
-
result.audio.duration = result.audio?.duration || result.audio?.tags?.DURATION
|
|
57
|
-
result.audio.bit_rate = result.audio?.bit_rate || result.audio?.tags?.BPS
|
|
58
|
-
}
|
|
59
|
-
return result
|
|
60
|
-
} catch (error) {
|
|
61
|
-
log.warn(`ERROR:`, error.message?.slice(-160))
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export async function getSimpleInfo(filePath, options = {}) {
|
|
66
|
-
const info = await getMediaInfo(filePath, options)
|
|
67
|
-
const arr = []
|
|
68
|
-
if (info?.audio) {
|
|
69
|
-
const a = info?.audio
|
|
70
|
-
arr.push(`a:codec=${a.codec_name},bitrate=${roundNum(a.bit_rate / 1000)}K,time=${Math.floor(a.duration)}s`)
|
|
71
|
-
}
|
|
72
|
-
if (info?.video) {
|
|
73
|
-
const v = info?.video
|
|
74
|
-
arr.push(`v:codec=${v.codec_name},bitrate=${roundNum(v.bit_rate / 1000)}K,time=${Math.floor(v.duration)}s,fps=${v.r_frame_rate},size=${v.width}x${v.height}`)
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return arr
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// "tags": {
|
|
81
|
-
// "title": "Encode By H-Enc",
|
|
82
|
-
// "BPS": "2430147",
|
|
83
|
-
// "DURATION": "00:16:44.003000000",
|
|
84
|
-
// }
|
|
85
|
-
// 提取tags里DURATION字段
|
|
86
|
-
// 示例 00:16:43.946000000
|
|
87
|
-
// 示例 00:16:44.003000000
|
|
88
|
-
// 分割 (00):(16):(44.003000000)
|
|
89
|
-
function extractDuration(timeString) {
|
|
90
|
-
// 使用 match 方法匹配时间字符串中的各个部分
|
|
91
|
-
const match = timeString.match(/(\d{2}):(\d{2}):(\d{2}(?:\.\d+))?/)
|
|
92
|
-
|
|
93
|
-
if (match) {
|
|
94
|
-
// log.showBlue(timeString, match)
|
|
95
|
-
// 解构赋值提取匹配到的时间部分
|
|
96
|
-
const [, hours, minutes, seconds] = match.map(Number)
|
|
97
|
-
// 将时间部分转换成秒
|
|
98
|
-
return roundNum(hours * 3600 + minutes * 60 + seconds)
|
|
99
|
-
} else {
|
|
100
|
-
return 0
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// 字符串值转为数字值,修改原对象
|
|
105
|
-
function convertNumber(obj) {
|
|
106
|
-
// 兼容空值
|
|
107
|
-
if (!obj) return obj
|
|
108
|
-
// 遍历对象的所有属性
|
|
109
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
110
|
-
// 匹配 DURATION
|
|
111
|
-
if (key === 'DURATION') {
|
|
112
|
-
obj[key] = extractDuration(value)
|
|
113
|
-
continue
|
|
114
|
-
}
|
|
115
|
-
// 对于对象类型,递归处理
|
|
116
|
-
if (typeof value === 'object') {
|
|
117
|
-
obj[key] = convertNumber(value)
|
|
118
|
-
continue
|
|
119
|
-
}
|
|
120
|
-
// 检查属性的值是否为字符串类型且可以转换为数字
|
|
121
|
-
else if (typeof value === 'string') {
|
|
122
|
-
// 匹配字符串数字
|
|
123
|
-
if (/^\d+(\.\d+)?$/.test(value)) {
|
|
124
|
-
// 解析字符串数字
|
|
125
|
-
// 如果可以转换为数字,则将其转换并更新对象的值
|
|
126
|
-
obj[key] = roundNum(parseFloat(value))
|
|
127
|
-
} else if (key.includes('frame_rate')) {
|
|
128
|
-
// 解析 '25/1' 这种 r_frame_rate 字段值
|
|
129
|
-
const regex = /(\d+)\/(\d+)/
|
|
130
|
-
const match = value.match(regex)
|
|
131
|
-
if (match) {
|
|
132
|
-
const numerator = parseInt(match[1])
|
|
133
|
-
const denominator = parseInt(match[2])
|
|
134
|
-
if (denominator !== 0) {
|
|
135
|
-
obj[key] = roundNum(numerator / denominator)
|
|
136
|
-
} else {
|
|
137
|
-
obj[key] = 0
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
continue
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
return obj
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// 返回数据示例 video/audio/format 三个字段
|
|
148
|
-
|
|
149
|
-
// {
|
|
150
|
-
// video: {
|
|
151
|
-
// codec_name: 'hevc',
|
|
152
|
-
// codec_long_name: 'H.265 / HEVC (High Efficiency Video Coding)',
|
|
153
|
-
// profile: 'Main 10',
|
|
154
|
-
// codec_type: 'video',
|
|
155
|
-
// codec_tag_string: '[0][0][0][0]',
|
|
156
|
-
// width: 1920,
|
|
157
|
-
// height: 1080,
|
|
158
|
-
// display_aspect_ratio: '16:9',
|
|
159
|
-
// pix_fmt: 'yuv420p10le',
|
|
160
|
-
// r_frame_rate: 23.98,
|
|
161
|
-
// avg_frame_rate: 23.98,
|
|
162
|
-
// time_base: '1/1000',
|
|
163
|
-
// tags: {
|
|
164
|
-
// title: 'Encode By H-Enc',
|
|
165
|
-
// BPS: '2430147',
|
|
166
|
-
// DURATION: '00:16:44.003000000',
|
|
167
|
-
// NUMBER_OF_FRAMES: '24072',
|
|
168
|
-
// NUMBER_OF_BYTES: '304984449',
|
|
169
|
-
// _STATISTICS_WRITING_APP: "mkvmerge v70.0.0 ('Caught A Lite Sneeze') 64-bit",
|
|
170
|
-
// _STATISTICS_WRITING_DATE_UTC: '2022-10-05 18:40:29',
|
|
171
|
-
// _STATISTICS_TAGS: 'BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES'
|
|
172
|
-
// }
|
|
173
|
-
// },
|
|
174
|
-
// audio: {
|
|
175
|
-
// codec_name: 'aac',
|
|
176
|
-
// codec_long_name: 'AAC (Advanced Audio Coding)',
|
|
177
|
-
// profile: 'LC',
|
|
178
|
-
// codec_type: 'audio',
|
|
179
|
-
// codec_tag_string: '[0][0][0][0]',
|
|
180
|
-
// sample_fmt: 'fltp',
|
|
181
|
-
// sample_rate: 48000,
|
|
182
|
-
// channels: 2,
|
|
183
|
-
// bits_per_sample: 0,
|
|
184
|
-
// r_frame_rate: 0,
|
|
185
|
-
// avg_frame_rate: 0,
|
|
186
|
-
// time_base: '1/1000',
|
|
187
|
-
// tags: {
|
|
188
|
-
// BPS: '261491',
|
|
189
|
-
// DURATION: '00:16:43.946000000',
|
|
190
|
-
// NUMBER_OF_FRAMES: '47060',
|
|
191
|
-
// NUMBER_OF_BYTES: '32815429',
|
|
192
|
-
// _STATISTICS_WRITING_APP: "mkvmerge v70.0.0 ('Caught A Lite Sneeze') 64-bit",
|
|
193
|
-
// _STATISTICS_WRITING_DATE_UTC: '2022-10-05 18:40:29',
|
|
194
|
-
// _STATISTICS_TAGS: 'BPS DURATION NUMBER_OF_FRAMES NUMBER_OF_BYTES'
|
|
195
|
-
// }
|
|
196
|
-
// },
|
|
197
|
-
// format: {
|
|
198
|
-
// format_name: 'matroska,webm',
|
|
199
|
-
// format_long_name: 'Matroska / WebM',
|
|
200
|
-
// duration: 1004,
|
|
201
|
-
// size: 338094926,
|
|
202
|
-
// bit_rate: 2693975
|
|
203
|
-
// },
|
|
204
|
-
// streams: undefined
|
|
205
|
-
// }
|