mediasnacks 0.32.0 → 0.33.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
@@ -32,6 +32,7 @@ mediasnacks <command> <args>
32
32
  - `ssim` Computes similarity of two images
33
33
  - `gif`: Video to GIF
34
34
  - `info`: Prints video attributes
35
+ - `tag`: Adds color tag for Finder
35
36
 
36
37
 
37
38
  - `detectdups` Detects sequentially duplicate frames in a video
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mediasnacks",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
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
@@ -18,7 +18,8 @@ const COMMANDS = {
18
18
  countframes: ['./countframes.js', 'Counts frames in a video'],
19
19
  ssim: ['./ssim.js', 'Computes SSIM between two images'],
20
20
  gif: ['./gif.js', 'Video to GIF'],
21
- info: ['./info.js', 'Prints video stream attributes\n'],
21
+ info: ['./info.js', 'Prints video stream attributes'],
22
+ tag: ['./tag.osascript', 'Adds color tag for Finder\n'],
22
23
 
23
24
  detectdups: ['./detectdups.js', 'Detects duplicate frames in a video'],
24
25
  dropdups: ['./dropdups.js', 'Removes duplicate frames in a video'],
@@ -55,7 +56,7 @@ SYNOPSIS
55
56
 
56
57
  COMMANDS
57
58
  ${commandsSummary().map(([cmd, desc]) =>
58
- ` ${styleText('bold', cmd.padEnd(12, ' '))}\t${desc}`).join('\n')}
59
+ ` ${styleText('bold', cmd.padEnd(12))}\t${desc}`).join('\n')}
59
60
  `.trim()
60
61
 
61
62
 
@@ -77,11 +78,11 @@ async function main() {
77
78
  if (!opt) throw HELP
78
79
  if (!Object.hasOwn(COMMANDS, opt)) throw `'${opt}' is not a command. See mediasnacks --help\n`
79
80
 
80
- const cmd = COMMANDS[opt][0]
81
- if (cmd.endsWith('.js'))
82
- await (await import(cmd)).default()
81
+ const prog = COMMANDS[opt][0]
82
+ if (prog.endsWith('.js'))
83
+ await (await import(prog)).default()
83
84
  else
84
- spawn(join(import.meta.dirname, cmd), args, { stdio: 'inherit' })
85
+ spawn(join(import.meta.dirname, prog), args, { stdio: 'inherit' })
85
86
  .on('exit', process.exit)
86
87
  }
87
88
 
package/src/dlaudio.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { parseOptions } from './utils/parseOptions.js'
2
- import { runSilently } from './utils/subprocess.js'
3
- import { unemoji } from './unemoji.js'
2
+ import { run } from './utils/subprocess.js'
4
3
 
5
4
 
6
5
  const HELP = `
@@ -17,18 +16,14 @@ export default async function main() {
17
16
  if (!positionals[0])
18
17
  throw usage('Missing URL')
19
18
 
20
- const f = await dlaudio(positionals[0])
21
- console.log(f)
19
+ await dlaudio(positionals[0])
22
20
  }
23
21
 
24
22
  export async function dlaudio(url) {
25
- const f = (await runSilently('yt-dlp', [
26
- '--print', 'filename',
23
+ await run('yt-dlp', [
27
24
  '--no-simulate',
28
25
  '-o', '%(title)s.%(ext)s',
29
26
  '-f', 'bestaudio[ext=m4a]/bestaudio',
30
27
  url
31
- ])).stdout.trim()
32
-
33
- return await unemoji(f) || f
28
+ ])
34
29
  }
package/src/info.js CHANGED
@@ -5,13 +5,38 @@ 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
+ awk "\\$3==\\"\${FPS}fps\\" { print \\$NF }" |
30
+ while read -r f; do
31
+ mv -- "$f" \$DIR/
32
+ done
33
+
34
+ Tag with "red" (in macOS Finder) 1080 videos:
35
+ COLOR=red
36
+ HEIGHT=1080
37
+ mediasnacks info *.mov |
38
+ awk "\\$2==\$HEIGHT { print \\$NF }" |
39
+ xargs -I{} mediasnacks tag $COLOR "{}"
15
40
  `
16
41
 
17
42
  export default async function main() {
@@ -19,45 +44,28 @@ export default async function main() {
19
44
  all: { short: 'a', type: 'boolean' }
20
45
  })
21
46
 
22
- const video = files[0]
23
- if (!video) throw usage('No video file specified')
47
+ if (!files[0]) throw usage('No video file specified')
24
48
 
25
- if (values.all)
26
- console.log(JSON.stringify(await videoAttrs(video), '', 2))
27
- else
28
- console.log(await infoSummary(video))
49
+ for (const video of files)
50
+ if (values.all)
51
+ console.log(JSON.stringify(await videoAttrs(video), '', 2))
52
+ else
53
+ console.log(`${await infoSummary(video)}\t${video}`)
29
54
  }
30
55
 
31
56
 
32
57
  export async function infoSummary(video) {
33
58
  const v = await videoAttrs(video)
34
59
  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(' ')
60
+ String(v.width).padStart(4),
61
+ String(v.height).padStart(4),
62
+ `${fps(v.r_frame_rate)}fps`.padStart(7),
63
+ formatSeconds(v.duration, 0).padStart(8),
64
+ v.codec_name
65
+ ].join('\t')
40
66
  }
41
67
 
42
68
  function fps(rFrameRate) {
43
69
  const [num, den] = rFrameRate.split('/').map(Number)
44
70
  return cleanDecimals((num / den).toFixed(2))
45
71
  }
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
-
package/src/seqcheck.js CHANGED
@@ -35,7 +35,7 @@ export function seqcheck(dir, leftDelim = LEFT_DELIM, rightDelim = RIGHT_DELIM)
35
35
  }
36
36
 
37
37
  export function extractSeqNums(names, leftDelim, rightDelim) {
38
- const pattern = new RegExp(escapeRegex(leftDelim) + '(\\d+)' + escapeRegex(rightDelim))
38
+ const pattern = new RegExp(RegExp.escape(leftDelim) + '(\\d+)' + RegExp.escape(rightDelim))
39
39
  const seq = []
40
40
  for (const name of names) {
41
41
  const match = name.match(pattern)
@@ -54,7 +54,3 @@ export function findMissingNumbers(seq) {
54
54
  missing.push(i)
55
55
  return missing
56
56
  }
57
-
58
- function escapeRegex(str) {
59
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
60
- }
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/osascript
2
+
3
+ property colorNames : {"none", "orange", "red", "yellow", "blue", "purple", "green", "gray"}
4
+
5
+ on usage()
6
+ log "USAGE: mediasnacks tag <color> <file>"
7
+ set AppleScript's text item delimiters to " "
8
+ set colorList to colorNames as text
9
+ set AppleScript's text item delimiters to ""
10
+ log "COLORS: " & colorList
11
+ end usage
12
+
13
+ on labelNumForColor(theItem)
14
+ repeat with i from 1 to count of colorNames
15
+ if item i of colorNames as text is theItem as text then
16
+ return i - 1
17
+ end if
18
+ end repeat
19
+ return -1
20
+ end labelNumForColor
21
+
22
+ on run argv
23
+ if (count of argv) < 2 then
24
+ usage()
25
+ return
26
+ end if
27
+ set tagColor to item 1 of argv
28
+ set filePath to item 2 of argv
29
+
30
+ set labelNum to labelNumForColor(tagColor)
31
+ if labelNum is -1 then
32
+ log "invalid color: " & tagColor
33
+ usage()
34
+ return
35
+ end if
36
+
37
+ set theFile to POSIX file filePath as alias
38
+ tell application "Finder"
39
+ set label index of theFile to labelNum
40
+ end tell
41
+ return
42
+ end run
package/src/unemoji.js CHANGED
@@ -44,7 +44,7 @@ export default async function main() {
44
44
 
45
45
  for (const file of files) {
46
46
  const newpath = await unemoji(file)
47
- if (newpath)
47
+ if (newpath !== file)
48
48
  console.log(`Renaming: ${file} -> ${newpath}`)
49
49
  }
50
50
  }
@@ -57,10 +57,11 @@ export async function unemoji(file) {
57
57
  .replace(/\s+/g, ' ')
58
58
  .replace(/\s+\./g, '.')
59
59
  .trim()
60
+ const newpath = join(dir, newbase)
61
+
60
62
  if (base === newbase)
61
- return null
63
+ return newpath
62
64
 
63
- const newpath = join(dir, newbase)
64
65
  if (existsSync(newpath))
65
66
  throw `Skipping (exists): ${file} -> ${newpath}`
66
67
 
@@ -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