mediasnacks 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +5 -0
- package/.github/workflows/test.yml +28 -0
- package/.zsh/completions/_mediasnacks +1 -2
- package/Dockerfile +14 -0
- package/Makefile +5 -0
- package/README.md +0 -2
- package/docker-shell.sh +24 -0
- package/package.json +1 -4
- package/src/cli.js +0 -1
- package/src/gif.sh +5 -7
- package/src/sqcrop.js +1 -1
- package/src/utils/parseOptions.test.js +2 -2
- package/src/vconcat.sh +10 -6
- package/src/vtrim.sh +9 -5
- package/tests/avif.test.js +10 -5
- package/tests/fixtures/lenna.avif +0 -0
- package/tests/vconcat.test.js +21 -0
- package/tests/vtrim.test.js +6 -6
- package/src/vsplit.sh +0 -50
- package/tests/fixtures/60fps_1.mp4 +0 -0
- package/tests/fixtures/60fps_2.mp4 +0 -0
- package/tests/fixtures/60fps_3.mp4 +0 -0
- package/tests/fixtures/60fps_4.mp4 +0 -0
- package/tests/fixtures/60fps_5.mp4 +0 -0
- package/tests/fixtures/60fps_6.mp4 +0 -0
- package/tests/vsplit.test.js +0 -22
package/.dockerignore
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: Test
|
|
2
|
+
|
|
3
|
+
on: [ push, pull_request, workflow_dispatch ]
|
|
4
|
+
|
|
5
|
+
permissions:
|
|
6
|
+
contents: read
|
|
7
|
+
pull-requests: write
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v6
|
|
15
|
+
|
|
16
|
+
- uses: docker/setup-buildx-action@v4
|
|
17
|
+
|
|
18
|
+
- name: Build image
|
|
19
|
+
uses: docker/build-push-action@v7
|
|
20
|
+
with:
|
|
21
|
+
context: .
|
|
22
|
+
load: true
|
|
23
|
+
tags: app-test
|
|
24
|
+
cache-from: type=gha
|
|
25
|
+
cache-to: type=gha,mode=max
|
|
26
|
+
|
|
27
|
+
- name: Run tests
|
|
28
|
+
run: docker run --rm app-test
|
|
@@ -19,7 +19,6 @@ _mediasnacks_commands=(
|
|
|
19
19
|
'rmcover:Removes cover art'
|
|
20
20
|
'curltime:Measures request response timings'
|
|
21
21
|
'gif:Video to GIF'
|
|
22
|
-
'vsplit:Split video at multiple time points'
|
|
23
22
|
'flattendir:Moves unique files to the top dir and deletes empty dirs'
|
|
24
23
|
)
|
|
25
24
|
|
|
@@ -30,7 +29,7 @@ fi
|
|
|
30
29
|
|
|
31
30
|
local cmd="$words[2]"
|
|
32
31
|
case "$cmd" in
|
|
33
|
-
avif|resize|sqcrop|moov2front|dropdups|seqcheck|hev1tohvc1|framediff|vdiff|vconcat|vtrim|dlaudio|dlvideo|unemoji|rmcover|curltime|gif|
|
|
32
|
+
avif|resize|sqcrop|moov2front|dropdups|seqcheck|hev1tohvc1|framediff|vdiff|vconcat|vtrim|dlaudio|dlvideo|unemoji|rmcover|curltime|gif|flattendir)
|
|
34
33
|
_files
|
|
35
34
|
;;
|
|
36
35
|
qdir)
|
package/Dockerfile
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
FROM mwader/static-ffmpeg:8.0.1 AS ffmpeg
|
|
2
|
+
FROM node:24-bookworm-slim
|
|
3
|
+
|
|
4
|
+
COPY --from=ffmpeg /ffmpeg /usr/local/bin/ffmpeg
|
|
5
|
+
COPY --from=ffmpeg /ffprobe /usr/local/bin/ffprobe
|
|
6
|
+
|
|
7
|
+
ENV FORCE_COLOR=1
|
|
8
|
+
WORKDIR /workspace
|
|
9
|
+
|
|
10
|
+
COPY src/ src/
|
|
11
|
+
COPY tests/ tests/
|
|
12
|
+
COPY package.json .
|
|
13
|
+
|
|
14
|
+
CMD ["node", "--test"]
|
package/Makefile
ADDED
package/README.md
CHANGED
|
@@ -33,8 +33,6 @@ Commands:
|
|
|
33
33
|
|
|
34
34
|
- `curltime`: Measures request response timings
|
|
35
35
|
- `gif`: Video to GIF
|
|
36
|
-
- `vsplit`: Split video at multiple time points
|
|
37
|
-
|
|
38
36
|
- `flattendir`: Moves unique files to the top dir and deletes empty dirs
|
|
39
37
|
|
|
40
38
|
### Glob Patterns and Literal Filenames
|
package/docker-shell.sh
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
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/package.json
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mediasnacks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Utilities for preparing videos, images, and audio 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": "node --test"
|
|
13
10
|
}
|
|
14
11
|
}
|
package/src/cli.js
CHANGED
|
@@ -30,7 +30,6 @@ const COMMANDS = {
|
|
|
30
30
|
|
|
31
31
|
curltime: ['curltime.sh', 'Measures request response timings'],
|
|
32
32
|
gif: ['gif.sh', 'Video to GIF'],
|
|
33
|
-
vsplit: ['vsplit.sh', 'Split video at multiple time points'],
|
|
34
33
|
flattendir: ['flattendir.sh', 'Moves all files to top dir and deletes dirs']
|
|
35
34
|
}
|
|
36
35
|
|
package/src/gif.sh
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
#!/bin/
|
|
1
|
+
#!/bin/sh
|
|
2
2
|
|
|
3
|
-
#
|
|
3
|
+
# Converts to GIF
|
|
4
4
|
|
|
5
5
|
FPS=10
|
|
6
6
|
WIDTH=600
|
|
@@ -10,7 +10,7 @@ usage() {
|
|
|
10
10
|
exit 1
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
while [
|
|
13
|
+
while [ $# -gt 0 ]; do
|
|
14
14
|
case "$1" in
|
|
15
15
|
--fps)
|
|
16
16
|
FPS="$2";
|
|
@@ -26,10 +26,8 @@ while [[ $# -gt 0 ]]; do
|
|
|
26
26
|
esac
|
|
27
27
|
done
|
|
28
28
|
|
|
29
|
-
[
|
|
30
|
-
|
|
31
|
-
outfile="${file:r}.gif"
|
|
29
|
+
[ -z "$file" ] && usage
|
|
32
30
|
|
|
33
31
|
ffmpeg -v error -i "$file" \
|
|
34
32
|
-vf "fps=${FPS},scale=${WIDTH}:-1" \
|
|
35
|
-
"$
|
|
33
|
+
"${file%.*}.gif"
|
package/src/sqcrop.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { join } from 'node:path'
|
|
4
|
-
|
|
5
4
|
import { rename } from 'node:fs/promises'
|
|
5
|
+
|
|
6
6
|
import { ffmpeg, assertUserHasFFmpeg } from './utils/ffmpeg.js'
|
|
7
7
|
import { lstat, uniqueFilenameFor } from './utils/fs-utils.js'
|
|
8
8
|
import { parseOptions } from './utils/parseOptions.js'
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
3
|
import { equal, deepEqual } from 'node:assert/strict'
|
|
4
|
-
import { mkdtemp, writeFile,
|
|
4
|
+
import { mkdtemp, writeFile, rm } from 'node:fs/promises'
|
|
5
5
|
import { test, describe, before, after } from 'node:test'
|
|
6
6
|
|
|
7
7
|
import { parseOptions } from './parseOptions.js'
|
|
@@ -18,7 +18,7 @@ describe('parseOptions', () => {
|
|
|
18
18
|
await writeFile(inTmpDir(file), '')
|
|
19
19
|
})
|
|
20
20
|
|
|
21
|
-
after(() =>
|
|
21
|
+
after(() => rm(testDir, { recursive: true }))
|
|
22
22
|
|
|
23
23
|
test('parses args and globs files', async () => {
|
|
24
24
|
const { values, files } = await parseOptions({
|
package/src/vconcat.sh
CHANGED
|
@@ -1,21 +1,25 @@
|
|
|
1
|
-
#!/bin/
|
|
1
|
+
#!/bin/sh
|
|
2
2
|
|
|
3
|
-
if
|
|
3
|
+
if [ "$#" -lt 2 ]; then
|
|
4
4
|
cat << EOF
|
|
5
5
|
Usage:
|
|
6
|
-
$(basename $0) vid1.mov vid2.mov [...]
|
|
7
|
-
$(basename $0) *.mp4
|
|
6
|
+
$(basename "$0") vid1.mov vid2.mov [...]
|
|
7
|
+
$(basename "$0") *.mp4
|
|
8
8
|
EOF
|
|
9
9
|
exit 1
|
|
10
10
|
fi
|
|
11
11
|
|
|
12
12
|
list_file=$(mktemp -p .)
|
|
13
13
|
for file in "$@"; do
|
|
14
|
-
|
|
14
|
+
# Escape single quotes by replacing ' with '\''
|
|
15
|
+
escaped=$(printf '%s\n' "$file" | sed "s/'/'\\\\''/g")
|
|
16
|
+
printf "file '%s'\n" "$escaped" >> "$list_file"
|
|
15
17
|
done
|
|
16
18
|
|
|
17
19
|
first_video="$1"
|
|
18
|
-
|
|
20
|
+
name="${first_video%.*}"
|
|
21
|
+
ext="${first_video##*.}"
|
|
22
|
+
outfile="${name}.concat.${ext}"
|
|
19
23
|
|
|
20
24
|
ffmpeg -v error -f concat -safe 0 -i "$list_file" -c copy "$outfile"
|
|
21
25
|
|
package/src/vtrim.sh
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#!/bin/
|
|
1
|
+
#!/bin/sh
|
|
2
2
|
|
|
3
3
|
if [ "$#" -ne 3 ]; then
|
|
4
4
|
cat << EOF
|
|
@@ -27,9 +27,13 @@ DIRNAME=$(dirname "$VIDEO")
|
|
|
27
27
|
EXT="${BASENAME##*.}"
|
|
28
28
|
NAME="${BASENAME%.*}"
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
echo "start $START, end $END"
|
|
31
31
|
|
|
32
|
-
ffmpeg -v error -y
|
|
33
|
-
|
|
32
|
+
ffmpeg -v error -y \
|
|
33
|
+
-ss "$START" \
|
|
34
34
|
-to "$END" \
|
|
35
|
-
|
|
35
|
+
-i "$VIDEO" \
|
|
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
|
package/tests/avif.test.js
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
2
|
import { test } from 'node:test'
|
|
3
|
-
import { equal } from 'node:assert/strict'
|
|
4
3
|
import { tmpdir } from 'node:os'
|
|
5
4
|
import { execSync } from 'node:child_process'
|
|
5
|
+
import { deepEqual } from 'node:assert/strict'
|
|
6
6
|
import { mkdtempSync } from 'node:fs'
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { videoAttrs } from '../src/utils/ffmpeg.js'
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
test('PNG to AVIF', () => {
|
|
11
|
+
test('PNG to AVIF', async () => {
|
|
12
12
|
const tmp = mkdtempSync(join(tmpdir(), 'avif-test-'))
|
|
13
|
-
execSync(`src/cli.js avif --output-dir ${tmp}
|
|
14
|
-
|
|
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'
|
|
15
20
|
})
|
|
Binary file
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
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'
|
|
8
|
+
|
|
9
|
+
test('vconcat concatenates videos with single quotes in filenames', async () => {
|
|
10
|
+
const tmp = mkdtempSync(join(tmpdir(), 'vconcat-test-'))
|
|
11
|
+
|
|
12
|
+
const file1 = join(tmp, `video'1.mp4`)
|
|
13
|
+
const file2 = join(tmp, `video'2.mp4`)
|
|
14
|
+
cpSync('tests/fixtures/60fps.mp4', file1)
|
|
15
|
+
cpSync('tests/fixtures/60fps.mp4', file2)
|
|
16
|
+
|
|
17
|
+
execSync(`src/cli.js vconcat "${file1}" "${file2}"`)
|
|
18
|
+
|
|
19
|
+
const { duration } = await videoAttrs(join(tmp, `video'1.concat.mp4`))
|
|
20
|
+
ok(parseFloat(duration) === 60, `Duration should be 60s, got ${duration}s`)
|
|
21
|
+
})
|
package/tests/vtrim.test.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
2
|
import { test } from 'node:test'
|
|
3
|
-
import {
|
|
3
|
+
import { ok } from 'node:assert/strict'
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import { execSync } from 'node:child_process'
|
|
6
6
|
import { mkdtempSync, cpSync } from 'node:fs'
|
|
7
|
-
import {
|
|
7
|
+
import { videoAttrs } from '../src/utils/ffmpeg.js'
|
|
8
8
|
|
|
9
|
-
test('vtrim trims video from start to end time', () => {
|
|
9
|
+
test('vtrim trims video from start to end time', async () => {
|
|
10
10
|
const tmp = mkdtempSync(join(tmpdir(), 'vtrim-test-'))
|
|
11
11
|
|
|
12
12
|
const inputFile = join(tmp, '60fps.mp4')
|
|
@@ -14,7 +14,7 @@ test('vtrim trims video from start to end time', () => {
|
|
|
14
14
|
|
|
15
15
|
execSync(`src/cli.js vtrim 5 10 ${inputFile}`)
|
|
16
16
|
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
|
|
17
|
+
const { duration } = await videoAttrs(join(tmp, '60fps.trim.mp4'))
|
|
18
|
+
const EPSILON = 0.05
|
|
19
|
+
ok(Math.abs(parseFloat(duration) - 5) < EPSILON, `Duration should be 5s, got ${duration}s`)
|
|
20
20
|
})
|
package/src/vsplit.sh
DELETED
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
#!/bin/zsh
|
|
2
|
-
|
|
3
|
-
if [ "$#" -lt 2 ]; then
|
|
4
|
-
cat << EOF
|
|
5
|
-
Usage:
|
|
6
|
-
$(basename $0) <split-time-1> [split-time-2 ...] <video-file>
|
|
7
|
-
|
|
8
|
-
Examples:
|
|
9
|
-
$(basename $0) 123.45 video.mp4
|
|
10
|
-
$(basename $0) 60 120 180 video.mov
|
|
11
|
-
$(basename $0) 00:01:00 00:02:00 00:03:00 video.mkv
|
|
12
|
-
EOF
|
|
13
|
-
exit 1
|
|
14
|
-
fi
|
|
15
|
-
|
|
16
|
-
VIDEO="${@[-1]}"
|
|
17
|
-
SPLITS=("${@[1,-2]}")
|
|
18
|
-
|
|
19
|
-
if [ ! -f "$VIDEO" ]; then
|
|
20
|
-
echo "Error: file not found: $VIDEO"
|
|
21
|
-
exit 1
|
|
22
|
-
fi
|
|
23
|
-
|
|
24
|
-
DIRNAME=$(dirname "$VIDEO")
|
|
25
|
-
BASENAME=$(basename "$VIDEO")
|
|
26
|
-
EXT="${BASENAME##*.}"
|
|
27
|
-
NAME="${BASENAME%.*}"
|
|
28
|
-
|
|
29
|
-
N_CLIPS=$((${#SPLITS[@]} + 1))
|
|
30
|
-
|
|
31
|
-
for (( i=1; i<=$N_CLIPS; i++ )); do
|
|
32
|
-
outfile="$DIRNAME/${NAME}_${i}.$EXT"
|
|
33
|
-
|
|
34
|
-
if [ $i -eq 1 ]; then # First clip: [start, first_split]
|
|
35
|
-
ffmpeg -v error -y -i "$VIDEO" \
|
|
36
|
-
-t "${SPLITS[1]}" \
|
|
37
|
-
-c copy "$outfile"
|
|
38
|
-
|
|
39
|
-
elif [ $i -eq $N_CLIPS ]; then # Last clip: [last_split, end]
|
|
40
|
-
ffmpeg -v error -y -i "$VIDEO" \
|
|
41
|
-
-ss "${SPLITS[-1]}" \
|
|
42
|
-
-c copy "$outfile"
|
|
43
|
-
|
|
44
|
-
else # Middle clip: [split[i-1], split[i]]
|
|
45
|
-
ffmpeg -v error -y -i "$VIDEO" \
|
|
46
|
-
-ss "${SPLITS[$((i-1))]}" \
|
|
47
|
-
-to "${SPLITS[$i]}" \
|
|
48
|
-
-c copy "$outfile"
|
|
49
|
-
fi
|
|
50
|
-
done
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/tests/vsplit.test.js
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { join } from 'node:path'
|
|
2
|
-
import { test } from 'node:test'
|
|
3
|
-
import { equal } from 'node:assert/strict'
|
|
4
|
-
import { tmpdir } from 'node:os'
|
|
5
|
-
import { execSync } from 'node:child_process'
|
|
6
|
-
import { mkdtempSync, cpSync } from 'node:fs'
|
|
7
|
-
import { sha1 } from './utils.js'
|
|
8
|
-
|
|
9
|
-
test('vsplit splits video at multiple time points', () => {
|
|
10
|
-
const tmp = mkdtempSync(join(tmpdir(), 'vsplit-test-'))
|
|
11
|
-
|
|
12
|
-
const inputFile = join(tmp, '60fps.mp4')
|
|
13
|
-
cpSync('tests/fixtures/60fps.mp4', inputFile)
|
|
14
|
-
|
|
15
|
-
execSync(`src/cli.js vsplit 5 10 15 20 25 ${inputFile}`)
|
|
16
|
-
|
|
17
|
-
for (let i = 1; i <= 6; i++) {
|
|
18
|
-
const generatedFile = join(tmp, `60fps_${i}.mp4`)
|
|
19
|
-
const expectedFile = `tests/fixtures/60fps_${i}.mp4`
|
|
20
|
-
equal(sha1(generatedFile), sha1(expectedFile), `60fps_${i}.mp4 hash should match expected`)
|
|
21
|
-
}
|
|
22
|
-
})
|