mediasnacks 0.15.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 (43) hide show
  1. package/.github/workflows/test.yml +2 -5
  2. package/.zsh/completions/_mediasnacks +11 -9
  3. package/Dockerfile +0 -1
  4. package/README.md +47 -25
  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 +17 -16
  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,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
- 'flattendir:Moves unique files to the top dir and deletes empty dirs'
23
25
  )
24
26
 
25
27
  if (( CURRENT == 2 )); then
@@ -29,7 +31,7 @@ fi
29
31
 
30
32
  local cmd="$words[2]"
31
33
  case "$cmd" in
32
- avif|resize|sqcrop|moov2front|dropdups|seqcheck|hev1tohvc1|framediff|vdiff|vconcat|vtrim|dlaudio|dlvideo|unemoji|rmcover|curltime|gif|flattendir)
34
+ avif|resize|sqcrop|moov2front|dropdups|edgespic|seqcheck|hev1tohvc1|framediff|vdiff|vconcat|vsplit|vtrim|dlaudio|dlvideo|unemoji|rmcover|curltime|gif|flattendir)
33
35
  _files
34
36
  ;;
35
37
  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,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,30 +37,26 @@ Commands:
32
37
  - `rmcover`: Removes cover art
33
38
 
34
39
  - `curltime`: Measures request response timings
35
- - `gif`: Video to GIF
36
- - `flattendir`: Moves unique files to the top dir and deletes empty dirs
37
40
 
38
- ### Glob Patterns and Literal Filenames
39
-
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.
42
-
43
- To treat arguments as literal filenames instead of
44
- glob patterns, use the `--` (double dash) separator:
41
+ ### Globs
42
+ Glob patterns are expanded by Node.js to match existing files.
45
43
 
46
44
  ```shell
47
- # Expands to: file2.png, file3.png, file4.png
48
45
  npx mediasnacks avif file[234].png
46
+ # Expands to: file2.png, file3.png, file4.png
47
+ ```
49
48
 
50
- # Literal filename: "file[234].png"
49
+ ```shell
51
50
  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
51
+ # Literal filename: "file[234].png"
55
52
  ```
56
53
 
57
54
  <br/>
58
55
 
56
+ ---
57
+
58
+ ## Commands
59
+
59
60
  ### Converting Images to AVIF
60
61
  ```shell
61
62
  npx mediasnacks avif [-y | --overwrite] [--output-dir=<dir>] <images>
@@ -101,5 +102,26 @@ What is Fast Start?
101
102
 
102
103
  <br/>
103
104
 
104
- ### License
105
- 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,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.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",
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
@@ -7,38 +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
- flattendir: ['flattendir.sh', 'Moves all files to top dir and deletes dirs']
34
35
  }
35
36
 
36
37
  const USAGE = `
37
- Usage: npx mediasnacks <command> <args>
38
+ Usage: mediasnacks <command> <args>
38
39
 
39
40
  Commands:
40
41
  ${Object.entries(COMMANDS).map(([cmd, [, title]]) =>
41
- ` ${cmd}\t${title}`).join('\n')}
42
+ ` ${cmd.padEnd(12, ' ')}\t${title}`).join('\n')}
42
43
  `.trim()
43
44
 
44
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
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