mediasnacks 0.30.3 → 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/README.md CHANGED
@@ -31,6 +31,7 @@ mediasnacks <command> <args>
31
31
  - `countframes` Counts frames in a video
32
32
  - `ssim` Computes similarity of two images
33
33
  - `gif`: Video to GIF
34
+ - `info`: Prints video attributes
34
35
 
35
36
 
36
37
  - `detectdups` Detects sequentially duplicate frames in a video
package/index.js CHANGED
@@ -21,3 +21,4 @@ export { unemoji } from './src/unemoji.js'
21
21
  export { vsplit } from './src/vsplit.js'
22
22
  export { vtrim } from './src/vtrim.js'
23
23
  export { base64 } from './src/base64.js'
24
+ export { infoSummary } from './src/info.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mediasnacks",
3
- "version": "0.30.3",
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/avif.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { join, basename, dirname } from 'node:path'
2
2
  import { parseOptions } from './utils/parseOptions.js'
3
3
  import { replaceExt, lstat } from './utils/fs-utils.js'
4
- import { ffmpeg } from './utils/subprocess.js'
4
+ import { ffmpeg } from './utils/ffmpeg.js'
5
5
 
6
6
 
7
7
  const HELP = `
package/src/cli.js CHANGED
@@ -17,7 +17,8 @@ const COMMANDS = {
17
17
  frameseq: ['./frameseq.js', 'Converts video to sequence of PNGs'],
18
18
  countframes: ['./countframes.js', 'Counts frames in a video'],
19
19
  ssim: ['./ssim.js', 'Computes SSIM between two images'],
20
- gif: ['./gif.js', 'Video to GIF\n'],
20
+ gif: ['./gif.js', 'Video to GIF'],
21
+ info: ['./info.js', 'Prints video stream attributes\n'],
21
22
 
22
23
  detectdups: ['./detectdups.js', 'Detects duplicate frames in a video'],
23
24
  dropdups: ['./dropdups.js', 'Removes duplicate frames in a video'],
@@ -54,7 +55,7 @@ SYNOPSIS
54
55
 
55
56
  COMMANDS
56
57
  ${commandsSummary().map(([cmd, desc]) =>
57
- ` ${styleText('bold', cmd.padEnd(12, ' '))}\t${desc}`).join('\n')}
58
+ ` ${styleText('bold', cmd.padEnd(12))}\t${desc}`).join('\n')}
58
59
  `.trim()
59
60
 
60
61
 
@@ -76,11 +77,11 @@ async function main() {
76
77
  if (!opt) throw HELP
77
78
  if (!Object.hasOwn(COMMANDS, opt)) throw `'${opt}' is not a command. See mediasnacks --help\n`
78
79
 
79
- const cmd = COMMANDS[opt][0]
80
- if (cmd.endsWith('.js'))
81
- await (await import(cmd)).default()
80
+ const prog = COMMANDS[opt][0]
81
+ if (prog.endsWith('.js'))
82
+ await (await import(prog)).default()
82
83
  else
83
- spawn(join(import.meta.dirname, cmd), args, { stdio: 'inherit' })
84
+ spawn(join(import.meta.dirname, prog), args, { stdio: 'inherit' })
84
85
  .on('exit', process.exit)
85
86
  }
86
87
 
package/src/detectdups.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { parseOptions } from './utils/parseOptions.js'
2
- import { ffmpeg } from './utils/subprocess.js'
3
2
  import { videoAttrs } from './utils/videoAttrs.js'
3
+ import { ffmpeg } from './utils/ffmpeg.js'
4
4
 
5
5
  const STDEV_THRESHOLD = 0.2
6
6
 
package/src/dropdups.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { resolve, parse, format } from 'node:path'
2
- import { parseOptions } from './utils/parseOptions.js'
3
- import { ffmpeg, run } from './utils/subprocess.js'
4
2
  import { ProresProfiles } from './prores.js'
3
+ import { parseOptions } from './utils/parseOptions.js'
4
+ import { ffmpegWithProgress } from './utils/ffmpeg.js'
5
+ import { infoSummary } from './info.js'
5
6
 
6
7
 
7
8
  const PROFILE = ProresProfiles.default
@@ -15,7 +16,7 @@ DESCRIPTION
15
16
 
16
17
  OPTIONS
17
18
  -n, --dup-frame-num <n> Known frame interval to drop.
18
- Default: n=0, which auto-detects repeated frames (slower)
19
+ Default: n=0, which auto-detects repeated frames (slower)
19
20
 
20
21
  EXAMPLES
21
22
  Use n=2 when every other frame is repeated:
@@ -38,14 +39,14 @@ export default async function main() {
38
39
  if (dupFrameNum && !Number.isInteger(+dupFrameNum))
39
40
  throw usage('Invalid -n. It must be a positive integer.')
40
41
 
41
- for (const file of files)
42
+ for (const file of files) {
43
+ console.log(await infoSummary(file))
42
44
  await dropdups(resolve(file), dupFrameNum)
45
+ }
43
46
  }
44
47
 
45
48
  export async function dropdups(video, dupFrameNum) {
46
- await run('ffmpeg', [
47
- '-v', 'error',
48
- '-stats',
49
+ await ffmpegWithProgress(video, [
49
50
  '-an',
50
51
  '-i', video,
51
52
  '-vf', dupFrameNum
package/src/edgespic.js CHANGED
@@ -3,7 +3,7 @@ import { basename, extname, join, parse } from 'node:path'
3
3
  import { mkDir } from './utils/fs-utils.js'
4
4
  import { videoAttrs } from './utils/videoAttrs.js'
5
5
  import { parseOptions } from './utils/parseOptions.js'
6
- import { ffmpeg } from './utils/subprocess.js'
6
+ import { ffmpeg } from './utils/ffmpeg.js'
7
7
 
8
8
 
9
9
  const WIDTH = 640
package/src/frameseq.js CHANGED
@@ -2,7 +2,7 @@ import { basename, extname, join, parse } from 'node:path'
2
2
 
3
3
  import { mkDir } from './utils/fs-utils.js'
4
4
  import { parseOptions } from './utils/parseOptions.js'
5
- import { ffmpeg } from './utils/subprocess.js'
5
+ import { ffmpeg } from './utils/ffmpeg.js'
6
6
  import { countframes } from './countframes.js'
7
7
 
8
8
 
package/src/hev1tohvc1.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { parseOptions } from './utils/parseOptions.js'
2
2
  import { uniqueFilenameFor, overwrite } from './utils/fs-utils.js'
3
- import { ffmpeg } from './utils/subprocess.js'
4
3
  import { videoAttrs } from './utils/videoAttrs.js'
4
+ import { ffmpeg } from './utils/ffmpeg.js'
5
5
 
6
6
 
7
7
  const HELP = `
package/src/info.js ADDED
@@ -0,0 +1,65 @@
1
+ import { parseOptions } from './utils/parseOptions.js'
2
+ import { videoAttrs } from './utils/videoAttrs.js'
3
+ import { formatSeconds, cleanDecimals } from './utils/formatSeconds.js'
4
+
5
+
6
+ const HELP = `
7
+ SYNOPSIS
8
+ mediasnacks info [-a | --all] <files>
9
+
10
+ DESCRIPTION
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.
13
+
14
+ OPTIONS
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
34
+ `
35
+
36
+ export default async function main() {
37
+ const { values, files, usage } = await parseOptions(HELP, {
38
+ all: { short: 'a', type: 'boolean' }
39
+ })
40
+
41
+ if (!files[0]) throw usage('No video file specified')
42
+
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}`)
48
+ }
49
+
50
+
51
+ export async function infoSummary(video) {
52
+ const v = await videoAttrs(video)
53
+ return [
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')
60
+ }
61
+
62
+ function fps(rFrameRate) {
63
+ const [num, den] = rFrameRate.split('/').map(Number)
64
+ return cleanDecimals((num / den).toFixed(2))
65
+ }
package/src/moov2front.js CHANGED
@@ -1,6 +1,6 @@
1
- import { ffmpeg } from './utils/subprocess.js'
2
1
  import { uniqueFilenameFor, overwrite } from './utils/fs-utils.js'
3
2
  import { parseOptions } from './utils/parseOptions.js'
3
+ import { ffmpeg } from './utils/ffmpeg.js'
4
4
 
5
5
 
6
6
  const HELP = `
package/src/prores.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { resolve, parse, join } from 'node:path'
2
2
  import { parseOptions } from './utils/parseOptions.js'
3
- import { run } from './utils/subprocess.js'
3
+ import { ffmpegWithProgress } from './utils/ffmpeg.js'
4
+ import { infoSummary } from './info.js'
4
5
 
5
6
 
6
7
  // https://github.com/oyvindln/vhs-decode/wiki/ProRes-The-Definitive-FFmpeg-Guide#profiles-can-be-the-following
@@ -68,13 +69,12 @@ export default async function main() {
68
69
  const output = join(dir, `${name}.prores.mov`)
69
70
 
70
71
  const { profile, start, end } = values
72
+ console.log(await infoSummary(video))
71
73
  await prores({ video, profile, start, end, output })
72
74
  }
73
75
 
74
76
  export async function prores({ video, profile, start, end, output }) {
75
- await run('ffmpeg', [
76
- '-v', 'error',
77
- '-stats',
77
+ await ffmpegWithProgress(video, [
78
78
  start ? ['-ss', start] : [],
79
79
  end ? ['-to', end] : [],
80
80
  '-i', video,
package/src/resize.js CHANGED
@@ -3,8 +3,8 @@ import { rename } from 'node:fs/promises'
3
3
 
4
4
  import { parseOptions } from './utils/parseOptions.js'
5
5
  import { isFile, uniqueFilenameFor } from './utils/fs-utils.js'
6
- import { ffmpeg } from './utils/subprocess.js'
7
6
  import { videoAttrs } from './utils/videoAttrs.js'
7
+ import { ffmpeg } from './utils/ffmpeg.js'
8
8
 
9
9
 
10
10
  const HELP = `
package/src/sqcrop.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { join } from 'node:path'
2
2
  import { rename } from 'node:fs/promises'
3
3
 
4
- import { ffmpeg } from './utils/subprocess.js'
5
4
  import { lstat, uniqueFilenameFor } from './utils/fs-utils.js'
6
5
  import { parseOptions } from './utils/parseOptions.js'
7
6
  import { videoAttrs } from './utils/videoAttrs.js'
7
+ import { ffmpeg } from './utils/ffmpeg.js'
8
8
 
9
9
 
10
10
  const HELP = `
package/src/ssim.js CHANGED
@@ -1,5 +1,5 @@
1
- import { ffmpeg } from './utils/subprocess.js'
2
1
  import { parseOptions } from './utils/parseOptions.js'
2
+ import { ffmpeg } from './utils/ffmpeg.js'
3
3
 
4
4
 
5
5
  const HELP = `
@@ -0,0 +1,64 @@
1
+ import os from 'node:os'
2
+ import { spawn } from 'node:child_process'
3
+ import { printProgress, showCursor } from './printProgress.js'
4
+ import { videoAttrs } from './videoAttrs.js'
5
+ import { runSilently } from './subprocess.js'
6
+
7
+
8
+ async function assertUserHasFFmpeg() {
9
+ try {
10
+ await runSilently('ffmpeg', ['-version'])
11
+ await runSilently('ffprobe', ['-version'])
12
+ }
13
+ catch {
14
+ throw new Error('ffmpeg not found. Please install ffmpeg.')
15
+ }
16
+ }
17
+
18
+
19
+ export async function ffmpeg(args) {
20
+ await assertUserHasFFmpeg()
21
+ return runSilently('ffmpeg', args)
22
+ }
23
+
24
+
25
+ export async function ffmpegWithProgress(input, args, onProgress = printProgress) {
26
+ await assertUserHasFFmpeg()
27
+ const µsVideoDuration = 1e6 * (await videoAttrs(input)).duration
28
+
29
+ const p = spawn('ffmpeg', [
30
+ '-v', 'error',
31
+ '-nostats',
32
+ '-progress', 'pipe:1',
33
+ ...args
34
+ ], { stdio: ['inherit', 'pipe', 'pipe'] })
35
+
36
+ const startTime = performance.now()
37
+ p.stdout.on('data', chunk => {
38
+ const text = chunk.toString()
39
+ const msElapsed = performance.now() - startTime
40
+ if (text.includes('progress=continue')) {
41
+ const m = text.match(/out_time_us=(\d+)/)
42
+ const progress = Number(m[1]) / µsVideoDuration
43
+ const msETA = msElapsed * (1 - progress) / progress
44
+ onProgress(progress, msElapsed, msETA)
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)
54
+ })
55
+ p.stderr.pipe(process.stderr)
56
+
57
+ await new Promise((resolve, reject) => {
58
+ p.on('error', reject)
59
+ p.on('close', code => {
60
+ if (code === 0) resolve()
61
+ else reject(Error(`ffmpeg failed with code ${code}`))
62
+ })
63
+ })
64
+ }
@@ -0,0 +1,32 @@
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
+
10
+ /**
11
+ * Converts seconds to a string like "9h9m9s".
12
+ *
13
+ * Examples:
14
+ * formatSeconds(1.1) -> "1.1s"
15
+ * formatSeconds(3661, 2) -> "1h1m1s"
16
+ * formatSeconds(3661.0, 2) -> "1h1m1s"
17
+ * formatSeconds(3661.25, 2) -> "1h1m1.25s"
18
+ */
19
+ export function formatSeconds(seconds, maxDecimals = 2) {
20
+ if (!Number.isFinite(+seconds))
21
+ return ''
22
+ const intSeconds = seconds | 0
23
+ const partialSeconds = seconds % 60
24
+ const minutes = (intSeconds % 3600) / 60 | 0
25
+ const hours = intSeconds / 3600 | 0
26
+
27
+ let result = ''
28
+ if (hours) result += hours + 'h'
29
+ if (minutes) result += minutes + 'm'
30
+ if (partialSeconds || !result) result += cleanDecimals(partialSeconds.toFixed(maxDecimals)) + 's'
31
+ return result
32
+ }
@@ -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
  }
@@ -0,0 +1,34 @@
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}`)
21
+ if (progress === 1)
22
+ process.stdout.write('\n' + SHOW_CURSOR)
23
+ }
24
+
25
+ function progressBar(progress, width = 44) {
26
+ width-- // for partial char
27
+ const nFull = (width * progress) | 0
28
+ const fPartial = (width * progress) - nFull
29
+ const nRemaining = width - nFull
30
+
31
+ const partials = ' ▏▎▍▌▋▊▉'
32
+ const partial = partials.at(partials.length * fPartial)
33
+ return '█'.repeat(nFull) + partial + '•'.repeat(nRemaining)
34
+ }
@@ -1,21 +1,6 @@
1
1
  import { spawn } from 'node:child_process'
2
2
 
3
3
 
4
- async function assertUserHasFFmpeg() {
5
- try {
6
- await runSilently('ffmpeg', ['-version'])
7
- await runSilently('ffprobe', ['-version'])
8
- }
9
- catch {
10
- throw new Error('ffmpeg not found. Please install ffmpeg.')
11
- }
12
- }
13
-
14
- export async function ffmpeg(args) {
15
- await assertUserHasFFmpeg()
16
- return runSilently('ffmpeg', args)
17
- }
18
-
19
4
  export async function runSilently(program, args) {
20
5
  return new Promise((resolve, reject) => {
21
6
  const stdout = []
@@ -38,6 +23,7 @@ export async function runSilently(program, args) {
38
23
  })
39
24
  }
40
25
 
26
+
41
27
  export async function run(program, args) {
42
28
  return new Promise((resolve, reject) => {
43
29
  const p = spawn(program, args, { stdio: ['inherit', 'pipe', 'pipe'] })
@@ -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
 
package/src/vtrim.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { resolve, parse } from 'node:path'
2
2
  import { parseOptions } from './utils/parseOptions.js'
3
- import { ffmpeg } from './utils/subprocess.js'
3
+ import { ffmpeg } from './utils/ffmpeg.js'
4
4
 
5
5
 
6
6
  const HELP = `