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.
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "mediasnacks",
3
- "version": "0.0.1",
3
+ "version": "0.1.1",
4
4
  "description": "Utilities for preparing videos, images, and audio for the web",
5
5
  "license": "MIT",
6
6
  "author": "Eric Fortis",
@@ -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, 'cli-avif.js'),
10
- resize: join(import.meta.dirname, 'cli-resize.js'),
11
- moov2front: join(import.meta.dirname, 'cli-moov2front.js')
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
- spawn(process.execPath, [COMMANDS[opt], ...args], { stdio: 'inherit' })
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))
@@ -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
+ })
@@ -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, makeTempDir, makeDirFor } from './fs-utils.js'
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 = join(await makeTempDir(), file) // FFmpeg can’t edit in-place
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, makeTempDir, makeDirFor } from './fs-utils.js'
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 = join(await makeTempDir(), file) // FFmpeg can’t edit in-place
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}`,
@@ -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 run('ffmpeg', args)
5
+ return runSilently('ffmpeg', args)
6
6
  }
7
7
 
8
8
  export async function assertUserHasFFmpeg() {
9
9
  try {
10
- await run('ffmpeg', ['-version'])
11
- await run('ffprobe', ['-version'])
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 run('ffprobe', [
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 run(program, args) {
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 { join, dirname } from 'node:path'
4
- import { mkdtemp, mkdir } from 'node:fs/promises'
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))
@@ -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,2 @@
1
+ sleep 0.5
2
+ exit 0
@@ -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
+ })