mediasnacks 0.32.0 → 0.32.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mediasnacks",
3
- "version": "0.32.0",
3
+ "version": "0.32.4",
4
4
  "description": "Utilities for optimizing and preparing videos and images",
5
5
  "license": "MIT",
6
6
  "author": "Eric Fortis",
package/src/cli.js CHANGED
@@ -55,7 +55,7 @@ SYNOPSIS
55
55
 
56
56
  COMMANDS
57
57
  ${commandsSummary().map(([cmd, desc]) =>
58
- ` ${styleText('bold', cmd.padEnd(12, ' '))}\t${desc}`).join('\n')}
58
+ ` ${styleText('bold', cmd.padEnd(12))}\t${desc}`).join('\n')}
59
59
  `.trim()
60
60
 
61
61
 
@@ -77,11 +77,11 @@ async function main() {
77
77
  if (!opt) throw HELP
78
78
  if (!Object.hasOwn(COMMANDS, opt)) throw `'${opt}' is not a command. See mediasnacks --help\n`
79
79
 
80
- const cmd = COMMANDS[opt][0]
81
- if (cmd.endsWith('.js'))
82
- await (await import(cmd)).default()
80
+ const prog = COMMANDS[opt][0]
81
+ if (prog.endsWith('.js'))
82
+ await (await import(prog)).default()
83
83
  else
84
- spawn(join(import.meta.dirname, cmd), args, { stdio: 'inherit' })
84
+ spawn(join(import.meta.dirname, prog), args, { stdio: 'inherit' })
85
85
  .on('exit', process.exit)
86
86
  }
87
87
 
package/src/info.js CHANGED
@@ -5,13 +5,32 @@ import { formatSeconds, cleanDecimals } from './utils/formatSeconds.js'
5
5
 
6
6
  const HELP = `
7
7
  SYNOPSIS
8
- mediasnacks info [-a | --all] <file>
8
+ mediasnacks info [-a | --all] <files>
9
9
 
10
10
  DESCRIPTION
11
- Prints all available attributes for the primary video stream from ffprobe.
11
+ Prints video or image attributes using ffprobe. By default, it’s similar to
12
+ \`ls\` but prints the: width, height, fps, duration, codec, and filename.
12
13
 
13
14
  OPTIONS
14
- -a, --all Prints all attributes as JSON
15
+ -a, --all Prints everything as JSON
16
+
17
+ EXAMPLES
18
+ Short summary of each match:
19
+ mediasnacks info *.mp4
20
+
21
+ Sort by fps:
22
+ mediasnacks info *.* | sort -k3,3n
23
+
24
+ Move 60fps videos into 60fps/ subdir:
25
+ FPS=60
26
+ DIR=\${FPS}fps
27
+ mkdir -p \$DIR
28
+ mediasnacks info *.* |
29
+ grep \${FPS}fps |
30
+ awk -F\\t '{print $NF}' |
31
+ while read -r f; do
32
+ mv -- "$f" \$DIR/
33
+ done
15
34
  `
16
35
 
17
36
  export default async function main() {
@@ -19,45 +38,28 @@ export default async function main() {
19
38
  all: { short: 'a', type: 'boolean' }
20
39
  })
21
40
 
22
- const video = files[0]
23
- if (!video) throw usage('No video file specified')
41
+ if (!files[0]) throw usage('No video file specified')
24
42
 
25
- if (values.all)
26
- console.log(JSON.stringify(await videoAttrs(video), '', 2))
27
- else
28
- console.log(await infoSummary(video))
43
+ for (const video of files)
44
+ if (values.all)
45
+ console.log(JSON.stringify(await videoAttrs(video), '', 2))
46
+ else
47
+ console.log(`${await infoSummary(video)}\t${video}`)
29
48
  }
30
49
 
31
50
 
32
51
  export async function infoSummary(video) {
33
52
  const v = await videoAttrs(video)
34
53
  return [
35
- `${v.width}x${v.height}`,
36
- `${fps(v.r_frame_rate)}fps`,
37
- formatSeconds(v.duration),
38
- prettyCodecName(v.codec_name)
39
- ].join(' ')
54
+ String(v.width).padStart(4),
55
+ String(v.height).padStart(4),
56
+ `${fps(v.r_frame_rate)}fps`.padStart(7),
57
+ formatSeconds(v.duration, 0).padStart(8),
58
+ v.codec_name
59
+ ].join('\t')
40
60
  }
41
61
 
42
62
  function fps(rFrameRate) {
43
63
  const [num, den] = rFrameRate.split('/').map(Number)
44
64
  return cleanDecimals((num / den).toFixed(2))
45
65
  }
46
-
47
- function prettyCodecName(codec) {
48
- // ffmpeg -codecs | grep '^...V'
49
- return {
50
- 'dnxhd': 'DNxHD',
51
- 'dvvideo': 'DV (Digital Video)',
52
- 'h264': 'H.264',
53
- 'hevc': 'H.265',
54
- 'jpeg2000': 'JPEG 2000',
55
- 'mpeg4': 'MPEG-4 Part 2',
56
- 'prores': 'ProRes',
57
- 'qtrle': 'QuickTime RLE',
58
- 'rawvideo': 'Uncompressed',
59
- }[codec] || codec
60
- }
61
-
62
-
63
-
@@ -1,5 +1,6 @@
1
+ import os from 'node:os'
1
2
  import { spawn } from 'node:child_process'
2
- import { printProgress } from './printProgress.js'
3
+ import { printProgress, showCursor } from './printProgress.js'
3
4
  import { videoAttrs } from './videoAttrs.js'
4
5
  import { runSilently } from './subprocess.js'
5
6
 
@@ -32,16 +33,26 @@ export async function ffmpegWithProgress(input, args, onProgress = printProgress
32
33
  ...args
33
34
  ], { stdio: ['inherit', 'pipe', 'pipe'] })
34
35
 
35
- p.stderr.pipe(process.stderr)
36
+ const startTime = performance.now()
36
37
  p.stdout.on('data', chunk => {
37
38
  const text = chunk.toString()
38
- if (text.includes('progress=end'))
39
- onProgress(1)
40
- else {
39
+ const msElapsed = performance.now() - startTime
40
+ if (text.includes('progress=continue')) {
41
41
  const m = text.match(/out_time_us=(\d+)/)
42
- onProgress(Number(m[1]) / µsVideoDuration)
42
+ const progress = Number(m[1]) / µsVideoDuration
43
+ const msETA = msElapsed * (1 - progress) / progress
44
+ onProgress(progress, msElapsed, msETA)
43
45
  }
46
+ else
47
+ onProgress(1, msElapsed, 0)
48
+ })
49
+ process.on('SIGINT', () => {
50
+ p?.kill('SIGINT')
51
+ showCursor()
52
+ console.log('\nAborted')
53
+ process.exit(128 + os.constants.signals.SIGINT)
44
54
  })
55
+ p.stderr.pipe(process.stderr)
45
56
 
46
57
  await new Promise((resolve, reject) => {
47
58
  p.on('error', reject)
@@ -1,3 +1,12 @@
1
+ /**
2
+ * Removes trailing zeros and a trailing decimal point.
3
+ *
4
+ * Examples:
5
+ * cleanDecimals(3.1400) -> "3.14"
6
+ * cleanDecimals(5.0) -> "5"
7
+ */
8
+ export const cleanDecimals = Number
9
+
1
10
  /**
2
11
  * Converts seconds to a string like "9h9m9s".
3
12
  *
@@ -8,26 +17,16 @@
8
17
  * formatSeconds(3661.25, 2) -> "1h1m1.25s"
9
18
  */
10
19
  export function formatSeconds(seconds, maxDecimals = 2) {
20
+ if (!Number.isFinite(+seconds))
21
+ return ''
11
22
  const intSeconds = seconds | 0
12
23
  const partialSeconds = seconds % 60
13
24
  const minutes = (intSeconds % 3600) / 60 | 0
14
25
  const hours = intSeconds / 3600 | 0
15
26
 
16
27
  let result = ''
17
- if (hours) result += `${hours}h`
18
- if (minutes) result += `${minutes}m`
19
- if (partialSeconds || !result) result += `${cleanDecimals(partialSeconds.toFixed(maxDecimals))}s`
28
+ if (hours) result += hours + 'h'
29
+ if (minutes) result += minutes + 'm'
30
+ if (partialSeconds || !result) result += cleanDecimals(partialSeconds.toFixed(maxDecimals)) + 's'
20
31
  return result
21
32
  }
22
-
23
-
24
- /**
25
- * Removes trailing zeros and a trailing decimal point.
26
- *
27
- * Examples:
28
- * cleanDecimals(3.1400) -> "3.14"
29
- * cleanDecimals(5.0) -> "5"
30
- */
31
- export function cleanDecimals(number) {
32
- return String(number).replace(/\.?0+$/, '') || '0'
33
- }
@@ -10,48 +10,41 @@ const glob = promisify(_glob)
10
10
  * @param {Partial<import('node:util').ParseArgsConfig>} [config]
11
11
  */
12
12
  export async function parseOptions(helpText, options = {}, config = {}) {
13
+ helpText = helpText.trim()
13
14
  options.help = { short: 'h', type: 'boolean' }
14
15
 
15
- const { values, positionals, tokens } = parseArgs({
16
+ const { values, positionals } = parseArgs({
16
17
  args: process.argv.slice(3),
17
18
  allowPositionals: true,
18
19
  options,
19
- ...config,
20
- tokens: true
20
+ ...config
21
21
  })
22
22
 
23
23
  if (values.help) {
24
- console.log(helpText.trim())
24
+ console.log(helpText)
25
25
  process.exit(0)
26
26
  }
27
27
 
28
28
  return {
29
29
  values,
30
30
  positionals,
31
- files: await resolveGlobs(positionals, tokens),
31
+ files: await resolveGlobs(positionals),
32
32
  usage: err => err
33
- ? styleText('redBright', '' + err + '\n') + helpText
33
+ ? styleText('redBright', err + '\n') + helpText
34
34
  : helpText
35
35
  }
36
36
  }
37
37
 
38
- async function resolveGlobs(arr, tokens = []) {
39
- const terminatorIndex = tokens.find(t => t.kind === 'option-terminator')?.index ?? -1
38
+ async function resolveGlobs(arr) {
40
39
  const set = new Set()
41
-
42
- const globable = terminatorIndex === -1
43
- ? arr
44
- : arr.slice(0, terminatorIndex)
45
-
46
- for (const g of globable)
47
- for (const file of await glob(g))
48
- set.add(file)
49
-
50
-
51
- if (terminatorIndex !== -1)
52
- for (const literal of arr.slice(terminatorIndex))
53
- set.add(literal)
54
-
40
+ for (const arg of arr) {
41
+ const matches = await glob(arg)
42
+ if (matches.length)
43
+ for (const file of matches)
44
+ set.add(file)
45
+ else
46
+ set.add(arg)
47
+ }
55
48
  return Array.from(set)
56
49
  }
57
50
 
@@ -6,11 +6,11 @@ export function parseTimecode(time) {
6
6
  if (parts.some(isNaN) || parts.length > 3)
7
7
  throw new Error(`Invalid time: ${time}`)
8
8
 
9
- // HH:MM:SS or HH:MM:SS.mmm
10
- if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]
9
+ if (parts.length === 3) // HH:MM:SS or HH:MM:SS.mmm
10
+ return (3600 * parts[0]) + (60 * parts[1]) + parts[2]
11
11
 
12
- // MM:SS or MM:SS.mmm
13
- if (parts.length === 2) return parts[0] * 60 + parts[1]
12
+ if (parts.length === 2) // MM:SS or MM:SS.mmm
13
+ return (60 * parts[0]) + parts[1]
14
14
 
15
15
  return parts[0]
16
16
  }
@@ -1,14 +1,34 @@
1
- export function printProgress(progress) {
2
- process.stdout.write(`\r${progressBar(progress)} ${(progress * 100).toFixed(1)}%`)
1
+ import { formatSeconds } from './formatSeconds.js'
2
+
3
+ const HIDE_CURSOR = '\x1b[?25l'
4
+ const SHOW_CURSOR = '\x1b[?25h'
5
+ const ERASE_TO_END = '\x1b[K'
6
+
7
+ export const showCursor = () => process.stdout.write(SHOW_CURSOR)
8
+
9
+ export function printProgress(progress, msElapsed, msETA) {
10
+ const elapsed = msElapsed
11
+ ? ` • ${formatSeconds(msElapsed / 1000, 0)}`
12
+ : ''
13
+ const eta = msETA
14
+ ? ` • ETA ${formatSeconds(msETA / 1000, 0)}`
15
+ : ''
16
+ const percent = progress === 1
17
+ ? '100%'
18
+ : `${(progress * 100).toFixed(1)}%`
19
+ process.stdout.write(HIDE_CURSOR)
20
+ process.stdout.write(`\r${progressBar(progress)} ${percent}${eta}${elapsed}${ERASE_TO_END}`)
3
21
  if (progress === 1)
4
- process.stdout.write('\n')
22
+ process.stdout.write('\n' + SHOW_CURSOR)
5
23
  }
6
24
 
7
- function progressBar(progress, width = 42) {
25
+ function progressBar(progress, width = 44) {
26
+ width-- // for partial char
8
27
  const nFull = (width * progress) | 0
9
28
  const fPartial = (width * progress) - nFull
10
29
  const nRemaining = width - nFull
30
+
11
31
  const partials = ' ▏▎▍▌▋▊▉'
12
- const partial = partials[Math.min(partials.length * fPartial | 0, partials.length - 1)]
13
- return '█'.repeat(nFull) + partial + ''.repeat(nRemaining)
32
+ const partial = partials.at(partials.length * fPartial)
33
+ return '█'.repeat(nFull) + partial + ''.repeat(nRemaining)
14
34
  }
@@ -5,19 +5,10 @@ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
5
5
 
6
6
  const rel = f => join(import.meta.dirname, f)
7
7
 
8
- export function mkTempDir(prefix = 'test-') {
9
- return mkdtempSync(join(tmpdir(), prefix))
10
- }
8
+ export const cli = (...args) => spawnSync(rel('../cli.js'), args)
11
9
 
12
- export function cli(...args) {
13
- return spawnSync(rel('../cli.js'), args)
14
- }
10
+ export const dir = (...args) => mkdirSync(join(...args), { recursive: true })
11
+ export const touch = (...args) => writeFileSync(join(...args), '')
15
12
 
16
- export function dir(...args) {
17
- return mkdirSync(join(...args), { recursive: true })
18
- }
19
-
20
- export function touch(...args) {
21
- return writeFileSync(join(...args), '')
22
- }
13
+ export const mkTempDir = (prefix = 'test-') => mkdtempSync(join(tmpdir(), prefix))
23
14