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.
- package/.github/workflows/test.yml +2 -5
- package/.zsh/completions/_mediasnacks +11 -9
- package/Dockerfile +0 -1
- package/README.md +47 -25
- package/docs/macos-quick-action.png +0 -0
- package/generate-fixtures.sh +11 -0
- package/package.json +5 -2
- package/src/avif.js +2 -2
- package/src/avif.test.js +19 -0
- package/src/cli.js +17 -16
- package/src/dropdups.js +1 -1
- package/src/edgespic.js +77 -0
- package/src/edgespic.test.js +32 -0
- package/src/fixtures/60fps.csv +7 -0
- package/src/fixtures/edgespic/60fps_first.png +0 -0
- package/src/fixtures/edgespic/60fps_last.png +0 -0
- package/src/qdir.js +3 -6
- package/src/qdir.test.js +24 -0
- package/src/resize.js +2 -2
- package/src/seqcheck.js +2 -2
- package/{tests → src}/seqcheck.test.js +1 -1
- package/src/sqcrop.js +2 -2
- package/src/utils/fs-utils.js +11 -1
- package/src/utils/test-utils.js +21 -0
- package/src/vconcat.sh +2 -4
- package/{tests → src}/vconcat.test.js +11 -9
- package/src/vsplit.js +100 -0
- package/src/vsplit.test.js +32 -0
- package/src/vtrim.sh +2 -5
- package/{tests → src}/vtrim.test.js +11 -9
- package/.dockerignore +0 -5
- package/Makefile +0 -5
- package/docker-shell.sh +0 -24
- package/tests/avif.test.js +0 -20
- package/tests/qdir/qdir.test.js +0 -26
- package/tests/utils.js +0 -6
- /package/{tests → src}/fixtures/60fps.mp4 +0 -0
- /package/{tests → src}/fixtures/lenna.avif +0 -0
- /package/{tests → src}/fixtures/lenna.png +0 -0
- /package/{tests/qdir/jobs → src/fixtures/qdir-jobs}/job1_good.sh +0 -0
- /package/{tests/qdir/jobs → src/fixtures/qdir-jobs}/job2_bad.sh +0 -0
- /package/{tests/qdir/jobs → src/fixtures/qdir-jobs}/job3_good.sh +0 -0
- /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:
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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
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
|
-
##
|
|
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
|
-
|
|
16
|
+
|
|
18
17
|
- `dropdups` Removes duplicate frames in a video
|
|
19
|
-
- `
|
|
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
|
-
- `
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## Adding a macOS Quick Action
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+

|
|
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.
|
|
4
|
-
"description": "Utilities for preparing videos
|
|
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'
|
|
23
|
-
help: { short: 'h', type: 'boolean'
|
|
22
|
+
overwrite: { short: 'y', type: 'boolean' },
|
|
23
|
+
help: { short: 'h', type: 'boolean' },
|
|
24
24
|
})
|
|
25
25
|
|
|
26
26
|
if (values.help) {
|
package/src/avif.test.js
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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:
|
|
38
|
+
Usage: mediasnacks <command> <args>
|
|
38
39
|
|
|
39
40
|
Commands:
|
|
40
41
|
${Object.entries(COMMANDS).map(([cmd, [, title]]) =>
|
|
41
|
-
`
|
|
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'
|
|
39
|
+
help: { short: 'h', type: 'boolean' },
|
|
40
40
|
})
|
|
41
41
|
|
|
42
42
|
if (values.help) {
|
package/src/edgespic.js
ADDED
|
@@ -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
|
+
})
|
|
Binary file
|
|
Binary file
|
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'
|
|
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 (
|
|
95
|
+
if (import.meta.main)
|
|
99
96
|
main().catch(err => {
|
|
100
97
|
console.error(err.message || err)
|
|
101
98
|
process.exit(1)
|
package/src/qdir.test.js
ADDED
|
@@ -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'
|
|
38
|
-
help: { short: 'h', type: 'boolean'
|
|
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'
|
|
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 (
|
|
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 '
|
|
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'
|
|
24
|
-
help: { short: 'h', type: 'boolean'
|
|
23
|
+
overwrite: { short: 'y', type: 'boolean' },
|
|
24
|
+
help: { short: 'h', type: 'boolean' },
|
|
25
25
|
})
|
|
26
26
|
|
|
27
27
|
if (values.help) {
|
package/src/utils/fs-utils.js
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
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 {
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
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 =
|
|
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('
|
|
15
|
-
cpSync('
|
|
16
|
+
cpSync(rel('fixtures/60fps.mp4'), file1)
|
|
17
|
+
cpSync(rel('fixtures/60fps.mp4'), file2)
|
|
16
18
|
|
|
17
|
-
|
|
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
|
-
|
|
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 {
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
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 =
|
|
13
|
+
const tmp = mkTempDir('vtrim')
|
|
11
14
|
|
|
12
15
|
const inputFile = join(tmp, '60fps.mp4')
|
|
13
|
-
cpSync('
|
|
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
package/Makefile
DELETED
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
|
package/tests/avif.test.js
DELETED
|
@@ -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
|
-
})
|
package/tests/qdir/qdir.test.js
DELETED
|
@@ -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
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|