mediasnacks 0.19.1 → 0.20.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/.zsh/completions/_mediasnacks +2 -1
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/avif.test.js +4 -7
- package/src/cli.js +1 -0
- package/src/detectdups.js +35 -11
- package/src/detectdups.test.js +36 -0
- package/src/edgespic.test.js +12 -8
- package/src/fixtures/big-buck-bunny/bbb_24_to_25fps_dup.mp4 +0 -0
- package/src/fixtures/big-buck-bunny/bbb_24_to_30fps_dup.mp4 +0 -0
- package/src/fixtures/big-buck-bunny/bbb_24_to_48fps_dup.mp4 +0 -0
- package/src/fixtures/big-buck-bunny/bbb_24fps_no_dups.mp4 +0 -0
- package/src/fixtures/big-buck-bunny/bbb_25_to_30fps_dup.mp4 +0 -0
- package/src/fixtures/big-buck-bunny/bbb_25_to_50fps_dup.mp4 +0 -0
- package/src/fixtures/big-buck-bunny/bbb_25_to_60fps_dup.mp4 +0 -0
- package/src/fixtures/big-buck-bunny/bbb_25fps_no_dups.mp4 +0 -0
- package/src/fixtures/big-buck-bunny/generate.md +71 -0
- package/src/fixtures/edgespic/60fps_first.png +0 -0
- package/src/fixtures/edgespic/60fps_last.png +0 -0
- package/src/fixtures/lenna.avif +0 -0
- package/src/ssim.js +44 -0
- package/src/utils/ffmpeg.js +3 -4
- package/src/utils/test-utils.js +5 -12
|
@@ -6,6 +6,7 @@ _mediasnacks_commands=(
|
|
|
6
6
|
|
|
7
7
|
'resize:Resizes videos or images'
|
|
8
8
|
'edgespic:Extracts first and last frames'
|
|
9
|
+
'ssim:Computes similarity of two images'
|
|
9
10
|
'gif:Video to GIF'
|
|
10
11
|
|
|
11
12
|
'detectdups:Detects sequentially duplicate frames in a video'
|
|
@@ -39,7 +40,7 @@ fi
|
|
|
39
40
|
|
|
40
41
|
local cmd="$words[2]"
|
|
41
42
|
case "$cmd" in
|
|
42
|
-
avif|resize|sqcrop|moov2front|detectdups|dropdups|edgespic|seqcheck|hev1tohvc1|framediff|vdiff|vconcat|vsplit|vtrim|dlaudio|dlvideo|unemoji|rmcover|curltime|gif|flattendir|prores)
|
|
43
|
+
avif|resize|sqcrop|moov2front|detectdups|dropdups|edgespic|seqcheck|hev1tohvc1|framediff|vdiff|vconcat|vsplit|vtrim|dlaudio|dlvideo|unemoji|rmcover|curltime|gif|flattendir|prores|ssim)
|
|
43
44
|
_files
|
|
44
45
|
;;
|
|
45
46
|
qdir)
|
package/README.md
CHANGED
package/package.json
CHANGED
package/src/avif.test.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
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
|
|
|
5
|
-
import {
|
|
5
|
+
import { ssim } from './ssim.js'
|
|
6
6
|
import { mkTempDir, cli } from './utils/test-utils.js'
|
|
7
7
|
|
|
8
8
|
const rel = f => join(import.meta.dirname, f)
|
|
@@ -11,9 +11,6 @@ test('PNG to AVIF', async () => {
|
|
|
11
11
|
const tmp = mkTempDir('avif')
|
|
12
12
|
cli('avif', '--output-dir', tmp, rel('fixtures/lenna.png'))
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
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'
|
|
14
|
+
const similarityScore = await ssim(join(tmp, 'lenna.avif'), rel('fixtures/lenna.avif'))
|
|
15
|
+
ok(similarityScore > 0.99, `Similarity too low: ${similarityScore}`)
|
|
19
16
|
})
|
package/src/cli.js
CHANGED
|
@@ -12,6 +12,7 @@ const COMMANDS = {
|
|
|
12
12
|
|
|
13
13
|
resize: ['resize.js', 'Resizes videos or images'],
|
|
14
14
|
edgespic: ['edgespic.js', 'Extracts first and last frames'],
|
|
15
|
+
ssim: ['ssim.js', 'Computes SSIM between two images'],
|
|
15
16
|
gif: ['gif.sh', 'Video to GIF\n'],
|
|
16
17
|
|
|
17
18
|
detectdups: ['detectdups.js', 'Detects duplicate frames in a video'],
|
package/src/detectdups.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { parseOptions } from './utils/parseOptions.js'
|
|
4
4
|
import { ffmpeg, assertUserHasFFmpeg, videoAttrs } from './utils/ffmpeg.js'
|
|
5
5
|
|
|
6
|
+
const STDEV_THRESHOLD = 0.2
|
|
6
7
|
|
|
7
8
|
const MAN = `
|
|
8
9
|
Usage: mediasnacks detectdups [options] <video>
|
|
@@ -73,7 +74,7 @@ async function main() {
|
|
|
73
74
|
analyze(dups, seek, duration)
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
async function detectDuplicateFramesNums(video, seek, duration) {
|
|
77
|
+
export async function detectDuplicateFramesNums(video, seek, duration) {
|
|
77
78
|
const { stderr } = await ffmpeg([
|
|
78
79
|
'-v', 'info',
|
|
79
80
|
'-stats',
|
|
@@ -81,7 +82,6 @@ async function detectDuplicateFramesNums(video, seek, duration) {
|
|
|
81
82
|
'-t', duration,
|
|
82
83
|
'-i', video,
|
|
83
84
|
'-vf', [
|
|
84
|
-
'scale=320:-1',
|
|
85
85
|
'tblend=all_mode=difference',
|
|
86
86
|
'format=gray',
|
|
87
87
|
'showinfo',
|
|
@@ -89,16 +89,23 @@ async function detectDuplicateFramesNums(video, seek, duration) {
|
|
|
89
89
|
'-f', 'null', '-',
|
|
90
90
|
])
|
|
91
91
|
|
|
92
|
-
const
|
|
92
|
+
const reNearBlackFrames = /n:\s*(\d+).*?mean:\[0].*?stdev:\[([0-9.]+)]/
|
|
93
93
|
const dupFrames = []
|
|
94
94
|
for (const line of stderr.split('\n')) {
|
|
95
|
-
const match = line.match(
|
|
96
|
-
if (match)
|
|
97
|
-
|
|
95
|
+
const match = line.match(reNearBlackFrames)
|
|
96
|
+
if (match) {
|
|
97
|
+
const stdev = parseFloat(match[2])
|
|
98
|
+
if (stdev <= STDEV_THRESHOLD) {
|
|
99
|
+
const frameNum = parseInt(match[1], 10)
|
|
100
|
+
dupFrames.push(frameNum)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
98
103
|
}
|
|
99
104
|
return dupFrames
|
|
100
105
|
}
|
|
101
106
|
|
|
107
|
+
// This is only good for when there's one repeated frame in a cycle.
|
|
108
|
+
// i.e. it's the wrong approach for e.g. 25 to 60, in which N=2 and N=3
|
|
102
109
|
function analyze(dup_frames, seek, duration) {
|
|
103
110
|
const histogram = {}
|
|
104
111
|
for (let i = 1; i < dup_frames.length; i++) {
|
|
@@ -110,11 +117,28 @@ function analyze(dup_frames, seek, duration) {
|
|
|
110
117
|
start_sec: seek,
|
|
111
118
|
end_sec: seek + duration
|
|
112
119
|
},
|
|
113
|
-
histogram
|
|
120
|
+
histogram,
|
|
121
|
+
n: maxFreqKey(histogram)
|
|
114
122
|
}, null, 2))
|
|
115
123
|
}
|
|
116
124
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
125
|
+
function maxFreqKey(hist) {
|
|
126
|
+
let maxKey = null
|
|
127
|
+
let maxVal = -1
|
|
128
|
+
for (const [key, val] of Object.entries(hist))
|
|
129
|
+
if (val > maxVal) {
|
|
130
|
+
maxVal = val
|
|
131
|
+
maxKey = key
|
|
132
|
+
}
|
|
133
|
+
return maxKey !== null
|
|
134
|
+
? Number(maxKey)
|
|
135
|
+
: null
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if (import.meta.main)
|
|
141
|
+
main().catch(err => {
|
|
142
|
+
console.error(err.message || err)
|
|
143
|
+
process.exit(1)
|
|
144
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { equal } from 'node:assert/strict'
|
|
4
|
+
import { cli } from './utils/test-utils.js'
|
|
5
|
+
|
|
6
|
+
const rel = f => join(import.meta.dirname, f)
|
|
7
|
+
|
|
8
|
+
function detect(video) {
|
|
9
|
+
const { stdout } = cli('detectdups', rel(video), 0, 7)
|
|
10
|
+
return JSON.parse(stdout).n
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
test('no dups', () =>
|
|
15
|
+
equal(detect('fixtures/big-buck-bunny/bbb_24fps_no_dups.mp4'), null))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
// These fixtures are badly retimed (non-interpolated, just duplicating a frame)
|
|
19
|
+
|
|
20
|
+
test('24 to 48 (has dup at n=2)', () =>
|
|
21
|
+
equal(detect('fixtures/big-buck-bunny/bbb_24_to_48fps_dup.mp4'), 2))
|
|
22
|
+
|
|
23
|
+
test('25 to 50 (has dup at n=2)', () =>
|
|
24
|
+
equal(detect('fixtures/big-buck-bunny/bbb_25_to_50fps_dup.mp4'), 2))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
test('24 to 30 (has dup at n=5)', () =>
|
|
28
|
+
equal(detect('fixtures/big-buck-bunny/bbb_24_to_30fps_dup.mp4'), 5))
|
|
29
|
+
|
|
30
|
+
test('25 to 30 (has dup at n=6)', () =>
|
|
31
|
+
equal(detect('fixtures/big-buck-bunny/bbb_25_to_30fps_dup.mp4'), 6))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
test('24 to 25 (has dup at n=25)', () =>
|
|
35
|
+
equal(detect('fixtures/big-buck-bunny/bbb_24_to_25fps_dup.mp4'), 25))
|
|
36
|
+
|
package/src/edgespic.test.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { ok } from 'node:assert/strict'
|
|
1
2
|
import { join } from 'node:path'
|
|
2
|
-
import { ok, equal } from 'node:assert/strict'
|
|
3
3
|
import { describe, test } from 'node:test'
|
|
4
4
|
import { cpSync, readdirSync, } from 'node:fs'
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { ssim } from './ssim.js'
|
|
7
|
+
import { cli, mkTempDir } from './utils/test-utils.js'
|
|
7
8
|
|
|
8
9
|
const rel = f => join(import.meta.dirname, f)
|
|
9
10
|
|
|
@@ -18,15 +19,18 @@ describe('edgespic', () => {
|
|
|
18
19
|
ok(files.length === 2, `Expected 2 PNG files, got ${files.length}`)
|
|
19
20
|
})
|
|
20
21
|
|
|
21
|
-
test('extracts first frame', () => {
|
|
22
|
+
test('extracts first frame', async () => {
|
|
23
|
+
const out = join(tmp, 'edgespic', '60fps_first.png')
|
|
22
24
|
const fixture = rel('fixtures/edgespic/60fps_first.png')
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
+
const similarityScore = await ssim(out, fixture)
|
|
26
|
+
ok(similarityScore > 0.99, `Similarity too low: ${similarityScore}`)
|
|
25
27
|
})
|
|
26
28
|
|
|
27
|
-
test('extracts last frame', () => {
|
|
29
|
+
test('extracts last frame', async () => {
|
|
30
|
+
const out = join(tmp, 'edgespic', '60fps_last.png')
|
|
28
31
|
const fixture = rel('fixtures/edgespic/60fps_last.png')
|
|
29
|
-
const
|
|
30
|
-
|
|
32
|
+
const similarityScore = await ssim(out, fixture)
|
|
33
|
+
ok(similarityScore > 0.99, `Similarity too low: ${similarityScore}`)
|
|
31
34
|
})
|
|
32
35
|
})
|
|
36
|
+
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Generating Fixtures
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
## 1. Download a video (Big Buck Bunny)
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
curl https://download.blender.org/peach/bigbuckbunny_movies/big_buck_bunny_1080p_h264.mov -o bbb_full_24fps.mov
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## 2. Extract a scene
|
|
11
|
+
Using the third scene because it's complex enough to cover many edge cases.
|
|
12
|
+
It has a bird flapping its wings with motion blur. Also, animated text titles,
|
|
13
|
+
and fairly static frames after the title ends.
|
|
14
|
+
|
|
15
|
+
The scene is 1080p, 24fps, 7.28sec, h.264.
|
|
16
|
+
```sh
|
|
17
|
+
brew tap ericfortis/fcpscene
|
|
18
|
+
brew install fcpscene
|
|
19
|
+
fcpscene -m files bbb_full_24fps.mov
|
|
20
|
+
|
|
21
|
+
cp bbb/bbb_full_24fps_003.mov ./bbb_24fps_no_dups.mov
|
|
22
|
+
rm -rf bbb
|
|
23
|
+
rm bbb_full_24fps.mov
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## 3. Re-encode the scene 24fps
|
|
27
|
+
This way all videos will share the same encoding, and no audio.
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
ffmpeg -i bbb_24fps_no_dups.mov \
|
|
31
|
+
-c:v libx264 -crf 18 -preset slow \
|
|
32
|
+
-an \
|
|
33
|
+
bbb_24fps_no_dups.mp4
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## 4. Retime (no dups) speed stretch to 25fps
|
|
37
|
+
For a good (no dups) 25fps, retiming by speeding it up.
|
|
38
|
+
```sh
|
|
39
|
+
ffmpeg -i bbb_24fps_no_dups.mp4 \
|
|
40
|
+
-vf "setpts=24/25*PTS" \
|
|
41
|
+
-r 25 \
|
|
42
|
+
bbb_25fps_no_dups.mp4
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
## 5. Retime by inserting duplicates (no interpolation)
|
|
47
|
+
```sh
|
|
48
|
+
for TARGET_FPS in 48 30 25; do
|
|
49
|
+
ffmpeg -i bbb_24fps_no_dups.mp4 \
|
|
50
|
+
-vf fps=$TARGET_FPS \
|
|
51
|
+
-c:v libx264 -crf 18 -preset slow \
|
|
52
|
+
-an \
|
|
53
|
+
"bbb_24_to_${TARGET_FPS}fps_dup.mp4"
|
|
54
|
+
done
|
|
55
|
+
|
|
56
|
+
for TARGET_FPS in 60 50 30; do
|
|
57
|
+
ffmpeg -i bbb_25fps_no_dups.mp4 \
|
|
58
|
+
-vf fps=$TARGET_FPS \
|
|
59
|
+
-c:v libx264 -crf 18 -preset slow \
|
|
60
|
+
-an \
|
|
61
|
+
"bbb_25_to_${TARGET_FPS}fps_dup.mp4"
|
|
62
|
+
done
|
|
63
|
+
```
|
|
64
|
+
Counting the cycle from 1 (not from 0):
|
|
65
|
+
|
|
66
|
+
- 24 to 48 (inserts dup at n=2) 0101
|
|
67
|
+
- 25 to 50 (inserts dup at n=2) 0101
|
|
68
|
+
- 24 to 30 (inserts dup at n=5) 0000100001
|
|
69
|
+
- 25 to 30 (inserts dup at n=6) 000001000001
|
|
70
|
+
- 24 to 25 (inserts dup at n=25) (0*24)1
|
|
71
|
+
- 25 to 60 (inserts dup at n=2 and n=3) 01011
|
|
Binary file
|
|
Binary file
|
package/src/fixtures/lenna.avif
CHANGED
|
Binary file
|
package/src/ssim.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { ffmpeg } from './utils/ffmpeg.js'
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
const MAN = `
|
|
7
|
+
SYNOPSIS
|
|
8
|
+
mediasnacks ssim <img1> <img2>
|
|
9
|
+
|
|
10
|
+
DESCRIPTION
|
|
11
|
+
Computes the Structural Similarity Index (SSIM) between two images using ffmpeg.
|
|
12
|
+
`.trim()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async function main() {
|
|
16
|
+
const [img1, img2] = process.argv.slice(2)
|
|
17
|
+
if (!img1 || !img2) {
|
|
18
|
+
console.log(MAN)
|
|
19
|
+
process.exit(1)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const score = await ssim(img1, img2)
|
|
23
|
+
console.log(score)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function ssim(img1, img2) {
|
|
27
|
+
const result = await ffmpeg([
|
|
28
|
+
'-i', img1,
|
|
29
|
+
'-i', img2,
|
|
30
|
+
'-filter_complex', 'ssim',
|
|
31
|
+
'-f', 'null', '-'
|
|
32
|
+
])
|
|
33
|
+
const match = result.stderr.match(/All:([\d.]+)/)
|
|
34
|
+
if (!match)
|
|
35
|
+
throw new Error(`Could not parse SSIM output:\n${result.stderr}`)
|
|
36
|
+
return parseFloat(match[1])
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
if (import.meta.main)
|
|
41
|
+
main().catch(err => {
|
|
42
|
+
console.error(err.message)
|
|
43
|
+
process.exit(1)
|
|
44
|
+
})
|
package/src/utils/ffmpeg.js
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process'
|
|
2
2
|
|
|
3
3
|
|
|
4
|
-
export async function ffmpeg(args) {
|
|
5
|
-
return runSilently('ffmpeg', args)
|
|
6
|
-
}
|
|
7
|
-
|
|
8
4
|
export async function assertUserHasFFmpeg() {
|
|
9
5
|
try {
|
|
10
6
|
await runSilently('ffmpeg', ['-version'])
|
|
@@ -15,6 +11,9 @@ export async function assertUserHasFFmpeg() {
|
|
|
15
11
|
}
|
|
16
12
|
}
|
|
17
13
|
|
|
14
|
+
export async function ffmpeg(args) {
|
|
15
|
+
return runSilently('ffmpeg', args)
|
|
16
|
+
}
|
|
18
17
|
|
|
19
18
|
async function runSilently(program, args) {
|
|
20
19
|
return new Promise((resolve, reject) => {
|
package/src/utils/test-utils.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { join }
|
|
2
|
-
import { tmpdir }
|
|
3
|
-
import { spawnSync }
|
|
4
|
-
import {
|
|
5
|
-
import { mkdtempSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { spawnSync } from 'node:child_process'
|
|
4
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
6
5
|
|
|
7
6
|
const rel = f => join(import.meta.dirname, f)
|
|
8
7
|
|
|
@@ -11,13 +10,7 @@ export function mkTempDir(prefix = 'test-') {
|
|
|
11
10
|
}
|
|
12
11
|
|
|
13
12
|
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')
|
|
13
|
+
return spawnSync(rel('../cli.js'), args)
|
|
21
14
|
}
|
|
22
15
|
|
|
23
16
|
export function dir(...args) {
|