mediasnacks 0.30.2 → 0.32.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
@@ -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.2",
3
+ "version": "0.32.0",
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 = `
@@ -17,13 +17,13 @@ EXAMPLES
17
17
  `
18
18
 
19
19
  export default async function main() {
20
- const { values, files } = await parseOptions(HELP, {
20
+ const { values, files, usage } = await parseOptions(HELP, {
21
21
  outdir: { type: 'string', default: '' },
22
22
  overwrite: { short: 'y', type: 'boolean' },
23
23
  })
24
24
 
25
25
  if (!files.length)
26
- throw 'Invalid input image'
26
+ throw usage('Invalid input image')
27
27
 
28
28
  for (const file of files) {
29
29
  await avif({
package/src/base64.js CHANGED
@@ -45,13 +45,13 @@ const mimes = new class {
45
45
 
46
46
 
47
47
  export default async function main() {
48
- const { values, files } = await parseOptions(HELP, {
48
+ const { values, files, usage } = await parseOptions(HELP, {
49
49
  css: { type: 'boolean' },
50
50
  img: { type: 'boolean' },
51
51
  })
52
52
 
53
- if (files.length === 0) throw 'Missing or invalid file'
54
- if (files.length !== 1) throw 'Only one file is accepted'
53
+ if (files.length === 0) throw usage('Missing or invalid file')
54
+ if (files.length !== 1) throw usage('Only one file is accepted')
55
55
 
56
56
  const { data, mime } = base64(files[0])
57
57
  if (values.css) console.log(`background-image: url(data:${mime};base64,${data});`)
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'],
@@ -22,7 +22,7 @@ EXAMPLES
22
22
 
23
23
 
24
24
  export default async function main() {
25
- const { values, files } = await parseOptions(HELP, {
25
+ const { values, files, usage } = await parseOptions(HELP, {
26
26
  fps: { type: 'string' },
27
27
  start: { short: 's', type: 'string' },
28
28
  end: { short: 'e', type: 'string' },
@@ -31,7 +31,7 @@ export default async function main() {
31
31
  const { fps, start, end } = values
32
32
  const video = files[0]
33
33
  if (!video)
34
- throw 'No video file specified'
34
+ throw usage('No video file specified')
35
35
 
36
36
  const n = await countframes({ video, fps, start, end })
37
37
  console.log(String(n))
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
 
@@ -29,18 +29,18 @@ SEE ALSO
29
29
 
30
30
 
31
31
  export default async function main() {
32
- const { values, files } = await parseOptions(HELP, {
32
+ const { values, files, usage } = await parseOptions(HELP, {
33
33
  seek: { short: 's', type: 'string', },
34
34
  duration: { short: 'd', type: 'string' },
35
35
  })
36
36
 
37
37
  if (files.length !== 1)
38
- throw 'Invalid input file. One video file must be specified.'
38
+ throw usage('Invalid input file. One video file must be specified.')
39
39
 
40
40
  const video = files[0]
41
41
  const v = await videoAttrs(video)
42
42
  if (v.codec_type !== 'video')
43
- throw 'Invalid input file. Must be a video.'
43
+ throw usage('Invalid input file. Must be a video.')
44
44
 
45
45
  const vDur = Number(v.duration)
46
46
 
@@ -52,9 +52,9 @@ export default async function main() {
52
52
  ? Number(values.duration)
53
53
  : vDur > 60 ? 20 : vDur
54
54
 
55
- if (isNaN(seek) || seek < 0) throw `Invalid --seek value: ${values.seek}`
56
- if (isNaN(duration) || duration < 1) throw `Invalid --duration value: ${values.duration}`
57
- if ((seek + duration) > vDur) throw `Invalid analysis range. Exceeds video duration: ${vDur}`
55
+ if (isNaN(seek) || seek < 0) throw usage(`Invalid --seek value: ${values.seek}`)
56
+ if (isNaN(duration) || duration < 1) throw usage(`Invalid --duration value: ${values.duration}`)
57
+ if ((seek + duration) > vDur) throw usage(`Invalid analysis range. Exceeds video duration: ${vDur}`)
58
58
 
59
59
  const dups = await detectdups({ video: files[0], seek, duration })
60
60
  const h = deltaHistogram(dups)
package/src/dlaudio.js CHANGED
@@ -12,10 +12,10 @@ DESCRIPTION
12
12
  `
13
13
 
14
14
  export default async function main() {
15
- const { values, positionals } = await parseOptions(HELP)
15
+ const { values, positionals, usage } = await parseOptions(HELP)
16
16
 
17
17
  if (!positionals[0])
18
- throw 'Missing URL'
18
+ throw usage('Missing URL')
19
19
 
20
20
  const f = await dlaudio(positionals[0])
21
21
  console.log(f)
package/src/dlvideo.js CHANGED
@@ -11,10 +11,10 @@ DESCRIPTION
11
11
  `
12
12
 
13
13
  export default async function main() {
14
- const { values, positionals } = await parseOptions(HELP)
14
+ const { values, positionals, usage } = await parseOptions(HELP)
15
15
 
16
16
  if (!positionals[0])
17
- throw 'Missing URL'
17
+ throw usage('Missing URL')
18
18
 
19
19
  await dlvideo(positionals[0])
20
20
  }
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:
@@ -27,25 +28,25 @@ EXAMPLES
27
28
 
28
29
 
29
30
  export default async function main() {
30
- const { values, files } = await parseOptions(HELP, {
31
+ const { values, files, usage } = await parseOptions(HELP, {
31
32
  'dup-frame-num': { short: 'n', type: 'string' },
32
33
  })
33
34
 
34
35
  if (!files.length)
35
- throw 'No video specified.'
36
+ throw usage('No video specified.')
36
37
 
37
38
  let dupFrameNum = values['dup-frame-num']
38
39
  if (dupFrameNum && !Number.isInteger(+dupFrameNum))
39
- throw 'Invalid -n. It must be a positive integer.'
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
@@ -24,13 +24,13 @@ EXAMPLES
24
24
  `
25
25
 
26
26
  export default async function main() {
27
- const { values, files } = await parseOptions(HELP, {
27
+ const { values, files, usage } = await parseOptions(HELP, {
28
28
  width: { short: 'w', type: 'string', default: String(WIDTH) }
29
29
  })
30
30
 
31
31
  const width = Number(values.width)
32
- if (width <= 0 || !Number.isInteger(width)) throw '--width must be a positive number'
33
- if (!files.length) throw 'No video files specified'
32
+ if (width <= 0 || !Number.isInteger(width)) throw usage('--width must be a positive number')
33
+ if (!files.length) throw usage('No video files specified')
34
34
 
35
35
  const outDir = join(parse(files[0]).dir, 'edgespic')
36
36
  await mkDir(outDir)
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
 
@@ -28,7 +28,7 @@ EXAMPLES
28
28
 
29
29
 
30
30
  export default async function main() {
31
- const { values, files } = await parseOptions(HELP, {
31
+ const { values, files, usage } = await parseOptions(HELP, {
32
32
  fps: { short: 'f', type: 'string' },
33
33
  start: { short: 's', type: 'string' },
34
34
  end: { short: 'e', type: 'string' },
@@ -37,10 +37,10 @@ export default async function main() {
37
37
 
38
38
  const { fps, start, end, outdir } = values
39
39
  const video = files[0]
40
- if (!video) throw 'No video files specified'
41
- if (fps && isNaN(parseFloat(fps))) throw 'Invalid --fps'
42
- if (start && isNaN(parseFloat(start))) throw 'Invalid --start'
43
- if (end && isNaN(parseFloat(end))) throw 'Invalid --end'
40
+ if (!video) throw usage('No video files specified')
41
+ if (fps && isNaN(parseFloat(fps))) throw usage('Invalid --fps')
42
+ if (start && isNaN(parseFloat(start))) throw usage('Invalid --start')
43
+ if (end && isNaN(parseFloat(end))) throw usage('Invalid --end')
44
44
 
45
45
  const nFrames = await countframes({ video, fps, start, end })
46
46
  const pad = String(nFrames).length
package/src/gif.js CHANGED
@@ -19,13 +19,13 @@ OPTIONS
19
19
  `
20
20
 
21
21
  export default async function main() {
22
- const { values, files } = await parseOptions(HELP, {
22
+ const { values, files, usage } = await parseOptions(HELP, {
23
23
  fps: { short: 'f', type: 'string', default: String(FPS) },
24
24
  width: { short: 'w', type: 'string', default: String(WIDTH) },
25
25
  })
26
26
 
27
27
  if (!files.length)
28
- throw 'Missing input file'
28
+ throw usage('Missing input file')
29
29
 
30
30
  await gif(files[0], values.fps, values.width)
31
31
  }
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 = `
@@ -16,10 +16,10 @@ DESCRIPTION
16
16
 
17
17
 
18
18
  export default async function main() {
19
- const { values, files } = await parseOptions(HELP)
19
+ const { values, files, usage } = await parseOptions(HELP)
20
20
 
21
21
  if (!files.length)
22
- throw 'Missing input file(s)'
22
+ throw usage('Missing input file(s)')
23
23
 
24
24
  for (const file of files) {
25
25
  await hev1tohvc1(file)
package/src/info.js ADDED
@@ -0,0 +1,63 @@
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] <file>
9
+
10
+ DESCRIPTION
11
+ Prints all available attributes for the primary video stream from ffprobe.
12
+
13
+ OPTIONS
14
+ -a, --all Prints all attributes as JSON
15
+ `
16
+
17
+ export default async function main() {
18
+ const { values, files, usage } = await parseOptions(HELP, {
19
+ all: { short: 'a', type: 'boolean' }
20
+ })
21
+
22
+ const video = files[0]
23
+ if (!video) throw usage('No video file specified')
24
+
25
+ if (values.all)
26
+ console.log(JSON.stringify(await videoAttrs(video), '', 2))
27
+ else
28
+ console.log(await infoSummary(video))
29
+ }
30
+
31
+
32
+ export async function infoSummary(video) {
33
+ const v = await videoAttrs(video)
34
+ 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(' ')
40
+ }
41
+
42
+ function fps(rFrameRate) {
43
+ const [num, den] = rFrameRate.split('/').map(Number)
44
+ return cleanDecimals((num / den).toFixed(2))
45
+ }
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/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 = `
@@ -17,10 +17,10 @@ SEE ALSO
17
17
  `
18
18
 
19
19
  export default async function main() {
20
- const { values, files } = await parseOptions(HELP)
20
+ const { values, files, usage } = await parseOptions(HELP)
21
21
 
22
22
  if (!files.length)
23
- throw 'Missing input file(s)'
23
+ throw usage('Missing input file(s)')
24
24
 
25
25
  for (const file of files) {
26
26
  await moov2front(file)
package/src/openrand.js CHANGED
@@ -14,12 +14,12 @@ DESCRIPTION
14
14
  `
15
15
 
16
16
  export default async function main() {
17
- const { values, positionals } = await parseOptions(HELP, {
17
+ const { values, positionals, usage } = await parseOptions(HELP, {
18
18
  recursive: { short: 'r', type: 'boolean' },
19
19
  })
20
20
 
21
21
  if (process.platform !== 'darwin')
22
- throw 'This command is only supported on macOS.'
22
+ throw usage('This command is only supported on macOS.')
23
23
 
24
24
  const dir = positionals[0] || '.'
25
25
  openrand(dir, values.recursive)
package/src/play.js CHANGED
@@ -17,7 +17,7 @@ EXAMPLE
17
17
 
18
18
 
19
19
  export default async function main() {
20
- const { values, positionals } = await parseOptions(HELP, {
20
+ const { values, positionals, usage } = await parseOptions(HELP, {
21
21
  recursive: { short: 'r', type: 'boolean', default: true },
22
22
  }, { allowNegative: true })
23
23
 
@@ -29,7 +29,7 @@ export default async function main() {
29
29
  })
30
30
 
31
31
  if (!files.length)
32
- throw 'No matching files found.'
32
+ throw usage('No matching files found.')
33
33
 
34
34
  play(files)
35
35
  }
package/src/png.js CHANGED
@@ -14,10 +14,10 @@ EXAMPLE
14
14
  `
15
15
 
16
16
  export default async function main() {
17
- const { values, files } = await parseOptions(HELP)
17
+ const { values, files, usage } = await parseOptions(HELP)
18
18
 
19
19
  if (!files.length)
20
- throw 'Missing input image(s)'
20
+ throw usage('Missing input image(s)')
21
21
 
22
22
  await png(...files)
23
23
  }
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
@@ -51,30 +52,29 @@ EXAMPLES
51
52
 
52
53
 
53
54
  export default async function main() {
54
- const { values, files } = await parseOptions(HELP, {
55
+ const { values, files, usage } = await parseOptions(HELP, {
55
56
  profile: { short: 'p', type: 'string', default: String(ProresProfiles.default) },
56
57
  start: { short: 's', type: 'string' },
57
58
  end: { short: 'e', type: 'string' },
58
59
  })
59
60
 
60
61
  if (!ProresProfiles.isValid(Number(values.profile)))
61
- throw 'Invalid profile. Must be one of: ' + ProresProfiles.list().join(',')
62
+ throw usage('Invalid profile. Must be one of: ' + ProresProfiles.list().join(','))
62
63
 
63
64
  if (files.length !== 1)
64
- throw 'Expected 1 argument: video file.'
65
+ throw usage('Expected 1 argument: video file.')
65
66
 
66
67
  const video = resolve(files[0])
67
68
  const { name, dir } = parse(video)
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 = `
@@ -32,7 +32,7 @@ EXAMPLES
32
32
  `
33
33
 
34
34
  export default async function main() {
35
- const { values, files } = await parseOptions(HELP, {
35
+ const { values, files, usage } = await parseOptions(HELP, {
36
36
  width: { type: 'string', default: '-2' },
37
37
  height: { type: 'string', default: '-2' },
38
38
  outdir: { type: 'string', default: '' },
@@ -42,8 +42,8 @@ export default async function main() {
42
42
  const width = Number(values.width)
43
43
  const height = Number(values.height)
44
44
 
45
- if (!files.length) throw 'No video files specified'
46
- if (width <= 0 && height <= 0) throw '--width or --height must be > 0'
45
+ if (!files.length) throw usage('No video files specified')
46
+ if (width <= 0 && height <= 0) throw usage('--width or --height must be > 0')
47
47
 
48
48
  for (const file of files) {
49
49
  await resize({
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 = `
@@ -16,13 +16,13 @@ DESCRIPTION
16
16
  `
17
17
 
18
18
  export default async function main() {
19
- const { values, files } = await parseOptions(HELP, {
19
+ const { values, files, usage } = await parseOptions(HELP, {
20
20
  outdir: { type: 'string', default: '' },
21
21
  overwrite: { short: 'y', type: 'boolean' },
22
22
  })
23
23
 
24
24
  if (!files.length)
25
- throw 'No images specified'
25
+ throw usage('No images specified')
26
26
 
27
27
  for (const file of files) {
28
28
  await sqcrop({
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 = `
@@ -11,10 +11,10 @@ DESCRIPTION
11
11
  `
12
12
 
13
13
  export default async function main() {
14
- const { values, positionals } = await parseOptions(HELP)
14
+ const { values, positionals, usage } = await parseOptions(HELP)
15
15
 
16
16
  if (positionals.length !== 2)
17
- throw 'Expected two images'
17
+ throw usage('Expected two images')
18
18
 
19
19
  const score = await ssim(...positionals)
20
20
  console.log(score.toString())
package/src/unemoji.js CHANGED
@@ -29,12 +29,12 @@ const EMOJI_RE = new RegExp(
29
29
  )
30
30
 
31
31
  export default async function main() {
32
- const { values, positionals } = await parseOptions(HELP, {
32
+ const { values, positionals, usage } = await parseOptions(HELP, {
33
33
  recursive: { short: 'r', type: 'boolean' }
34
34
  })
35
35
 
36
36
  if (positionals.length !== 1)
37
- throw 'Must pass only one dir'
37
+ throw usage('Must pass only one dir')
38
38
 
39
39
  const files = findFiles({
40
40
  dir: positionals[0],
@@ -0,0 +1,53 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { printProgress } from './printProgress.js'
3
+ import { videoAttrs } from './videoAttrs.js'
4
+ import { runSilently } from './subprocess.js'
5
+
6
+
7
+ async function assertUserHasFFmpeg() {
8
+ try {
9
+ await runSilently('ffmpeg', ['-version'])
10
+ await runSilently('ffprobe', ['-version'])
11
+ }
12
+ catch {
13
+ throw new Error('ffmpeg not found. Please install ffmpeg.')
14
+ }
15
+ }
16
+
17
+
18
+ export async function ffmpeg(args) {
19
+ await assertUserHasFFmpeg()
20
+ return runSilently('ffmpeg', args)
21
+ }
22
+
23
+
24
+ export async function ffmpegWithProgress(input, args, onProgress = printProgress) {
25
+ await assertUserHasFFmpeg()
26
+ const µsVideoDuration = 1e6 * (await videoAttrs(input)).duration
27
+
28
+ const p = spawn('ffmpeg', [
29
+ '-v', 'error',
30
+ '-nostats',
31
+ '-progress', 'pipe:1',
32
+ ...args
33
+ ], { stdio: ['inherit', 'pipe', 'pipe'] })
34
+
35
+ p.stderr.pipe(process.stderr)
36
+ p.stdout.on('data', chunk => {
37
+ const text = chunk.toString()
38
+ if (text.includes('progress=end'))
39
+ onProgress(1)
40
+ else {
41
+ const m = text.match(/out_time_us=(\d+)/)
42
+ onProgress(Number(m[1]) / µsVideoDuration)
43
+ }
44
+ })
45
+
46
+ await new Promise((resolve, reject) => {
47
+ p.on('error', reject)
48
+ p.on('close', code => {
49
+ if (code === 0) resolve()
50
+ else reject(Error(`ffmpeg failed with code ${code}`))
51
+ })
52
+ })
53
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Converts seconds to a string like "9h9m9s".
3
+ *
4
+ * Examples:
5
+ * formatSeconds(1.1) -> "1.1s"
6
+ * formatSeconds(3661, 2) -> "1h1m1s"
7
+ * formatSeconds(3661.0, 2) -> "1h1m1s"
8
+ * formatSeconds(3661.25, 2) -> "1h1m1.25s"
9
+ */
10
+ export function formatSeconds(seconds, maxDecimals = 2) {
11
+ const intSeconds = seconds | 0
12
+ const partialSeconds = seconds % 60
13
+ const minutes = (intSeconds % 3600) / 60 | 0
14
+ const hours = intSeconds / 3600 | 0
15
+
16
+ let result = ''
17
+ if (hours) result += `${hours}h`
18
+ if (minutes) result += `${minutes}m`
19
+ if (partialSeconds || !result) result += `${cleanDecimals(partialSeconds.toFixed(maxDecimals))}s`
20
+ return result
21
+ }
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
+ }
@@ -1,4 +1,4 @@
1
- import { promisify, parseArgs } from 'node:util'
1
+ import { promisify, parseArgs, styleText } from 'node:util'
2
2
  import { glob as _glob } from 'node:fs'
3
3
 
4
4
  const glob = promisify(_glob)
@@ -28,7 +28,10 @@ export async function parseOptions(helpText, options = {}, config = {}) {
28
28
  return {
29
29
  values,
30
30
  positionals,
31
- files: await resolveGlobs(positionals, tokens)
31
+ files: await resolveGlobs(positionals, tokens),
32
+ usage: err => err
33
+ ? styleText('redBright', '' + err + '\n') + helpText
34
+ : helpText
32
35
  }
33
36
  }
34
37
 
@@ -0,0 +1,14 @@
1
+ export function printProgress(progress) {
2
+ process.stdout.write(`\r${progressBar(progress)} ${(progress * 100).toFixed(1)}%`)
3
+ if (progress === 1)
4
+ process.stdout.write('\n')
5
+ }
6
+
7
+ function progressBar(progress, width = 42) {
8
+ const nFull = (width * progress) | 0
9
+ const fPartial = (width * progress) - nFull
10
+ const nRemaining = width - nFull
11
+ const partials = ' ▏▎▍▌▋▊▉'
12
+ const partial = partials[Math.min(partials.length * fPartial | 0, partials.length - 1)]
13
+ return '█'.repeat(nFull) + partial + '⠂'.repeat(nRemaining)
14
+ }
@@ -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'] })
package/src/vsplit.js CHANGED
@@ -35,10 +35,10 @@ SEE ALSO
35
35
 
36
36
 
37
37
  export default async function main() {
38
- const { values, files } = await parseOptions(HELP)
38
+ const { values, files, usage } = await parseOptions(HELP)
39
39
 
40
40
  if (files.length !== 2)
41
- throw 'Expected 2 arguments: CSV file and video file.'
41
+ throw usage('Expected 2 arguments: CSV file and video file.')
42
42
 
43
43
  const [csvPath, videoPath] = files.map(f => resolve(f))
44
44
 
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 = `
@@ -20,13 +20,13 @@ SEE ALSO
20
20
 
21
21
 
22
22
  export default async function main() {
23
- const { values, files } = await parseOptions(HELP, {
23
+ const { values, files, usage } = await parseOptions(HELP, {
24
24
  start: { short: 's', type: 'string' },
25
25
  end: { short: 'e', type: 'string' },
26
26
  })
27
27
 
28
28
  if (!files.length)
29
- throw 'No video specified.'
29
+ throw usage('No video specified.')
30
30
 
31
31
  for (const file of files)
32
32
  await vtrim({