mediasnacks 0.14.0 → 0.16.1

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.
Files changed (52) hide show
  1. package/.github/workflows/test.yml +25 -0
  2. package/.zsh/completions/_mediasnacks +11 -10
  3. package/Dockerfile +13 -0
  4. package/README.md +47 -27
  5. package/docs/macos-quick-action.png +0 -0
  6. package/generate-fixtures.sh +11 -0
  7. package/package.json +3 -3
  8. package/src/avif.js +2 -2
  9. package/src/avif.test.js +19 -0
  10. package/src/cli.js +17 -17
  11. package/src/dropdups.js +1 -1
  12. package/src/edgespic.js +77 -0
  13. package/src/edgespic.test.js +32 -0
  14. package/src/fixtures/60fps.csv +7 -0
  15. package/src/fixtures/edgespic/60fps_first.png +0 -0
  16. package/src/fixtures/edgespic/60fps_last.png +0 -0
  17. package/src/fixtures/lenna.avif +0 -0
  18. package/src/gif.sh +5 -7
  19. package/src/qdir.js +3 -6
  20. package/src/qdir.test.js +24 -0
  21. package/src/resize.js +2 -2
  22. package/src/seqcheck.js +2 -2
  23. package/{tests → src}/seqcheck.test.js +1 -1
  24. package/src/sqcrop.js +3 -3
  25. package/src/utils/fs-utils.js +11 -1
  26. package/src/utils/parseOptions.test.js +2 -2
  27. package/src/utils/test-utils.js +21 -0
  28. package/src/vconcat.sh +9 -7
  29. package/src/vconcat.test.js +23 -0
  30. package/src/vsplit.js +100 -0
  31. package/src/vsplit.test.js +32 -0
  32. package/src/vtrim.sh +7 -6
  33. package/src/vtrim.test.js +22 -0
  34. package/src/vsplit.sh +0 -50
  35. package/tests/avif.test.js +0 -15
  36. package/tests/fixtures/60fps_1.mp4 +0 -0
  37. package/tests/fixtures/60fps_2.mp4 +0 -0
  38. package/tests/fixtures/60fps_3.mp4 +0 -0
  39. package/tests/fixtures/60fps_4.mp4 +0 -0
  40. package/tests/fixtures/60fps_5.mp4 +0 -0
  41. package/tests/fixtures/60fps_6.mp4 +0 -0
  42. package/tests/fixtures/lenna.avif +0 -0
  43. package/tests/qdir/qdir.test.js +0 -26
  44. package/tests/utils.js +0 -6
  45. package/tests/vsplit.test.js +0 -22
  46. package/tests/vtrim.test.js +0 -20
  47. /package/{tests → src}/fixtures/60fps.mp4 +0 -0
  48. /package/{tests → src}/fixtures/lenna.png +0 -0
  49. /package/{tests/qdir/jobs → src/fixtures/qdir-jobs}/job1_good.sh +0 -0
  50. /package/{tests/qdir/jobs → src/fixtures/qdir-jobs}/job2_bad.sh +0 -0
  51. /package/{tests/qdir/jobs → src/fixtures/qdir-jobs}/job3_good.sh +0 -0
  52. /package/{tests/qdir/jobs → src/fixtures/qdir-jobs}/job4_bad.sh +0 -0
@@ -0,0 +1,25 @@
1
+ name: Test
2
+
3
+ on: [ push, pull_request, workflow_dispatch ]
4
+
5
+ permissions:
6
+ contents: read
7
+ pull-requests: write
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - uses: actions/checkout@v6
15
+ - uses: docker/setup-buildx-action@v4
16
+ - uses: docker/build-push-action@v7
17
+ with:
18
+ context: .
19
+ load: true
20
+ tags: app-test
21
+ cache-from: type=gha
22
+ cache-to: type=gha,mode=max
23
+
24
+ - name: Tests
25
+ run: docker run --rm app-test
@@ -2,25 +2,26 @@
2
2
 
3
3
  _mediasnacks_commands=(
4
4
  'avif:Converts images to AVIF'
5
- 'resize:Resizes videos or images'
6
5
  'sqcrop:Square crops images'
7
- 'moov2front:Rearranges metadata for fast-start streaming'
8
6
  'dropdups:Removes duplicate frames in a video'
9
- 'seqcheck:Finds missing sequence number'
10
- 'qdir:Sequentially runs all *.sh files in a folder'
11
- 'hev1tohvc1:Fixes video thumbnails not rendering in macOS Finder'
7
+ 'edgespic:Extracts first and last frames'
12
8
  'framediff:ffplay with a filter for diffing adjacent frames'
13
- 'vdiff:Plays a video with the difference of two videos'
9
+ 'gif:Video to GIF'
10
+ 'hev1tohvc1:Fixes video thumbnails not rendering in macOS Finder'
11
+ 'moov2front:Rearranges metadata for fast-start streaming'
12
+ 'resize:Resizes videos or images'
14
13
  'vconcat:Concatenates videos'
14
+ 'vdiff:Plays a video with the difference of two videos'
15
+ 'vsplit:Splits a video into multiple clips from CSV timestamps'
15
16
  'vtrim:Trims video from start to end time'
17
+ 'flattendir:Moves unique files to the top dir and deletes empty dirs'
18
+ 'seqcheck:Finds missing sequence number'
19
+ 'qdir:Sequentially runs all *.sh files in a folder'
16
20
  'dlaudio: yt-dlp best audio'
17
21
  'dlvideo: yt-dlp best video'
18
22
  'unemoji:Removes emojis from filenames'
19
23
  'rmcover:Removes cover art'
20
24
  'curltime:Measures request response timings'
21
- 'gif:Video to GIF'
22
- 'vsplit:Split video at multiple time points'
23
- 'flattendir:Moves unique files to the top dir and deletes empty dirs'
24
25
  )
25
26
 
26
27
  if (( CURRENT == 2 )); then
@@ -30,7 +31,7 @@ fi
30
31
 
31
32
  local cmd="$words[2]"
32
33
  case "$cmd" in
33
- avif|resize|sqcrop|moov2front|dropdups|seqcheck|hev1tohvc1|framediff|vdiff|vconcat|vtrim|dlaudio|dlvideo|unemoji|rmcover|curltime|gif|vsplit|flattendir)
34
+ avif|resize|sqcrop|moov2front|dropdups|edgespic|seqcheck|hev1tohvc1|framediff|vdiff|vconcat|vsplit|vtrim|dlaudio|dlvideo|unemoji|rmcover|curltime|gif|flattendir)
34
35
  _files
35
36
  ;;
36
37
  qdir)
package/Dockerfile ADDED
@@ -0,0 +1,13 @@
1
+ FROM mwader/static-ffmpeg:8.0.1 AS ffmpeg
2
+ FROM node:24-bookworm-slim
3
+
4
+ COPY --from=ffmpeg /ffmpeg /usr/local/bin/ffmpeg
5
+ COPY --from=ffmpeg /ffprobe /usr/local/bin/ffprobe
6
+
7
+ ENV FORCE_COLOR=1
8
+ WORKDIR /workspace
9
+
10
+ COPY src/ src/
11
+ COPY package.json .
12
+
13
+ CMD ["node", "--test"]
package/README.md CHANGED
@@ -3,28 +3,33 @@
3
3
  Utilities optimizing and preparing video and images for the web.
4
4
 
5
5
 
6
- ## Usage Overview
6
+ ## Overview
7
7
  **FFmpeg and Node.js must be installed.**
8
8
 
9
9
  ```shell
10
10
  npx mediasnacks <command> <args>
11
11
  ```
12
12
 
13
- Commands:
13
+ ### Commands
14
14
  - `avif` Converts images to AVIF
15
- - `resize` Resizes videos or images
16
15
  - `sqcrop` Square crops images
17
- - `moov2front` Rearranges .mov and .mp4 metadata for fast-start streaming
16
+
18
17
  - `dropdups` Removes duplicate frames in a video
19
- - `seqcheck` Finds missing sequence number
20
- - `qdir` Sequentially runs all *.sh files in a folder
21
- - `hev1tohvc1`: Fixes video thumbnails not rendering in macOS Finder
22
-
18
+ - `edgespic` Extracts first and last frames
23
19
  - `framediff`: Plays a video of adjacent frames diff
24
- - `vdiff`: Plays a video with the difference of two videos
20
+ - `gif`: Video to GIF
21
+ - `hev1tohvc1`: Fixes video thumbnails not rendering in macOS Finder
22
+ - `moov2front` Rearranges .mov and .mp4 metadata for fast-start streaming
23
+ - `resize` Resizes videos or images
25
24
  - `vconcat`: Concatenates videos
25
+ - `vdiff`: Plays a video with the difference of two videos
26
+ - `vsplit`: Splits a video into multiple clips from CSV timestamps
26
27
  - `vtrim`: Trims video from start to end time
27
28
 
29
+ - `flattendir`: Moves unique files to the top dir and deletes empty dirs
30
+ - `qdir` Sequentially runs all *.sh files in a folder
31
+ - `seqcheck` Finds missing sequence number
32
+
28
33
  - `dlaudio`: yt-dlp best audio
29
34
  - `dlvideo`: yt-dlp best video
30
35
 
@@ -32,32 +37,26 @@ Commands:
32
37
  - `rmcover`: Removes cover art
33
38
 
34
39
  - `curltime`: Measures request response timings
35
- - `gif`: Video to GIF
36
- - `vsplit`: Split video at multiple time points
37
-
38
- - `flattendir`: Moves unique files to the top dir and deletes empty dirs
39
-
40
- ### Glob Patterns and Literal Filenames
41
-
42
- Most commands accept glob patterns (like `*.png` or `file[234].png`) to match multiple
43
- files. By default, these patterns are expanded by Node.js to match existing files.
44
40
 
45
- To treat arguments as literal filenames instead of
46
- glob patterns, use the `--` (double dash) separator:
41
+ ### Globs
42
+ Glob patterns are expanded by Node.js to match existing files.
47
43
 
48
44
  ```shell
49
- # Expands to: file2.png, file3.png, file4.png
50
45
  npx mediasnacks avif file[234].png
46
+ # Expands to: file2.png, file3.png, file4.png
47
+ ```
51
48
 
52
- # Literal filename: "file[234].png"
49
+ ```shell
53
50
  npx mediasnacks avif -- file[234].png
54
-
55
- # Mixed: expand first pattern, treat second as literal
56
- npx mediasnacks avif file2.png -- file[234].png
51
+ # Literal filename: "file[234].png"
57
52
  ```
58
53
 
59
54
  <br/>
60
55
 
56
+ ---
57
+
58
+ ## Commands
59
+
61
60
  ### Converting Images to AVIF
62
61
  ```shell
63
62
  npx mediasnacks avif [-y | --overwrite] [--output-dir=<dir>] <images>
@@ -103,5 +102,26 @@ What is Fast Start?
103
102
 
104
103
  <br/>
105
104
 
106
- ### License
107
- MIT
105
+ ---
106
+
107
+ ## Adding a macOS Quick Action
108
+
109
+
110
+ ![](./docs/macos-quick-action.png)
111
+
112
+
113
+ For example, for `dropdups -n2 file.mov`
114
+
115
+ - Open Automator
116
+ - Select: Quick Action
117
+ - Workflow receives current: `movie files` in `Finder.app`
118
+ - Action: `Run Shell Script`
119
+ ```shell
120
+ export PATH="/opt/homebrew/bin"
121
+ for f in "$@"; do
122
+ $HOME/bin/mediasnacks dropdups -n2 "$f"
123
+ done
124
+ ```
125
+
126
+ It will be saved to `~/Library/Services`
127
+
Binary file
@@ -0,0 +1,11 @@
1
+ #!/bin/sh
2
+
3
+ docker run --rm \
4
+ -v "$(pwd)/src/fixtures:/workspace/src/fixtures" \
5
+ $(docker build -q .) \
6
+ /bin/bash -c "
7
+ node --test
8
+ cp /tmp/avif*/lenna.avif /workspace/src/fixtures/
9
+ cp /tmp/edgespic*/edgespic/*.png /workspace/src/fixtures/edgespic/
10
+ "
11
+
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mediasnacks",
3
- "version": "0.14.0",
4
- "description": "Utilities for preparing videos, images, and audio for the web",
3
+ "version": "0.16.1",
4
+ "description": "Utilities for optimizing and preparing videos and images for the web",
5
5
  "license": "MIT",
6
6
  "author": "Eric Fortis",
7
7
  "type": "module",
@@ -9,6 +9,6 @@
9
9
  "mediasnacks": "src/cli.js"
10
10
  },
11
11
  "scripts": {
12
- "test": "node --test"
12
+ "test": "docker run --rm $(docker build -q .)"
13
13
  }
14
14
  }
package/src/avif.js CHANGED
@@ -19,8 +19,8 @@ async function main() {
19
19
 
20
20
  const { values, files } = await parseOptions({
21
21
  'output-dir': { type: 'string', default: '' },
22
- overwrite: { short: 'y', type: 'boolean', default: false },
23
- help: { short: 'h', type: 'boolean', default: false },
22
+ overwrite: { short: 'y', type: 'boolean' },
23
+ help: { short: 'h', type: 'boolean' },
24
24
  })
25
25
 
26
26
  if (values.help) {
@@ -0,0 +1,19 @@
1
+ import { join } from 'node:path'
2
+ import { test } from 'node:test'
3
+ import { deepEqual } from 'node:assert/strict'
4
+
5
+ import { videoAttrs } from './utils/ffmpeg.js'
6
+ import { mkTempDir, cli } from './utils/test-utils.js'
7
+
8
+ const rel = f => join(import.meta.dirname, f)
9
+
10
+ test('PNG to AVIF', async () => {
11
+ const tmp = mkTempDir('avif')
12
+ cli('avif', '--output-dir', tmp, rel('fixtures/lenna.png'))
13
+
14
+ deepEqual(
15
+ await videoAttrs(join(tmp, 'lenna.avif')),
16
+ await videoAttrs(rel('fixtures/lenna.avif')))
17
+ // That's because we use non-deterministic avif.
18
+ // Claude says: avif is deterministic only when it's single-threaded: '-threads 1'
19
+ })
package/src/cli.js CHANGED
@@ -7,39 +7,39 @@ import pkgJSON from '../package.json' with { type: 'json' }
7
7
 
8
8
  const COMMANDS = {
9
9
  avif: ['avif.js', 'Converts images to AVIF'],
10
- resize: ['resize.js', 'Resizes videos or images'],
11
- sqcrop: ['sqcrop.js', 'Square crops images'],
12
- moov2front: ['moov2front.js', 'Rearranges .mov and .mp4 metadata for fast-start streaming'],
10
+ sqcrop: ['sqcrop.js', 'Square crops images\n'],
13
11
 
14
12
  dropdups: ['dropdups.js', 'Removes duplicate frames in a video'],
15
- seqcheck: ['seqcheck.js', 'Finds missing sequence number'],
16
- qdir: ['qdir.js', 'Sequentially runs all *.sh files in a folder'],
17
- hev1tohvc1: ['hev1tohvc1.js', 'Fixes video thumbnails not rendering in macOS Finder '],
18
-
13
+ edgespic: ['edgespic.js', 'Extracts first and last frames'],
19
14
  framediff: ['framediff.sh', 'Plays a video of adjacent frames diff'],
15
+ gif: ['gif.sh', 'Video to GIF'],
16
+ hev1tohvc1: ['hev1tohvc1.js', 'Fixes video thumbnails not rendering in macOS Finder '],
17
+ moov2front: ['moov2front.js', 'Rearranges .mov and .mp4 metadata for fast-start streaming'],
18
+ resize: ['resize.js', 'Resizes videos or images'],
19
+ vconcat: ['vconcat.sh', 'Concatenates videos'],
20
20
  vdiff: ['vdiff.sh', 'Plays a video with the difference of two videos'],
21
+ vsplit: ['vsplit.js', 'Splits a video into multiple clips from CSV timestamps'],
22
+ vtrim: ['vtrim.sh', 'Trims video from start to end time\n'],
21
23
 
22
- vconcat: ['vconcat.sh', 'Concatenates videos'],
23
- vtrim: ['vtrim.sh', 'Trims video from start to end time'],
24
+ flattendir: ['flattendir.sh', 'Moves all files to top dir and deletes dirs'],
25
+ qdir: ['qdir.js', 'Sequentially runs all *.sh files in a folder'],
26
+ seqcheck: ['seqcheck.js', 'Finds missing sequence number\n'],
24
27
 
25
28
  dlaudio: ['dlaudio.sh', 'yt-dlp best audio'],
26
- dlvideo: ['dlvideo.sh', 'yt-dlp best video'],
29
+ dlvideo: ['dlvideo.sh', 'yt-dlp best video\n'],
27
30
 
28
31
  unemoji: ['unemoji.sh', 'Removes emojis from filenames'],
29
- rmcover: ['rmcover.sh', 'Removes cover art'],
30
-
32
+ rmcover: ['rmcover.sh', 'Removes cover art\n'],
33
+
31
34
  curltime: ['curltime.sh', 'Measures request response timings'],
32
- gif: ['gif.sh', 'Video to GIF'],
33
- vsplit: ['vsplit.sh', 'Split video at multiple time points'],
34
- flattendir: ['flattendir.sh', 'Moves all files to top dir and deletes dirs']
35
35
  }
36
36
 
37
37
  const USAGE = `
38
- Usage: npx mediasnacks <command> <args>
38
+ Usage: mediasnacks <command> <args>
39
39
 
40
40
  Commands:
41
41
  ${Object.entries(COMMANDS).map(([cmd, [, title]]) =>
42
- ` ${cmd}\t${title}`).join('\n')}
42
+ ` ${cmd.padEnd(12, ' ')}\t${title}`).join('\n')}
43
43
  `.trim()
44
44
 
45
45
 
package/src/dropdups.js CHANGED
@@ -36,7 +36,7 @@ async function main() {
36
36
 
37
37
  const { values, files } = await parseOptions({
38
38
  'bad-frame-number': { short: 'n', type: 'string', default: '' },
39
- help: { short: 'h', type: 'boolean', default: false },
39
+ help: { short: 'h', type: 'boolean' },
40
40
  })
41
41
 
42
42
  if (values.help) {
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { basename, extname, join, parse } from 'node:path'
4
+
5
+ import { mkDir } from './utils/fs-utils.js'
6
+ import { parseOptions } from './utils/parseOptions.js'
7
+ import { ffmpeg, videoAttrs, assertUserHasFFmpeg } from './utils/ffmpeg.js'
8
+
9
+
10
+ const USAGE = `
11
+ Usage: npx mediasnacks edgespic [--width=<num>] <files>
12
+
13
+ Extracts the first and last frames from each video and saves them to the 'edgepics/' subfolder.
14
+ --width defaults to 640px and The aspect ratio is preserved.
15
+
16
+ Example:
17
+ npx mediasnacks edgespic --width 800 *.mov
18
+ npx mediasnacks edgespic -w 600 'videos/**/*.mp4'
19
+ `.trim()
20
+
21
+
22
+ async function main() {
23
+ await assertUserHasFFmpeg()
24
+
25
+ const { values, files } = await parseOptions({
26
+ 'width': { short: 'w', type: 'string', default: '640' },
27
+ help: { short: 'h', type: 'boolean' },
28
+ })
29
+
30
+ if (values.help) {
31
+ console.log(USAGE)
32
+ process.exit(0)
33
+ }
34
+
35
+ const width = Number(values['width'])
36
+ if (width <= 0 || !Number.isInteger(width))
37
+ throw new Error('--width must be a positive number')
38
+
39
+ if (!files.length)
40
+ throw new Error('No video files specified')
41
+
42
+ const outDir = join(parse(files[0]).dir, 'edgespic')
43
+ await mkDir(outDir)
44
+
45
+ console.log('Extracting edge frames…')
46
+ for (const file of files)
47
+ await extractEdgeFrames(file, width, outDir)
48
+ }
49
+
50
+
51
+ async function extractEdgeFrames(video, width, outDir) {
52
+ const { r_frame_rate } = await videoAttrs(video)
53
+ const name = basename(video, extname(video))
54
+
55
+ await ffmpeg([
56
+ '-y',
57
+ '-i', video,
58
+ '-vf', `scale=${width}:-1`,
59
+ '-frames:v', '1',
60
+ join(outDir, `${name}_first.png`)
61
+ ])
62
+
63
+ await ffmpeg([
64
+ '-y',
65
+ '-sseof', -1 / eval(r_frame_rate),
66
+ '-i', video,
67
+ '-vf', `scale=${width}:-1`,
68
+ '-frames:v', '1',
69
+ join(outDir, `${name}_last.png`)
70
+ ])
71
+ }
72
+
73
+
74
+ main().catch(err => {
75
+ console.error(err.message)
76
+ process.exit(1)
77
+ })
@@ -0,0 +1,32 @@
1
+ import { join } from 'node:path'
2
+ import { ok, equal } from 'node:assert/strict'
3
+ import { describe, test } from 'node:test'
4
+ import { cpSync, readdirSync, } from 'node:fs'
5
+
6
+ import { cli, mkTempDir, sha1 } from './utils/test-utils.js'
7
+
8
+ const rel = f => join(import.meta.dirname, f)
9
+
10
+ describe('edgespic', () => {
11
+ const tmp = mkTempDir('edgespic')
12
+ const inputFile = join(tmp, '60fps.mp4')
13
+ cpSync(rel('fixtures/60fps.mp4'), inputFile)
14
+ cli('edgespic', inputFile)
15
+
16
+ test('creates output directory', () => {
17
+ const files = readdirSync(join(tmp, 'edgespic'))
18
+ ok(files.length === 2, `Expected 2 PNG files, got ${files.length}`)
19
+ })
20
+
21
+ test('extracts first frame', () => {
22
+ const fixture = rel('fixtures/edgespic/60fps_first.png')
23
+ const generated = join(tmp, 'edgespic', '60fps_first.png')
24
+ equal(sha1(generated), sha1(fixture))
25
+ })
26
+
27
+ test('extracts last frame', () => {
28
+ const fixture = rel('fixtures/edgespic/60fps_last.png')
29
+ const generated = join(tmp, 'edgespic', '60fps_last.png')
30
+ equal(sha1(generated), sha1(fixture))
31
+ })
32
+ })
@@ -0,0 +1,7 @@
1
+ start,end
2
+ 0,5
3
+ 5,10
4
+ 10,15
5
+ 15,20
6
+ 20,25
7
+ 25,30
Binary file
package/src/gif.sh CHANGED
@@ -1,6 +1,6 @@
1
- #!/bin/zsh
1
+ #!/bin/sh
2
2
 
3
- # Convert to GIF
3
+ # Converts to GIF
4
4
 
5
5
  FPS=10
6
6
  WIDTH=600
@@ -10,7 +10,7 @@ usage() {
10
10
  exit 1
11
11
  }
12
12
 
13
- while [[ $# -gt 0 ]]; do
13
+ while [ $# -gt 0 ]; do
14
14
  case "$1" in
15
15
  --fps)
16
16
  FPS="$2";
@@ -26,10 +26,8 @@ while [[ $# -gt 0 ]]; do
26
26
  esac
27
27
  done
28
28
 
29
- [[ -z "$file" ]] && usage
30
-
31
- outfile="${file:r}.gif"
29
+ [ -z "$file" ] && usage
32
30
 
33
31
  ffmpeg -v error -i "$file" \
34
32
  -vf "fps=${FPS},scale=${WIDTH}:-1" \
35
- "$outfile"
33
+ "${file%.*}.gif"
package/src/qdir.js CHANGED
@@ -12,17 +12,14 @@ import { isFile } from './utils/fs-utils.js'
12
12
  const USAGE = `
13
13
  Usage: npx mediasnacks qdir [folder]
14
14
 
15
- Sequentially runs all *.sh files in a folder.
16
- It uses the current working directory by default.
17
-
18
- -h, --help
15
+ Sequentially runs all *.sh files in a folder (cwd by default).
19
16
  `.trim()
20
17
 
21
18
 
22
19
  async function main() {
23
20
  const { values, positionals } = parseArgs({
24
21
  options: {
25
- help: { short: 'h', type: 'boolean', default: false },
22
+ help: { short: 'h', type: 'boolean' },
26
23
  },
27
24
  allowPositionals: true,
28
25
  })
@@ -95,7 +92,7 @@ function sleep(ms) {
95
92
  }
96
93
 
97
94
 
98
- if (fileURLToPath(import.meta.url) === process.argv[1])
95
+ if (import.meta.main)
99
96
  main().catch(err => {
100
97
  console.error(err.message || err)
101
98
  process.exit(1)
@@ -0,0 +1,24 @@
1
+ import { test } from 'node:test'
2
+ import { join } from 'node:path'
3
+ import { equal, deepEqual } from 'node:assert/strict'
4
+ import { cpSync, readdirSync } from 'node:fs'
5
+
6
+ import { qdir } from './qdir.js'
7
+ import { mkTempDir } from './utils/test-utils.js'
8
+
9
+ const rel = f => join(import.meta.dirname, f)
10
+
11
+ test('qdir-jobs get renamed and failed have their exit status code', async () => {
12
+ const tmp = mkTempDir('qdir')
13
+ cpSync(rel('fixtures/qdir-jobs'), tmp, { recursive: true })
14
+
15
+ const err = await qdir(tmp, 0.2)
16
+ equal(err, null)
17
+
18
+ deepEqual(readdirSync(tmp).sort(), [
19
+ 'job1_good.sh.done',
20
+ 'job2_bad.sh.failed.1',
21
+ 'job3_good.sh.done',
22
+ 'job4_bad.sh.failed.1'
23
+ ])
24
+ })
package/src/resize.js CHANGED
@@ -34,8 +34,8 @@ async function main() {
34
34
  width: { type: 'string', default: '-2' },
35
35
  height: { type: 'string', default: '-2' },
36
36
  'output-dir': { type: 'string', default: '' },
37
- overwrite: { short: 'y', type: 'boolean', default: false },
38
- help: { short: 'h', type: 'boolean', default: false },
37
+ overwrite: { short: 'y', type: 'boolean' },
38
+ help: { short: 'h', type: 'boolean' },
39
39
  })
40
40
 
41
41
  if (values.help) {
package/src/seqcheck.js CHANGED
@@ -22,7 +22,7 @@ function main() {
22
22
  options: {
23
23
  'left-delimiter': { type: 'string', default: '_' },
24
24
  'right-delimiter': { type: 'string', default: '.' },
25
- help: { short: 'h', type: 'boolean', default: false },
25
+ help: { short: 'h', type: 'boolean' },
26
26
  },
27
27
  allowPositionals: true,
28
28
  })
@@ -68,5 +68,5 @@ function escapeRegex(str) {
68
68
  }
69
69
 
70
70
 
71
- if (fileURLToPath(import.meta.url) === process.argv[1])
71
+ if (import.meta.main)
72
72
  main()
@@ -1,6 +1,6 @@
1
1
  import { test } from 'node:test'
2
2
  import { deepEqual } from 'node:assert/strict'
3
- import { extractSeqNums, findMissingNumbers } from '../src/seqcheck.js'
3
+ import { extractSeqNums, findMissingNumbers } from './seqcheck.js'
4
4
 
5
5
 
6
6
  test('extractSeqNums extracts sequence numbers from filenames', () => {
package/src/sqcrop.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { join } from 'node:path'
4
-
5
4
  import { rename } from 'node:fs/promises'
5
+
6
6
  import { ffmpeg, assertUserHasFFmpeg } from './utils/ffmpeg.js'
7
7
  import { lstat, uniqueFilenameFor } from './utils/fs-utils.js'
8
8
  import { parseOptions } from './utils/parseOptions.js'
@@ -20,8 +20,8 @@ async function main() {
20
20
 
21
21
  const { values, files } = await parseOptions({
22
22
  'output-dir': { type: 'string', default: '' },
23
- overwrite: { short: 'y', type: 'boolean', default: false },
24
- help: { short: 'h', type: 'boolean', default: false },
23
+ overwrite: { short: 'y', type: 'boolean' },
24
+ help: { short: 'h', type: 'boolean' },
25
25
  })
26
26
 
27
27
  if (values.help) {
@@ -1,6 +1,6 @@
1
1
  import { lstatSync } from 'node:fs'
2
2
  import { randomUUID } from 'node:crypto'
3
- import { unlink, rename } from 'node:fs/promises'
3
+ import { mkdir, unlink, rename } from 'node:fs/promises'
4
4
  import { dirname, extname, join } from 'node:path'
5
5
 
6
6
 
@@ -23,3 +23,13 @@ export async function overwrite(src, target) {
23
23
  await unlink(target)
24
24
  await rename(src, target)
25
25
  }
26
+
27
+ export async function mkDir(path) {
28
+ try {
29
+ await mkdir(path, { recursive: true })
30
+ }
31
+ catch (err) {
32
+ if (err.code !== 'EEXIST')
33
+ throw err
34
+ }
35
+ }
@@ -1,7 +1,7 @@
1
1
  import { join } from 'node:path'
2
2
  import { tmpdir } from 'node:os'
3
3
  import { equal, deepEqual } from 'node:assert/strict'
4
- import { mkdtemp, writeFile, rmdir } from 'node:fs/promises'
4
+ import { mkdtemp, writeFile, rm } from 'node:fs/promises'
5
5
  import { test, describe, before, after } from 'node:test'
6
6
 
7
7
  import { parseOptions } from './parseOptions.js'
@@ -18,7 +18,7 @@ describe('parseOptions', () => {
18
18
  await writeFile(inTmpDir(file), '')
19
19
  })
20
20
 
21
- after(() => rmdir(testDir, { recursive: true }))
21
+ after(() => rm(testDir, { recursive: true }))
22
22
 
23
23
  test('parses args and globs files', async () => {
24
24
  const { values, files } = await parseOptions({
@@ -0,0 +1,21 @@
1
+ import { join } from 'node:path'
2
+ import { tmpdir } from 'node:os'
3
+ import { spawnSync } from 'node:child_process'
4
+ import { createHash } from 'node:crypto'
5
+ import { mkdtempSync, readFileSync } from 'node:fs'
6
+
7
+ const rel = f => join(import.meta.dirname, f)
8
+
9
+ export function mkTempDir(prefix = 'test-') {
10
+ return mkdtempSync(join(tmpdir(), prefix))
11
+ }
12
+
13
+ export function cli(...args) {
14
+ spawnSync(rel('../cli.js'), args)
15
+ }
16
+
17
+ export function sha1(filePath) {
18
+ return createHash('sha1')
19
+ .update(readFileSync(filePath))
20
+ .digest('base64')
21
+ }
package/src/vconcat.sh CHANGED
@@ -1,22 +1,24 @@
1
- #!/bin/zsh
1
+ #!/bin/sh
2
2
 
3
- if (( $# < 2 )); then
3
+ if [ "$#" -lt 2 ]; then
4
4
  cat << EOF
5
5
  Usage:
6
- $(basename $0) vid1.mov vid2.mov [...]
7
- $(basename $0) *.mp4
6
+ $(basename "$0") vid1.mov vid2.mov [...]
7
+ $(basename "$0") *.mp4
8
8
  EOF
9
9
  exit 1
10
10
  fi
11
11
 
12
12
  list_file=$(mktemp -p .)
13
13
  for file in "$@"; do
14
- printf "file %q\n" "$file" >> "$list_file"
14
+ fname=$(printf '%s' "$file" | sed "s/'/'\\\\''/g") # Escape single quotes
15
+ printf "file '%s'\n" "$fname" >> "$list_file"
15
16
  done
16
17
 
17
18
  first_video="$1"
18
- outfile="${first_video:r}.concat.${first_video:e}"
19
+ name="${first_video%.*}"
20
+ ext="${first_video##*.}"
21
+ outfile="${name}.concat.${ext}"
19
22
 
20
23
  ffmpeg -v error -f concat -safe 0 -i "$list_file" -c copy "$outfile"
21
-
22
24
  rm "$list_file"
@@ -0,0 +1,23 @@
1
+ import { ok } from 'node:assert/strict'
2
+ import { join } from 'node:path'
3
+ import { test } from 'node:test'
4
+ import { cpSync } from 'node:fs'
5
+
6
+ import { videoAttrs } from './utils/ffmpeg.js'
7
+ import { mkTempDir, cli } from './utils/test-utils.js'
8
+
9
+ const rel = f => join(import.meta.dirname, f)
10
+
11
+ test('vconcat concatenates videos with single quotes in filenames', async () => {
12
+ const tmp = mkTempDir('vconcat')
13
+
14
+ const file1 = join(tmp, `video'1.mp4`)
15
+ const file2 = join(tmp, `video'2.mp4`)
16
+ cpSync(rel('fixtures/60fps.mp4'), file1)
17
+ cpSync(rel('fixtures/60fps.mp4'), file2)
18
+
19
+ cli('vconcat', file1, file2)
20
+
21
+ const { duration } = await videoAttrs(join(tmp, `video'1.concat.mp4`))
22
+ ok(parseFloat(duration) === 60, `Duration should be 60s, got ${duration}s`)
23
+ })
package/src/vsplit.js ADDED
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync } from 'node:fs'
4
+ import { resolve, parse, join } from 'node:path'
5
+
6
+ import { parseOptions } from './utils/parseOptions.js'
7
+ import { assertUserHasFFmpeg, run } from './utils/ffmpeg.js'
8
+
9
+
10
+ // TODO looks like it's missing a frame (perhaps becaue of -c copy)
11
+
12
+ const USAGE = `
13
+ Usage: npx mediasnacks vsplit <csv> <video>
14
+
15
+ Splits a video into multiple clips from CSV timestamps.
16
+
17
+ Arguments:
18
+ <csv> CSV file with start,end columns (in seconds)
19
+ <video> Video file to split
20
+
21
+ Example:
22
+ npx mediasnacks vsplit clips.csv video.mov
23
+
24
+ Given clips.csv:
25
+ start,end
26
+ 0,5
27
+ 5,10
28
+ 10,15
29
+
30
+ Outputs: video_1.mov, video_2.mov, video_3.mov
31
+ `.trim()
32
+
33
+
34
+ async function main() {
35
+ await assertUserHasFFmpeg()
36
+
37
+ const { values, files } = await parseOptions({
38
+ help: { short: 'h', type: 'boolean' },
39
+ })
40
+
41
+ if (values.help) {
42
+ console.log(USAGE)
43
+ process.exit(0)
44
+ }
45
+
46
+ if (files.length !== 2)
47
+ throw new Error('Expected 2 arguments: CSV file and video file. See npx mediasnacks vsplit --help')
48
+
49
+ const [csvPath, videoPath] = files.map(f => resolve(f))
50
+
51
+ const clips = parseCSV(csvPath)
52
+ if (!clips.length)
53
+ throw new Error('CSV file contains no clips')
54
+
55
+ console.log(`Splitting video into ${clips.length} clip${clips.length === 1 ? '' : 's'}…`)
56
+ await splitVideo(videoPath, clips)
57
+ }
58
+
59
+ function parseCSV(csvPath) {
60
+ const content = readFileSync(csvPath, 'utf8')
61
+ const lines = content.split('\n').filter(line => line.trim())
62
+
63
+ if (!lines.length)
64
+ throw new Error('CSV file is empty')
65
+
66
+ const clips = []
67
+ for (let i = 1; i < lines.length; i++) { // unconditionally skips header
68
+ const parts = lines[i].split(',').map(s => s.trim())
69
+
70
+ if (parts.length !== 2)
71
+ throw new Error(`Invalid CSV format at line ${i + 1}: expected 2 columns, got ${parts.length}`)
72
+
73
+ clips.push(parts)
74
+ }
75
+ return clips
76
+ }
77
+
78
+ async function splitVideo(videoPath, clips) {
79
+ const { dir, name, ext } = parse(videoPath)
80
+ const seqLen = Math.log10(clips.length) + 1 | 0
81
+
82
+ for (let i = 0; i < clips.length; i++) {
83
+ const [start, end] = clips[i]
84
+ const seq = String(i + 1).padStart(seqLen, '0')
85
+ await run('ffmpeg', [
86
+ '-v', 'error',
87
+ '-ss', start,
88
+ '-to', end,
89
+ '-i', videoPath,
90
+ '-c', 'copy',
91
+ '-y',
92
+ join(dir, `${name}_${seq}${ext}`)
93
+ ])
94
+ }
95
+ }
96
+
97
+ main().catch(err => {
98
+ console.error(err.message || err)
99
+ process.exit(1)
100
+ })
@@ -0,0 +1,32 @@
1
+ import { ok } from 'node:assert/strict'
2
+ import { join } from 'node:path'
3
+ import { describe, test } from 'node:test'
4
+ import { cpSync, readdirSync } from 'node:fs'
5
+
6
+ import { videoAttrs } from './utils/ffmpeg.js'
7
+ import { mkTempDir, cli } from './utils/test-utils.js'
8
+
9
+
10
+ const rel = f => join(import.meta.dirname, f)
11
+
12
+ describe('vsplit splits video into multiple clips from CSV', () => {
13
+ const tmp = mkTempDir('vsplit')
14
+
15
+ const csvFile = join(tmp, '60fps.csv')
16
+ const inputFile = join(tmp, '60fps.mp4')
17
+
18
+ cpSync(rel('fixtures/60fps.csv'), csvFile)
19
+ cpSync(rel('fixtures/60fps.mp4'), inputFile)
20
+ cli('vsplit', csvFile, inputFile)
21
+
22
+ test('all 6 clips were created', () => {
23
+ const files = readdirSync(tmp).filter(f => f.startsWith('60fps_'))
24
+ ok(files.length === 6, `Expected 6 clips, got ${files.length}`)
25
+ })
26
+
27
+ test('first clip has correct duration (5 seconds)', async () => {
28
+ const { duration } = await videoAttrs(join(tmp, '60fps_1.mp4'))
29
+ const EPSILON = 0.05
30
+ ok(Math.abs(parseFloat(duration) - 5) < EPSILON, `Duration should be 5s, got ${duration}s`)
31
+ })
32
+ })
package/src/vtrim.sh CHANGED
@@ -1,4 +1,4 @@
1
- #!/bin/zsh
1
+ #!/bin/sh
2
2
 
3
3
  if [ "$#" -ne 3 ]; then
4
4
  cat << EOF
@@ -27,9 +27,10 @@ DIRNAME=$(dirname "$VIDEO")
27
27
  EXT="${BASENAME##*.}"
28
28
  NAME="${BASENAME%.*}"
29
29
 
30
- outfile="$DIRNAME/${NAME}.trim.$EXT"
31
-
32
- ffmpeg -v error -y -i "$VIDEO" \
33
- -ss "$START" \
30
+ # For speed, we copy without re-encoding (with -ss before -i), but
31
+ # that means that the output isn’t going to be exact
32
+ ffmpeg -v error -y \
33
+ -ss "$START" \
34
34
  -to "$END" \
35
- -c copy "$outfile"
35
+ -i "$VIDEO" \
36
+ -c copy "$DIRNAME/${NAME}.trim.$EXT"
@@ -0,0 +1,22 @@
1
+ import { ok } from 'node:assert/strict'
2
+ import { join } from 'node:path'
3
+ import { test } from 'node:test'
4
+ import { cpSync } from 'node:fs'
5
+
6
+ import { videoAttrs } from './utils/ffmpeg.js'
7
+ import { mkTempDir, cli } from './utils/test-utils.js'
8
+
9
+ const rel = f => join(import.meta.dirname, f)
10
+
11
+
12
+ test('vtrim trims video from start to end time', async () => {
13
+ const tmp = mkTempDir('vtrim')
14
+
15
+ const inputFile = join(tmp, '60fps.mp4')
16
+ cpSync(rel('fixtures/60fps.mp4'), inputFile)
17
+ cli('vtrim', 5, 10, inputFile)
18
+
19
+ const { duration } = await videoAttrs(join(tmp, '60fps.trim.mp4'))
20
+ const EPSILON = 0.05
21
+ ok(Math.abs(parseFloat(duration) - 5) < EPSILON, `Duration should be 5s, got ${duration}s`)
22
+ })
package/src/vsplit.sh DELETED
@@ -1,50 +0,0 @@
1
- #!/bin/zsh
2
-
3
- if [ "$#" -lt 2 ]; then
4
- cat << EOF
5
- Usage:
6
- $(basename $0) <split-time-1> [split-time-2 ...] <video-file>
7
-
8
- Examples:
9
- $(basename $0) 123.45 video.mp4
10
- $(basename $0) 60 120 180 video.mov
11
- $(basename $0) 00:01:00 00:02:00 00:03:00 video.mkv
12
- EOF
13
- exit 1
14
- fi
15
-
16
- VIDEO="${@[-1]}"
17
- SPLITS=("${@[1,-2]}")
18
-
19
- if [ ! -f "$VIDEO" ]; then
20
- echo "Error: file not found: $VIDEO"
21
- exit 1
22
- fi
23
-
24
- DIRNAME=$(dirname "$VIDEO")
25
- BASENAME=$(basename "$VIDEO")
26
- EXT="${BASENAME##*.}"
27
- NAME="${BASENAME%.*}"
28
-
29
- N_CLIPS=$((${#SPLITS[@]} + 1))
30
-
31
- for (( i=1; i<=$N_CLIPS; i++ )); do
32
- outfile="$DIRNAME/${NAME}_${i}.$EXT"
33
-
34
- if [ $i -eq 1 ]; then # First clip: [start, first_split]
35
- ffmpeg -v error -y -i "$VIDEO" \
36
- -t "${SPLITS[1]}" \
37
- -c copy "$outfile"
38
-
39
- elif [ $i -eq $N_CLIPS ]; then # Last clip: [last_split, end]
40
- ffmpeg -v error -y -i "$VIDEO" \
41
- -ss "${SPLITS[-1]}" \
42
- -c copy "$outfile"
43
-
44
- else # Middle clip: [split[i-1], split[i]]
45
- ffmpeg -v error -y -i "$VIDEO" \
46
- -ss "${SPLITS[$((i-1))]}" \
47
- -to "${SPLITS[$i]}" \
48
- -c copy "$outfile"
49
- fi
50
- done
@@ -1,15 +0,0 @@
1
- import { join } from 'node:path'
2
- import { test } from 'node:test'
3
- import { equal } from 'node:assert/strict'
4
- import { tmpdir } from 'node:os'
5
- import { execSync } from 'node:child_process'
6
- import { mkdtempSync } from 'node:fs'
7
-
8
- import { sha1 } from './utils.js'
9
-
10
-
11
- test('PNG to AVIF', () => {
12
- const tmp = mkdtempSync(join(tmpdir(), 'avif-test-'))
13
- execSync(`src/cli.js avif --output-dir ${tmp} tests/fixtures/lenna.png` )
14
- equal(sha1(join(tmp, 'lenna.avif')), sha1('tests/fixtures/lenna.avif'))
15
- })
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -1,26 +0,0 @@
1
- import test from 'node:test'
2
- import { join } from 'node:path'
3
- import { tmpdir } from 'node:os'
4
- import { equal, deepEqual } from 'node:assert/strict'
5
- import { mkdtempSync, cpSync, readdirSync } from 'node:fs'
6
-
7
- import { qdir } from '../../src/qdir.js'
8
-
9
-
10
- test('jobs get renamed and failed have their exit status code', async () => {
11
- const fixtures = join(import.meta.dirname, 'jobs')
12
- const tmp = join(mkdtempSync(join(tmpdir(), 'qdir-')))
13
-
14
- cpSync(fixtures, tmp, { recursive: true })
15
-
16
- const err = await qdir(tmp, 0.2)
17
- equal(err, null)
18
-
19
- const files = readdirSync(tmp).sort()
20
- deepEqual(files, [
21
- 'job1_good.sh.done',
22
- 'job2_bad.sh.failed.1',
23
- 'job3_good.sh.done',
24
- 'job4_bad.sh.failed.1'
25
- ])
26
- })
package/tests/utils.js DELETED
@@ -1,6 +0,0 @@
1
- import { createHash } from 'node:crypto'
2
- import { readFileSync } from 'node:fs'
3
-
4
- export function sha1(filePath) {
5
- return createHash('sha1').update(readFileSync(filePath)).digest('hex')
6
- }
@@ -1,22 +0,0 @@
1
- import { join } from 'node:path'
2
- import { test } from 'node:test'
3
- import { equal } from 'node:assert/strict'
4
- import { tmpdir } from 'node:os'
5
- import { execSync } from 'node:child_process'
6
- import { mkdtempSync, cpSync } from 'node:fs'
7
- import { sha1 } from './utils.js'
8
-
9
- test('vsplit splits video at multiple time points', () => {
10
- const tmp = mkdtempSync(join(tmpdir(), 'vsplit-test-'))
11
-
12
- const inputFile = join(tmp, '60fps.mp4')
13
- cpSync('tests/fixtures/60fps.mp4', inputFile)
14
-
15
- execSync(`src/cli.js vsplit 5 10 15 20 25 ${inputFile}`)
16
-
17
- for (let i = 1; i <= 6; i++) {
18
- const generatedFile = join(tmp, `60fps_${i}.mp4`)
19
- const expectedFile = `tests/fixtures/60fps_${i}.mp4`
20
- equal(sha1(generatedFile), sha1(expectedFile), `60fps_${i}.mp4 hash should match expected`)
21
- }
22
- })
@@ -1,20 +0,0 @@
1
- import { join } from 'node:path'
2
- import { test } from 'node:test'
3
- import { equal } from 'node:assert/strict'
4
- import { tmpdir } from 'node:os'
5
- import { execSync } from 'node:child_process'
6
- import { mkdtempSync, cpSync } from 'node:fs'
7
- import { sha1 } from './utils.js'
8
-
9
- test('vtrim trims video from start to end time', () => {
10
- const tmp = mkdtempSync(join(tmpdir(), 'vtrim-test-'))
11
-
12
- const inputFile = join(tmp, '60fps.mp4')
13
- cpSync('tests/fixtures/60fps.mp4', inputFile)
14
-
15
- execSync(`src/cli.js vtrim 5 10 ${inputFile}`)
16
-
17
- const out = join(tmp, '60fps.trim.mp4')
18
- const expected = 'tests/fixtures/60fps_2.mp4'
19
- equal(sha1(out), sha1(expected), 'Trimmed video (5-10s) should match 60fps_2.mp4')
20
- })
File without changes
File without changes