mediasnacks 0.0.1 → 0.1.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 +42 -0
- package/README.md +7 -1
- package/package.json +1 -1
- package/src/{cli-avif.js → avif.js} +2 -5
- package/src/cli.js +24 -4
- package/src/dropdups.js +91 -0
- package/src/framediff.sh +11 -0
- package/src/hev1tohvc1.js +54 -0
- package/src/{cli-moov2front.js → moov2front.js} +3 -8
- package/src/qdir.js +99 -0
- package/src/{cli-resize.js → resize.js} +3 -8
- package/src/seqcheck.js +71 -0
- package/src/{ffmpeg.js → utils/ffmpeg.js} +22 -5
- package/src/{fs-utils.js → utils/fs-utils.js} +5 -6
- package/src/videodiff.sh +10 -0
- package/tests/qdir/jobs/job1_good.sh +1 -0
- package/tests/qdir/jobs/job2_bad.sh +1 -0
- package/tests/qdir/jobs/job3_good.sh +2 -0
- package/tests/qdir/jobs/job4_bad.sh +1 -0
- package/tests/qdir/qdir.test.js +26 -0
- package/tests/seqcheck.test.js +27 -0
- /package/src/{fs-utils.test.js → utils/fs-utils.test.js} +0 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#compdef mediasnacks
|
|
2
|
+
|
|
3
|
+
_mediasnacks_commands=(
|
|
4
|
+
'avif:Converts images to AVIF'
|
|
5
|
+
'resize:Resizes videos or images'
|
|
6
|
+
'moov2front:Rearranges metadata for fast-start streaming'
|
|
7
|
+
'dropdups:Removes duplicate frames in a video'
|
|
8
|
+
'seqcheck:Finds missing sequence number'
|
|
9
|
+
'qdir:Sequentially runs all *.sh files in a folder'
|
|
10
|
+
'hev1tohvc1:Fixes video thumbnails not rendering in macOS Finder'
|
|
11
|
+
'framediff:ffplay with a filter for diffing adjacent frames'
|
|
12
|
+
'videodiff:Plays a video with the difference of two videos'
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
_mediasnacks() {
|
|
16
|
+
if (( CURRENT == 2 )); then
|
|
17
|
+
_describe -t commands 'mediasnacks commands' _mediasnacks_commands
|
|
18
|
+
return
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
local cmd="$words[2]"
|
|
22
|
+
case "$cmd" in
|
|
23
|
+
avif|resize|moov2front|dropdups|seqcheck|hev1tohvc1|framediff|videodiff)
|
|
24
|
+
_files
|
|
25
|
+
;;
|
|
26
|
+
qdir)
|
|
27
|
+
_files -/
|
|
28
|
+
;;
|
|
29
|
+
esac
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
compdef _mediasnacks mediasnacks
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# INSTALL in: ~/.zshrc
|
|
36
|
+
#fpath=(~/.zsh/completions $fpath)
|
|
37
|
+
#zmodload zsh/complist
|
|
38
|
+
#autoload -U compinit && compinit
|
|
39
|
+
|
|
40
|
+
#function mediasnacks() {
|
|
41
|
+
# $HOME/work/mediasnacks/src/./cli.js "$@"
|
|
42
|
+
#}
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# mediasnacks
|
|
2
2
|
|
|
3
|
-
Utilities optimizing and preparing video and images for web.
|
|
3
|
+
Utilities optimizing and preparing video and images for the web.
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
## Usage Overview
|
|
@@ -14,7 +14,13 @@ Commands:
|
|
|
14
14
|
- `avif` Converts images to AVIF
|
|
15
15
|
- `resize` Resizes videos or images
|
|
16
16
|
- `moov2front` Rearranges .mov and .mp4 metadata for fast-start streaming
|
|
17
|
+
- `dropdups` Removes duplicate frames in a video
|
|
18
|
+
- `seqcheck` Finds missing sequence number
|
|
19
|
+
- `qdir` Sequentially runs all *.sh files in a folder
|
|
20
|
+
- `hev1tohvc1`: Fixes video thumbnails not rendering in macOS Finder
|
|
17
21
|
|
|
22
|
+
- `framediff`: Plays a video of adjacent frames diff
|
|
23
|
+
- `videodiff`: Plays a video with the difference of two videos
|
|
18
24
|
<br/>
|
|
19
25
|
|
|
20
26
|
### Converting Images to AVIF
|
package/package.json
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
1
|
import { join } from 'node:path'
|
|
4
2
|
import { parseArgs } from 'node:util'
|
|
5
3
|
|
|
6
|
-
import { glob, replaceExt, lstat } from './fs-utils.js'
|
|
7
|
-
import { ffmpeg, assertUserHasFFmpeg } from './ffmpeg.js'
|
|
4
|
+
import { glob, replaceExt, lstat } from './utils/fs-utils.js'
|
|
5
|
+
import { ffmpeg, assertUserHasFFmpeg } from './utils/ffmpeg.js'
|
|
8
6
|
|
|
9
7
|
|
|
10
8
|
const USAGE = `
|
|
@@ -57,7 +55,6 @@ async function toAvif({ file, outFile, overwrite }) {
|
|
|
57
55
|
return
|
|
58
56
|
}
|
|
59
57
|
|
|
60
|
-
// TODO test on linux
|
|
61
58
|
console.log(file)
|
|
62
59
|
await ffmpeg([
|
|
63
60
|
'-y', // overwrites
|
package/src/cli.js
CHANGED
|
@@ -6,9 +6,17 @@ import pkgJSON from '../package.json' with { type: 'json' }
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
const COMMANDS = {
|
|
9
|
-
avif: join(import.meta.dirname, '
|
|
10
|
-
resize: join(import.meta.dirname, '
|
|
11
|
-
moov2front: join(import.meta.dirname, '
|
|
9
|
+
avif: join(import.meta.dirname, 'avif.js'),
|
|
10
|
+
resize: join(import.meta.dirname, 'resize.js'),
|
|
11
|
+
moov2front: join(import.meta.dirname, 'moov2front.js'),
|
|
12
|
+
|
|
13
|
+
dropdups: join(import.meta.dirname, 'dropdups.js'),
|
|
14
|
+
seqcheck: join(import.meta.dirname, 'seqcheck.js'),
|
|
15
|
+
qdir: join(import.meta.dirname, 'qdir.js'),
|
|
16
|
+
hev1tohvc1: join(import.meta.dirname, 'hev1tohvc1.js'),
|
|
17
|
+
|
|
18
|
+
framediff: join(import.meta.dirname, 'framediff.sh'),
|
|
19
|
+
videodiff: join(import.meta.dirname, 'videodiff.sh'),
|
|
12
20
|
}
|
|
13
21
|
|
|
14
22
|
const USAGE = `
|
|
@@ -18,6 +26,14 @@ Commands:
|
|
|
18
26
|
avif: Converts images to AVIF
|
|
19
27
|
resize: Resizes videos or images
|
|
20
28
|
moov2front: Rearranges .mov and .mp4 metadata for fast-start streaming
|
|
29
|
+
|
|
30
|
+
dropdups: Removes duplicate frames in a video
|
|
31
|
+
seqcheck: Finds missing sequence number
|
|
32
|
+
qdir: Sequentially runs all *.sh files in a folder
|
|
33
|
+
hev1tohvc1: Fixes video thumbnails not rendering in macOS Finder
|
|
34
|
+
|
|
35
|
+
framediff: Plays a video of adjacent frames diff
|
|
36
|
+
videodiff: Plays a video with the difference of two videos
|
|
21
37
|
`.trim()
|
|
22
38
|
|
|
23
39
|
|
|
@@ -43,5 +59,9 @@ if (!Object.hasOwn(COMMANDS, opt)) {
|
|
|
43
59
|
process.exit(1)
|
|
44
60
|
}
|
|
45
61
|
|
|
46
|
-
|
|
62
|
+
const cmd = COMMANDS[opt]
|
|
63
|
+
const executable = cmd.endsWith('.sh')
|
|
64
|
+
? 'sh'
|
|
65
|
+
: process.execPath
|
|
66
|
+
spawn(executable, [cmd, ...args], { stdio: 'inherit' })
|
|
47
67
|
.on('exit', code => process.exit(code))
|
package/src/dropdups.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { parseArgs } from 'node:util'
|
|
3
|
+
import { resolve, parse, format } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { glob } from './utils/fs-utils.js'
|
|
6
|
+
import { ffmpeg, assertUserHasFFmpeg, run } from './utils/ffmpeg.js'
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
const PRORES_PROFILES = {
|
|
10
|
+
'proxy': 0,
|
|
11
|
+
'lt': 1,
|
|
12
|
+
'standard': 2,
|
|
13
|
+
'hq': 3,
|
|
14
|
+
'4444': 4,
|
|
15
|
+
'4444xq': 5,
|
|
16
|
+
}
|
|
17
|
+
const PROFILE = PRORES_PROFILES.hq
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
const USAGE = `
|
|
21
|
+
Usage: npx mediasnacks dropdups [-n <bad-frame-number>] <video>
|
|
22
|
+
|
|
23
|
+
Removes duplicate frames and outputs ProRes 422 HQ.
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
-n, --bad-frame-number <n> Known frame interval to drop.
|
|
27
|
+
(default: n=0) auto-detects repeated frames (slower)
|
|
28
|
+
Ex.A: Use n=2 when every other frame is repeated.
|
|
29
|
+
Ex.B: Use n=6 if e.g., a 25 fps got upped to 30 fps without interpolation.
|
|
30
|
+
-h, --help
|
|
31
|
+
`.trim()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async function main() {
|
|
35
|
+
const { values, positionals } = parseArgs({
|
|
36
|
+
options: {
|
|
37
|
+
'bad-frame-number': { short: 'n', type: 'string', default: '' },
|
|
38
|
+
help: { short: 'h', type: 'boolean', default: false },
|
|
39
|
+
},
|
|
40
|
+
allowPositionals: true
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
if (values.help) {
|
|
44
|
+
console.log(USAGE)
|
|
45
|
+
process.exit(0)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!positionals.length)
|
|
49
|
+
throw new Error('No video specified. See npx mediasnacks dropdups --help')
|
|
50
|
+
|
|
51
|
+
let nBadFrame = values['bad-frame-number']
|
|
52
|
+
if (nBadFrame && /^\d+$/.test(nBadFrame))
|
|
53
|
+
throw new Error('Invalid --bad-frame-number. It must be a positive integer.')
|
|
54
|
+
|
|
55
|
+
await assertUserHasFFmpeg()
|
|
56
|
+
|
|
57
|
+
console.log('Dropping Duplicate Frames…')
|
|
58
|
+
for (const g of positionals)
|
|
59
|
+
for (const file of await glob(g))
|
|
60
|
+
await drop(resolve(file), nBadFrame)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function drop(video, nBadFrame) {
|
|
64
|
+
await run('ffmpeg', [
|
|
65
|
+
'-v', 'error',
|
|
66
|
+
'-stats',
|
|
67
|
+
'-an',
|
|
68
|
+
'-i', video,
|
|
69
|
+
'-vf', nBadFrame
|
|
70
|
+
? `decimate=cycle=${nBadFrame}`
|
|
71
|
+
: 'mpdecimate,setpts=N/FRAME_RATE/TB',
|
|
72
|
+
'-fps_mode', 'cfr',
|
|
73
|
+
'-c:v', 'prores_ks',
|
|
74
|
+
'-profile:v', PROFILE,
|
|
75
|
+
'-pix_fmt', 'yuv422p10le',
|
|
76
|
+
makeOutputPath(video)
|
|
77
|
+
])
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function makeOutputPath(video) {
|
|
81
|
+
const abs = resolve(video)
|
|
82
|
+
const { dir, name, ext } = parse(abs)
|
|
83
|
+
return ext.toLowerCase() === '.mov'
|
|
84
|
+
? format({ dir, name: `${name}.dedup`, ext: '.mov' })
|
|
85
|
+
: format({ dir, name, ext: '.mov' })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
main().catch(err => {
|
|
89
|
+
console.error(err.message || err)
|
|
90
|
+
process.exit(1)
|
|
91
|
+
})
|
package/src/framediff.sh
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Plays a video with a filter for diffing adjacent frames.
|
|
2
|
+
# I use this for finding repeated frames. For example, you’ll see
|
|
3
|
+
# a black frame if two consecutive frames are almost similar.
|
|
4
|
+
|
|
5
|
+
# The frame number is rendered at the top-left.
|
|
6
|
+
|
|
7
|
+
ffplay -hide_banner "$1" -vf "
|
|
8
|
+
tblend=all_mode=difference,
|
|
9
|
+
format=gray,
|
|
10
|
+
drawtext=text='%{n}':x=20:y=20:fontcolor=white:fontsize=48
|
|
11
|
+
"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util'
|
|
2
|
+
import { unlink, rename } from 'node:fs/promises'
|
|
3
|
+
|
|
4
|
+
import { glob, uniqueFilenameFor } from './utils/fs-utils.js'
|
|
5
|
+
import { videoAttrs, ffmpeg, assertUserHasFFmpeg } from './utils/ffmpeg.js'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
const USAGE = `
|
|
9
|
+
Usage: npx mediasnacks hev1tohvc1 <videos>
|
|
10
|
+
|
|
11
|
+
This program fixes video thumbnails not rendering in macOS
|
|
12
|
+
Finder, and fixes video not importable in Final Cut Pro. That’s done
|
|
13
|
+
by changing the container’s sample entry code from HEV1 to HVC1.
|
|
14
|
+
`.trim()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async function main() {
|
|
18
|
+
const { positionals } = parseArgs({ allowPositionals: true })
|
|
19
|
+
|
|
20
|
+
if (!positionals.length)
|
|
21
|
+
throw new Error(USAGE)
|
|
22
|
+
|
|
23
|
+
await assertUserHasFFmpeg()
|
|
24
|
+
|
|
25
|
+
console.log('HEV1 to HVC1…')
|
|
26
|
+
for (const g of positionals)
|
|
27
|
+
for (const file of await glob(g))
|
|
28
|
+
await toHvc1(file)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function toHvc1(file) {
|
|
32
|
+
const v = await videoAttrs(file, 'codec_tag_string')
|
|
33
|
+
if (v.codec_tag_string !== 'hev1') {
|
|
34
|
+
console.log('(skipped: non hev1)', file)
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
console.log(file)
|
|
39
|
+
const tmp = uniqueFilenameFor(file)
|
|
40
|
+
await ffmpeg([
|
|
41
|
+
'-i', file,
|
|
42
|
+
'-c', 'copy',
|
|
43
|
+
'-tag:v', 'hvc1',
|
|
44
|
+
tmp
|
|
45
|
+
])
|
|
46
|
+
await unlink(file)
|
|
47
|
+
await rename(tmp, file)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
main().catch(err => {
|
|
52
|
+
console.error(err.message)
|
|
53
|
+
process.exit(1)
|
|
54
|
+
})
|
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import { join } from 'node:path'
|
|
4
1
|
import { parseArgs } from 'node:util'
|
|
5
2
|
import { unlink, rename } from 'node:fs/promises'
|
|
6
3
|
|
|
7
|
-
import { glob,
|
|
8
|
-
import { ffmpeg, assertUserHasFFmpeg } from './ffmpeg.js'
|
|
4
|
+
import { glob, uniqueFilenameFor } from './utils/fs-utils.js'
|
|
5
|
+
import { ffmpeg, assertUserHasFFmpeg } from './utils/ffmpeg.js'
|
|
9
6
|
|
|
10
7
|
|
|
11
8
|
const USAGE = `
|
|
@@ -42,9 +39,7 @@ async function moov2front(file) {
|
|
|
42
39
|
}
|
|
43
40
|
|
|
44
41
|
console.log(file)
|
|
45
|
-
const tmp =
|
|
46
|
-
await makeDirFor(tmp)
|
|
47
|
-
|
|
42
|
+
const tmp = uniqueFilenameFor(file)
|
|
48
43
|
await ffmpeg([
|
|
49
44
|
'-hide_banner',
|
|
50
45
|
'-i', file,
|
package/src/qdir.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { parseArgs } from 'node:util'
|
|
3
|
+
import { resolve, join } from 'node:path'
|
|
4
|
+
import { readdir, writeFile, unlink, rename } from 'node:fs/promises'
|
|
5
|
+
import { isFile } from './utils/fs-utils.js'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
const USAGE = `
|
|
10
|
+
Usage: npx mediasnacks qdir [folder]
|
|
11
|
+
|
|
12
|
+
Sequentially runs all *.sh files in a folder.
|
|
13
|
+
It uses the current working directory by default.
|
|
14
|
+
|
|
15
|
+
-h, --help
|
|
16
|
+
`.trim()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async function main() {
|
|
20
|
+
const { values, positionals } = parseArgs({
|
|
21
|
+
options: {
|
|
22
|
+
help: { short: 'h', type: 'boolean', default: false },
|
|
23
|
+
},
|
|
24
|
+
allowPositionals: true,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
if (values.help) {
|
|
28
|
+
console.log(USAGE)
|
|
29
|
+
process.exit(0)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const dir = positionals[0] || process.cwd()
|
|
33
|
+
const err = await qdir(dir)
|
|
34
|
+
if (err)
|
|
35
|
+
throw new Error(err)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
export async function qdir(dir, pollIntervalMs = 10_000) {
|
|
40
|
+
const lock = join(dir, '.lock')
|
|
41
|
+
|
|
42
|
+
if (isFile(lock))
|
|
43
|
+
return 'Found lockfile'
|
|
44
|
+
|
|
45
|
+
while (true) {
|
|
46
|
+
if (isFile(lock)) {
|
|
47
|
+
await sleep(pollIntervalMs)
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const job = await getNextJob(dir)
|
|
52
|
+
if (!job)
|
|
53
|
+
return null
|
|
54
|
+
|
|
55
|
+
const jobName = job.split('/').pop()
|
|
56
|
+
await writeFile(lock, jobName, 'utf8')
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const exitCode = await runShell(job)
|
|
60
|
+
await rename(job, job + (exitCode === 0
|
|
61
|
+
? '.done'
|
|
62
|
+
: `.failed.${exitCode}`))
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
await unlink(lock).catch(() => {})
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function getNextJob(dir) {
|
|
71
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
72
|
+
const scripts = entries
|
|
73
|
+
.filter(d => d.isFile() && d.name.endsWith('.sh'))
|
|
74
|
+
.map(d => d.name)
|
|
75
|
+
.sort()
|
|
76
|
+
return scripts.length
|
|
77
|
+
? join(dir, scripts[0])
|
|
78
|
+
: null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async function runShell(scriptPath) {
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
const p = spawn('/bin/sh', [scriptPath], { stdio: 'inherit' })
|
|
85
|
+
p.on('error', reject)
|
|
86
|
+
p.on('exit', code => resolve(code))
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function sleep(ms) {
|
|
91
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if (fileURLToPath(import.meta.url) === process.argv[1])
|
|
96
|
+
main().catch(err => {
|
|
97
|
+
console.error(err.message || err)
|
|
98
|
+
process.exit(1)
|
|
99
|
+
})
|
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
1
|
import { join } from 'node:path'
|
|
4
2
|
import { rename } from 'node:fs/promises'
|
|
5
3
|
import { parseArgs } from 'node:util'
|
|
6
4
|
|
|
7
|
-
import { glob, isFile,
|
|
8
|
-
import { ffmpeg, videoAttrs, assertUserHasFFmpeg } from './ffmpeg.js'
|
|
5
|
+
import { glob, isFile, uniqueFilenameFor } from './utils/fs-utils.js'
|
|
6
|
+
import { ffmpeg, videoAttrs, assertUserHasFFmpeg } from './utils/ffmpeg.js'
|
|
9
7
|
|
|
10
8
|
|
|
11
9
|
|
|
@@ -83,10 +81,7 @@ async function resize({ file, outFile, overwrite, width, height }) {
|
|
|
83
81
|
}
|
|
84
82
|
|
|
85
83
|
console.log(file)
|
|
86
|
-
const tmp =
|
|
87
|
-
await makeDirFor(tmp)
|
|
88
|
-
await makeDirFor(outFile)
|
|
89
|
-
|
|
84
|
+
const tmp = uniqueFilenameFor(file)
|
|
90
85
|
await ffmpeg([
|
|
91
86
|
'-i', file,
|
|
92
87
|
'-vf', `scale=${width}:${height}`,
|
package/src/seqcheck.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import { parseArgs } from 'node:util'
|
|
3
|
+
import { readdirSync } from 'node:fs'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
const USAGE = `
|
|
8
|
+
Usage: npx mediasnacks seqcheck [options] [folder]
|
|
9
|
+
|
|
10
|
+
Find missing numbered files in a sequence.
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
-ld, --left-delimiter <str> Delimiter before the number (default: "_")
|
|
14
|
+
-rd, --right-delimiter <str> Delimiter after the number (default: ".")
|
|
15
|
+
-h, --help
|
|
16
|
+
`.trim()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
function main() {
|
|
20
|
+
const { values, positionals } = parseArgs({
|
|
21
|
+
options: {
|
|
22
|
+
'left-delimiter': { type: 'string', default: '_' },
|
|
23
|
+
'right-delimiter': { type: 'string', default: '.' },
|
|
24
|
+
help: { short: 'h', type: 'boolean', default: false },
|
|
25
|
+
},
|
|
26
|
+
allowPositionals: true,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
if (values.help) {
|
|
30
|
+
console.log(USAGE)
|
|
31
|
+
process.exit(0)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const seq = extractSeqNums(
|
|
35
|
+
readdirSync(positionals[0] || process.cwd()),
|
|
36
|
+
values['left-delimiter'],
|
|
37
|
+
values['right-delimiter'])
|
|
38
|
+
|
|
39
|
+
const missing = findMissingNumbers(seq)
|
|
40
|
+
if (missing.length)
|
|
41
|
+
console.log('Missing:', missing)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function extractSeqNums(names, leftDelimiter, rightDelimiter) {
|
|
45
|
+
const pattern = new RegExp(escapeRegex(leftDelimiter) + '(\\d+)' + escapeRegex(rightDelimiter))
|
|
46
|
+
const seq = []
|
|
47
|
+
for (const name of names) {
|
|
48
|
+
const match = name.match(pattern)
|
|
49
|
+
if (match)
|
|
50
|
+
seq.push(Number(match[1]))
|
|
51
|
+
}
|
|
52
|
+
return seq.sort()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function findMissingNumbers(seq) {
|
|
56
|
+
if (seq.length < 2)
|
|
57
|
+
return []
|
|
58
|
+
const missing = []
|
|
59
|
+
for (let i = seq[0]; i <= seq[seq.length - 1]; i++)
|
|
60
|
+
if (!seq.includes(i))
|
|
61
|
+
missing.push(i)
|
|
62
|
+
return missing
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function escapeRegex(str) {
|
|
66
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if (fileURLToPath(import.meta.url) === process.argv[1])
|
|
71
|
+
main()
|
|
@@ -2,13 +2,13 @@ import { spawn } from 'node:child_process'
|
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
export async function ffmpeg(args) {
|
|
5
|
-
return
|
|
5
|
+
return runSilently('ffmpeg', args)
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
export async function assertUserHasFFmpeg() {
|
|
9
9
|
try {
|
|
10
|
-
await
|
|
11
|
-
await
|
|
10
|
+
await runSilently('ffmpeg', ['-version'])
|
|
11
|
+
await runSilently('ffprobe', ['-version'])
|
|
12
12
|
}
|
|
13
13
|
catch {
|
|
14
14
|
throw new Error('ffmpeg not found. Please install ffmpeg.')
|
|
@@ -16,7 +16,7 @@ export async function assertUserHasFFmpeg() {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export async function videoAttrs(v, ...props) {
|
|
19
|
-
const { stdout } = await
|
|
19
|
+
const { stdout } = await runSilently('ffprobe', [
|
|
20
20
|
'-v', 'error',
|
|
21
21
|
'-select_streams', 'v:0',
|
|
22
22
|
'-show_entries', `stream=${props.join(',')}`,
|
|
@@ -27,7 +27,7 @@ export async function videoAttrs(v, ...props) {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
|
|
30
|
-
async function
|
|
30
|
+
async function runSilently(program, args) {
|
|
31
31
|
return new Promise((resolve, reject) => {
|
|
32
32
|
const stdout = []
|
|
33
33
|
const stderr = []
|
|
@@ -49,3 +49,20 @@ async function run(program, args) {
|
|
|
49
49
|
})
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
export async function run(program, args) {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const p = spawn(program, args)
|
|
55
|
+
p.stdout.on('data', data => process.stdout.write(data))
|
|
56
|
+
p.stderr.on('data', chunk => process.stderr.write(chunk))
|
|
57
|
+
|
|
58
|
+
p.on('error', reject)
|
|
59
|
+
p.on('close', code => {
|
|
60
|
+
if (code === 0)
|
|
61
|
+
resolve()
|
|
62
|
+
else
|
|
63
|
+
reject(new Error(`${program} failed with code ${code}`))
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { tmpdir } from 'node:os'
|
|
2
1
|
import { promisify } from 'node:util'
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
3
|
+
import { dirname, extname, join } from 'node:path'
|
|
5
4
|
import { lstatSync, glob as _glob } from 'node:fs'
|
|
6
5
|
|
|
7
6
|
|
|
@@ -10,9 +9,6 @@ export const glob = promisify(_glob)
|
|
|
10
9
|
export const lstat = f => lstatSync(f, { throwIfNoEntry: false })
|
|
11
10
|
export const isFile = path => lstat(path)?.isFile()
|
|
12
11
|
|
|
13
|
-
export const makeDirFor = async file => mkdir(dirname(file), { recursive: true })
|
|
14
|
-
export const makeTempDir = async () => mkdtemp(join(tmpdir(), 'mediasnacks-'))
|
|
15
|
-
|
|
16
12
|
export const replaceExt = (f, ext) => {
|
|
17
13
|
const parts = f.split('.')
|
|
18
14
|
if (parts.length > 1 && parts[0])
|
|
@@ -20,3 +16,6 @@ export const replaceExt = (f, ext) => {
|
|
|
20
16
|
parts.push(ext)
|
|
21
17
|
return parts.join('.')
|
|
22
18
|
}
|
|
19
|
+
|
|
20
|
+
export const uniqueFilenameFor = file =>
|
|
21
|
+
join(dirname(file), randomUUID() + extname(file))
|
package/src/videodiff.sh
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Diffs two video files
|
|
2
|
+
# The videos must have the same resolution and ideally the same framerate.
|
|
3
|
+
video1="$1"
|
|
4
|
+
video2="$2"
|
|
5
|
+
|
|
6
|
+
ffplay -f lavfi "movie=$video1 [a]; movie=$video2 [b]; [a][b] blend=all_mode=difference128"
|
|
7
|
+
|
|
8
|
+
#all_mode=difference: absolute diff (ideal for detecting visual changes)
|
|
9
|
+
#all_mode=subtract: raw subtraction (can go <0, may appear darker)
|
|
10
|
+
#all_mode=difference128: shows neutral gray if identical, differences as dark/light shifts
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
exit 0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
exit 1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
exit 1
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import { deepEqual } from 'node:assert/strict'
|
|
3
|
+
import { extractSeqNums, findMissingNumbers } from '../src/seqcheck.js'
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
test('extractSeqNums extracts sequence numbers from filenames', () => {
|
|
7
|
+
const filenames = [
|
|
8
|
+
'video-111_001.mov',
|
|
9
|
+
'video-111_002.mov',
|
|
10
|
+
'video-111_004.mov',
|
|
11
|
+
'bad.mov',
|
|
12
|
+
'bad_too_a39.mov',
|
|
13
|
+
]
|
|
14
|
+
deepEqual(extractSeqNums(filenames, '_', '.'), [1, 2, 4])
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
test('findMissingNumbers ', () => {
|
|
19
|
+
test('finds gaps in a sequence', () =>
|
|
20
|
+
deepEqual(findMissingNumbers([1, 2, 4, 5, 8]), [3, 6, 7]))
|
|
21
|
+
|
|
22
|
+
test('returns empty array for empty input', () =>
|
|
23
|
+
deepEqual(findMissingNumbers([]), []))
|
|
24
|
+
|
|
25
|
+
test('returns empty array when there are no gaps', () =>
|
|
26
|
+
deepEqual(findMissingNumbers([10, 11, 12]), []))
|
|
27
|
+
})
|
|
File without changes
|