mediasnacks 0.15.0 → 0.16.2

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 (43) hide show
  1. package/.github/workflows/test.yml +2 -5
  2. package/.zsh/completions/_mediasnacks +17 -9
  3. package/Dockerfile +0 -1
  4. package/README.md +58 -31
  5. package/docs/macos-quick-action.png +0 -0
  6. package/generate-fixtures.sh +11 -0
  7. package/package.json +5 -2
  8. package/src/avif.js +2 -2
  9. package/src/avif.test.js +19 -0
  10. package/src/cli.js +18 -15
  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/qdir.js +3 -6
  18. package/src/qdir.test.js +24 -0
  19. package/src/resize.js +2 -2
  20. package/src/seqcheck.js +2 -2
  21. package/{tests → src}/seqcheck.test.js +1 -1
  22. package/src/sqcrop.js +2 -2
  23. package/src/utils/fs-utils.js +11 -1
  24. package/src/utils/test-utils.js +21 -0
  25. package/src/vconcat.sh +2 -4
  26. package/{tests → src}/vconcat.test.js +11 -9
  27. package/src/vsplit.js +100 -0
  28. package/src/vsplit.test.js +32 -0
  29. package/src/vtrim.sh +2 -5
  30. package/{tests → src}/vtrim.test.js +11 -9
  31. package/.dockerignore +0 -5
  32. package/Makefile +0 -5
  33. package/docker-shell.sh +0 -24
  34. package/tests/avif.test.js +0 -20
  35. package/tests/qdir/qdir.test.js +0 -26
  36. package/tests/utils.js +0 -6
  37. /package/{tests → src}/fixtures/60fps.mp4 +0 -0
  38. /package/{tests → src}/fixtures/lenna.avif +0 -0
  39. /package/{tests → src}/fixtures/lenna.png +0 -0
  40. /package/{tests/qdir/jobs → src/fixtures/qdir-jobs}/job1_good.sh +0 -0
  41. /package/{tests/qdir/jobs → src/fixtures/qdir-jobs}/job2_bad.sh +0 -0
  42. /package/{tests/qdir/jobs → src/fixtures/qdir-jobs}/job3_good.sh +0 -0
  43. /package/{tests/qdir/jobs → src/fixtures/qdir-jobs}/job4_bad.sh +0 -0
@@ -12,11 +12,8 @@ jobs:
12
12
 
13
13
  steps:
14
14
  - uses: actions/checkout@v6
15
-
16
15
  - uses: docker/setup-buildx-action@v4
17
-
18
- - name: Build image
19
- uses: docker/build-push-action@v7
16
+ - uses: docker/build-push-action@v7
20
17
  with:
21
18
  context: .
22
19
  load: true
@@ -24,5 +21,5 @@ jobs:
24
21
  cache-from: type=gha
25
22
  cache-to: type=gha,mode=max
26
23
 
27
- - name: Run tests
24
+ - name: Tests
28
25
  run: docker run --rm app-test
@@ -2,24 +2,32 @@
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'
6
+
7
+ 'resize:Resizes videos or images'
8
+ 'edgespic:Extracts first and last frames'
9
+ 'gif:Video to GIF'
10
+
8
11
  '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'
12
12
  'framediff:ffplay with a filter for diffing adjacent frames'
13
- 'vdiff:Plays a video with the difference of two videos'
13
+ 'hev1tohvc1:Fixes video thumbnails not rendering in macOS Finder'
14
+ 'moov2front:Rearranges metadata for fast-start streaming'
14
15
  'vconcat:Concatenates videos'
16
+ 'vdiff:Plays a video with the difference of two videos'
17
+ 'vsplit:Splits a video into multiple clips from CSV timestamps'
15
18
  'vtrim:Trims video from start to end time'
19
+
20
+ 'flattendir:Moves unique files to the top dir and deletes empty dirs'
21
+ 'seqcheck:Finds missing sequence number'
22
+ 'qdir:Sequentially runs all *.sh files in a folder'
23
+
16
24
  'dlaudio: yt-dlp best audio'
17
25
  'dlvideo: yt-dlp best video'
26
+
18
27
  'unemoji:Removes emojis from filenames'
19
28
  'rmcover:Removes cover art'
29
+
20
30
  'curltime:Measures request response timings'
21
- 'gif:Video to GIF'
22
- 'flattendir:Moves unique files to the top dir and deletes empty dirs'
23
31
  )
24
32
 
25
33
  if (( CURRENT == 2 )); then
@@ -29,7 +37,7 @@ fi
29
37
 
30
38
  local cmd="$words[2]"
31
39
  case "$cmd" in
32
- avif|resize|sqcrop|moov2front|dropdups|seqcheck|hev1tohvc1|framediff|vdiff|vconcat|vtrim|dlaudio|dlvideo|unemoji|rmcover|curltime|gif|flattendir)
40
+ avif|resize|sqcrop|moov2front|dropdups|edgespic|seqcheck|hev1tohvc1|framediff|vdiff|vconcat|vsplit|vtrim|dlaudio|dlvideo|unemoji|rmcover|curltime|gif|flattendir)
33
41
  _files
34
42
  ;;
35
43
  qdir)
package/Dockerfile CHANGED
@@ -8,7 +8,6 @@ ENV FORCE_COLOR=1
8
8
  WORKDIR /workspace
9
9
 
10
10
  COPY src/ src/
11
- COPY tests/ tests/
12
11
  COPY package.json .
13
12
 
14
13
  CMD ["node", "--test"]
package/README.md CHANGED
@@ -3,62 +3,69 @@
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
18
- - `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
16
 
17
+
18
+ - `resize` Resizes videos or images
19
+ - `edgespic` Extracts first and last frames
20
+ - `gif`: Video to GIF
21
+
22
+
23
+ - `dropdups` Removes duplicate frames in a video
23
24
  - `framediff`: Plays a video of adjacent frames diff
24
- - `vdiff`: Plays a video with the difference of two videos
25
+ - `hev1tohvc1`: Fixes video thumbnails not rendering in macOS Finder
26
+ - `moov2front` Rearranges .mov and .mp4 metadata for fast-start streaming
25
27
  - `vconcat`: Concatenates videos
28
+ - `vdiff`: Plays a video with the difference of two videos
29
+ - `vsplit`: Splits a video into multiple clips from CSV timestamps
26
30
  - `vtrim`: Trims video from start to end time
27
31
 
32
+
33
+ - `flattendir`: Moves unique files to the top dir and deletes empty dirs
34
+ - `qdir` Sequentially runs all *.sh files in a folder
35
+ - `seqcheck` Finds missing sequence number
36
+
37
+
28
38
  - `dlaudio`: yt-dlp best audio
29
39
  - `dlvideo`: yt-dlp best video
30
40
 
41
+
31
42
  - `unemoji`: Removes emojis from filenames
32
43
  - `rmcover`: Removes cover art
33
44
 
34
- - `curltime`: Measures request response timings
35
- - `gif`: Video to GIF
36
- - `flattendir`: Moves unique files to the top dir and deletes empty dirs
37
-
38
- ### Glob Patterns and Literal Filenames
39
45
 
40
- Most commands accept glob patterns (like `*.png` or `file[234].png`) to match multiple
41
- files. By default, these patterns are expanded by Node.js to match existing files.
46
+ - `curltime`: Measures request response timings
42
47
 
43
- To treat arguments as literal filenames instead of
44
- glob patterns, use the `--` (double dash) separator:
48
+ ### Globs
49
+ Glob patterns are expanded by Node.js.
45
50
 
46
51
  ```shell
52
+ mediasnacks avif file[234].png
47
53
  # Expands to: file2.png, file3.png, file4.png
48
- npx mediasnacks avif file[234].png
54
+ ```
49
55
 
56
+ ```shell
57
+ mediasnacks avif -- file[234].png
50
58
  # Literal filename: "file[234].png"
51
- npx mediasnacks avif -- file[234].png
52
-
53
- # Mixed: expand first pattern, treat second as literal
54
- npx mediasnacks avif file2.png -- file[234].png
55
59
  ```
56
60
 
57
- <br/>
61
+
62
+ ---
63
+
64
+ ## Commands
58
65
 
59
66
  ### Converting Images to AVIF
60
67
  ```shell
61
- npx mediasnacks avif [-y | --overwrite] [--output-dir=<dir>] <images>
68
+ mediasnacks avif [-y | --overwrite] [--output-dir=<dir>] <images>
62
69
  ```
63
70
 
64
71
  <br/>
@@ -71,17 +78,17 @@ Resizes videos and images. The aspect ratio is preserved when only one dimension
71
78
  - `-2` same as `-1` but rounds to the nearest even number
72
79
 
73
80
  ```shell
74
- npx mediasnacks resize [--width=<num>] [--height=<num>] [-y | --overwrite] [--output-dir=<dir>] <files>
81
+ mediasnacks resize [--width=<num>] [--height=<num>] [-y | --overwrite] [--output-dir=<dir>] <files>
75
82
  ```
76
83
 
77
84
  Example: Overwrites the input file (-y)
78
85
  ```shell
79
- npx mediasnacks resize -y --width 480 'dir-a/**/*.png' 'dir-b/**/*.mp4'
86
+ mediasnacks resize -y --width 480 'dir-a/**/*.png' 'dir-b/**/*.mp4'
80
87
  ```
81
88
 
82
89
  Example: Output directory (-o)
83
90
  ```shell
84
- npx mediasnacks resize --height 240 --output-dir /tmp/out video.mov
91
+ mediasnacks resize --height 240 --output-dir /tmp/out video.mov
85
92
  ```
86
93
 
87
94
  <br/>
@@ -92,7 +99,7 @@ Rearranges .mov and .mp4 metadata to the start of the file for fast-start stream
92
99
  **Files are overwritten**
93
100
 
94
101
  ```shell
95
- npx mediasnacks moov2front <videos>
102
+ mediasnacks moov2front <videos>
96
103
  ```
97
104
  What is Fast Start?
98
105
  - https://wiki.avblocks.com/avblocks-for-cpp/muxer-parameters/mp4
@@ -101,5 +108,25 @@ What is Fast Start?
101
108
 
102
109
  <br/>
103
110
 
104
- ### License
105
- MIT
111
+ ---
112
+
113
+ ## Adding a macOS Quick Action
114
+
115
+
116
+ ![](./docs/macos-quick-action.png)
117
+
118
+ For example, for `dropdups -n2 file.mov`
119
+
120
+ - Open Automator
121
+ - Select: Quick Action
122
+ - Workflow receives current: `movie files` in `Finder.app`
123
+ - Action: `Run Shell Script`
124
+ ```shell
125
+ export PATH="/opt/homebrew/bin"
126
+ for f in "$@"; do
127
+ $HOME/bin/mediasnacks dropdups -n2 "$f"
128
+ done
129
+ ```
130
+
131
+ It will be saved to `~/Library/Services`
132
+
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,11 +1,14 @@
1
1
  {
2
2
  "name": "mediasnacks",
3
- "version": "0.15.0",
4
- "description": "Utilities for preparing videos, images, and audio for the web",
3
+ "version": "0.16.2",
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",
8
8
  "bin": {
9
9
  "mediasnacks": "src/cli.js"
10
+ },
11
+ "scripts": {
12
+ "test": "docker run --rm $(docker build -q .)"
10
13
  }
11
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
@@ -1,44 +1,47 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { join } from 'node:path'
4
+ import { styleText } from 'node:util'
4
5
  import { spawn } from 'node:child_process'
5
6
  import pkgJSON from '../package.json' with { type: 'json' }
6
7
 
7
8
 
8
9
  const COMMANDS = {
9
10
  avif: ['avif.js', 'Converts images to AVIF'],
11
+ sqcrop: ['sqcrop.js', 'Square crops images\n'],
12
+
10
13
  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'],
14
+ edgespic: ['edgespic.js', 'Extracts first and last frames'],
15
+ gif: ['gif.sh', 'Video to GIF\n'],
13
16
 
14
17
  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
-
19
18
  framediff: ['framediff.sh', 'Plays a video of adjacent frames diff'],
19
+ hev1tohvc1: ['hev1tohvc1.js', 'Fixes video thumbnails not rendering in macOS Finder '],
20
+ moov2front: ['moov2front.js', 'Rearranges .mov and .mp4 metadata for fast-start streaming'],
21
+ vconcat: ['vconcat.sh', 'Concatenates videos'],
20
22
  vdiff: ['vdiff.sh', 'Plays a video with the difference of two videos'],
23
+ vsplit: ['vsplit.js', 'Splits a video into multiple clips from CSV timestamps'],
24
+ vtrim: ['vtrim.sh', 'Trims video from start to end time\n'],
21
25
 
22
- vconcat: ['vconcat.sh', 'Concatenates videos'],
23
- vtrim: ['vtrim.sh', 'Trims video from start to end time'],
26
+ flattendir: ['flattendir.sh', 'Moves all files to top dir and deletes dirs'],
27
+ qdir: ['qdir.js', 'Sequentially runs all *.sh files in a folder'],
28
+ seqcheck: ['seqcheck.js', 'Finds missing sequence number\n'],
24
29
 
25
30
  dlaudio: ['dlaudio.sh', 'yt-dlp best audio'],
26
- dlvideo: ['dlvideo.sh', 'yt-dlp best video'],
31
+ dlvideo: ['dlvideo.sh', 'yt-dlp best video\n'],
27
32
 
28
33
  unemoji: ['unemoji.sh', 'Removes emojis from filenames'],
29
- rmcover: ['rmcover.sh', 'Removes cover art'],
30
-
34
+ rmcover: ['rmcover.sh', 'Removes cover art\n'],
35
+
31
36
  curltime: ['curltime.sh', 'Measures request response timings'],
32
- gif: ['gif.sh', 'Video to GIF'],
33
- flattendir: ['flattendir.sh', 'Moves all files to top dir and deletes dirs']
34
37
  }
35
38
 
36
39
  const USAGE = `
37
- Usage: npx mediasnacks <command> <args>
40
+ Usage: mediasnacks <command> <args>
38
41
 
39
42
  Commands:
40
43
  ${Object.entries(COMMANDS).map(([cmd, [, title]]) =>
41
- ` ${cmd}\t${title}`).join('\n')}
44
+ ` ${styleText('bold', cmd.padEnd(12, ' '))}\t${title}`).join('\n')}
42
45
  `.trim()
43
46
 
44
47
 
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
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
@@ -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
+ }
@@ -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
@@ -11,9 +11,8 @@ fi
11
11
 
12
12
  list_file=$(mktemp -p .)
13
13
  for file in "$@"; do
14
- # Escape single quotes by replacing ' with '\''
15
- escaped=$(printf '%s\n' "$file" | sed "s/'/'\\\\''/g")
16
- printf "file '%s'\n" "$escaped" >> "$list_file"
14
+ fname=$(printf '%s' "$file" | sed "s/'/'\\\\''/g") # Escape single quotes
15
+ printf "file '%s'\n" "$fname" >> "$list_file"
17
16
  done
18
17
 
19
18
  first_video="$1"
@@ -22,5 +21,4 @@ ext="${first_video##*.}"
22
21
  outfile="${name}.concat.${ext}"
23
22
 
24
23
  ffmpeg -v error -f concat -safe 0 -i "$list_file" -c copy "$outfile"
25
-
26
24
  rm "$list_file"
@@ -1,20 +1,22 @@
1
+ import { ok } from 'node:assert/strict'
1
2
  import { join } from 'node:path'
2
3
  import { test } from 'node:test'
3
- import { ok } 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 { videoAttrs } from '../src/utils/ffmpeg.js'
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)
8
10
 
9
11
  test('vconcat concatenates videos with single quotes in filenames', async () => {
10
- const tmp = mkdtempSync(join(tmpdir(), 'vconcat-test-'))
12
+ const tmp = mkTempDir('vconcat')
11
13
 
12
14
  const file1 = join(tmp, `video'1.mp4`)
13
15
  const file2 = join(tmp, `video'2.mp4`)
14
- cpSync('tests/fixtures/60fps.mp4', file1)
15
- cpSync('tests/fixtures/60fps.mp4', file2)
16
+ cpSync(rel('fixtures/60fps.mp4'), file1)
17
+ cpSync(rel('fixtures/60fps.mp4'), file2)
16
18
 
17
- execSync(`src/cli.js vconcat "${file1}" "${file2}"`)
19
+ cli('vconcat', file1, file2)
18
20
 
19
21
  const { duration } = await videoAttrs(join(tmp, `video'1.concat.mp4`))
20
22
  ok(parseFloat(duration) === 60, `Duration should be 60s, got ${duration}s`)
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
@@ -27,13 +27,10 @@ DIRNAME=$(dirname "$VIDEO")
27
27
  EXT="${BASENAME##*.}"
28
28
  NAME="${BASENAME%.*}"
29
29
 
30
- echo "start $START, end $END"
31
-
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
32
  ffmpeg -v error -y \
33
33
  -ss "$START" \
34
34
  -to "$END" \
35
35
  -i "$VIDEO" \
36
36
  -c copy "$DIRNAME/${NAME}.trim.$EXT"
37
-
38
- # For speed, we copy without re-encoding (with -ss before -i), but
39
- # that means that the output isn’t going to be exact
@@ -1,18 +1,20 @@
1
+ import { ok } from 'node:assert/strict'
1
2
  import { join } from 'node:path'
2
3
  import { test } from 'node:test'
3
- import { ok } 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 { videoAttrs } from '../src/utils/ffmpeg.js'
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
+
8
11
 
9
12
  test('vtrim trims video from start to end time', async () => {
10
- const tmp = mkdtempSync(join(tmpdir(), 'vtrim-test-'))
13
+ const tmp = mkTempDir('vtrim')
11
14
 
12
15
  const inputFile = join(tmp, '60fps.mp4')
13
- cpSync('tests/fixtures/60fps.mp4', inputFile)
14
-
15
- execSync(`src/cli.js vtrim 5 10 ${inputFile}`)
16
+ cpSync(rel('fixtures/60fps.mp4'), inputFile)
17
+ cli('vtrim', 5, 10, inputFile)
16
18
 
17
19
  const { duration } = await videoAttrs(join(tmp, '60fps.trim.mp4'))
18
20
  const EPSILON = 0.05
package/.dockerignore DELETED
@@ -1,5 +0,0 @@
1
- .git
2
- .gitignore
3
- .github
4
- .DS_Store
5
- *.log
package/Makefile DELETED
@@ -1,5 +0,0 @@
1
- .PHONY: *
2
-
3
- test:
4
- @docker run --rm $$(docker build -q .)
5
-
package/docker-shell.sh DELETED
@@ -1,24 +0,0 @@
1
- #!/bin/bash
2
- # Opens a shell in the Docker test environment with fixtures mounted
3
-
4
- set -e
5
-
6
- echo "Building Docker test image..."
7
- docker build -t mediasnacks-test .
8
-
9
- echo ""
10
- echo "Opening shell in Docker container..."
11
- echo "Fixtures directory is mounted at /workspace/tests/fixtures"
12
- echo ""
13
- echo "To generate fixtures:"
14
- echo " cd /tmp"
15
- echo " cp /workspace/tests/fixtures/lenna.png ."
16
- echo " /workspace/src/cli.js avif lenna.png"
17
- echo " sha1sum lenna.avif"
18
- echo " cp lenna.avif /workspace/tests/fixtures/"
19
- echo ""
20
-
21
- docker run --rm -it \
22
- -v "$(pwd)/tests/fixtures:/workspace/tests/fixtures" \
23
- mediasnacks-test \
24
- /bin/bash
@@ -1,20 +0,0 @@
1
- import { join } from 'node:path'
2
- import { test } from 'node:test'
3
- import { tmpdir } from 'node:os'
4
- import { execSync } from 'node:child_process'
5
- import { deepEqual } from 'node:assert/strict'
6
- import { mkdtempSync } from 'node:fs'
7
-
8
- import { videoAttrs } from '../src/utils/ffmpeg.js'
9
-
10
-
11
- test('PNG to AVIF', async () => {
12
- const tmp = mkdtempSync(join(tmpdir(), 'avif-test-'))
13
- execSync(`src/cli.js avif --output-dir ${tmp} ${join(import.meta.dirname, 'fixtures/lenna.png')}`)
14
-
15
- deepEqual(
16
- await videoAttrs(join(tmp, 'lenna.avif')),
17
- await videoAttrs(join(import.meta.dirname, 'fixtures/lenna.avif')))
18
- // That's because we use non-deterministic avif.
19
- // Claude says: avif is deterministic only when it's single-threaded: '-threads 1'
20
- })
@@ -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
- }
File without changes
File without changes
File without changes