mediasnacks 0.0.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/.editorconfig ADDED
@@ -0,0 +1,11 @@
1
+ root = true
2
+
3
+ [*]
4
+ charset = utf-8
5
+ end_of_line = lf
6
+ indent_size = 2
7
+ indent_style = tab
8
+ insert_final_newline = true
9
+
10
+ [*.md]
11
+ indent_style = space
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Eric Fortis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # mediasnacks
2
+
3
+ Utilities optimizing and preparing video and images for web.
4
+
5
+
6
+ ## Usage Overview
7
+ **FFmpeg and Node.js must be installed.**
8
+
9
+ ```shell
10
+ npx mediasnacks <command> <args>
11
+ ```
12
+
13
+ Commands:
14
+ - `avif` Converts images to AVIF
15
+ - `resize` Resizes videos or images
16
+ - `moov2front` Rearranges .mov and .mp4 metadata for fast-start streaming
17
+
18
+ <br/>
19
+
20
+ ### Converting Images to AVIF
21
+ ```shell
22
+ npx mediasnacks avif [-y | --overwrite] [--output-dir=<dir>] <images>
23
+ ```
24
+
25
+ <br/>
26
+
27
+ ### Resizing Images or Videos
28
+ Resizes videos and images. The aspect ratio is preserved when only one dimension is specified.
29
+
30
+ `--width` and `--height` are `-2` by default:
31
+ - `-1` auto-compute while preserving the aspect ratio (may result in an odd number)
32
+ - `-2` same as `-1` but rounds to the nearest even number
33
+
34
+ ```shell
35
+ npx mediasnacks resize [--width=<num>] [--height=<num>] [-y | --overwrite] [--output-dir=<dir>] <files>
36
+ ```
37
+
38
+ Example: Overwrites the input file (-y)
39
+ ```shell
40
+ npx mediasnacks resize -y --width 480 'dir-a/**/*.png' 'dir-b/**/*.mp4'
41
+ ```
42
+
43
+ Example: Output directory (-o)
44
+ ```shell
45
+ npx mediasnacks resize --height 240 --output-dir /tmp/out video.mov
46
+ ```
47
+
48
+ <br/>
49
+
50
+ ### Fast-Start Streaming Video
51
+ Rearranges .mov and .mp4 metadata to the start of the file for fast-start streaming.
52
+
53
+ **Files are overwritten**
54
+
55
+ ```shell
56
+ npx mediasnacks moov2front <videos>
57
+ ```
58
+ hat is Fast Start?
59
+ - https://wiki.avblocks.com/avblocks-for-cpp/muxer-parameters/mp4
60
+ - https://trac.ffmpeg.org/wiki/HowToCheckIfFaststartIsEnabledForPlayback
61
+
62
+
63
+ <br/>
64
+
65
+ ### License
66
+ MIT
package/TODO.md ADDED
@@ -0,0 +1,4 @@
1
+ # TODO
2
+
3
+ - Test on Windows
4
+ - Test on Linux
package/package.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "mediasnacks",
3
+ "version": "0.0.1",
4
+ "description": "Utilities for preparing videos, images, and audio for the web",
5
+ "license": "MIT",
6
+ "author": "Eric Fortis",
7
+ "type": "module",
8
+ "bin": {
9
+ "mediasnacks": "src/cli.js"
10
+ },
11
+ "scripts": {
12
+ "test": "node --test"
13
+ }
14
+ }
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { join } from 'node:path'
4
+ import { parseArgs } from 'node:util'
5
+
6
+ import { glob, replaceExt, lstat } from './fs-utils.js'
7
+ import { ffmpeg, assertUserHasFFmpeg } from './ffmpeg.js'
8
+
9
+
10
+ const USAGE = `
11
+ Usage: npx mediasnacks avif [-y | --overwrite] [--output-dir=<dir>] <images>
12
+
13
+ Converts images to AVIF.
14
+ `.trim()
15
+
16
+
17
+ async function main() {
18
+ const { values, positionals } = parseArgs({
19
+ options: {
20
+ 'output-dir': { type: 'string', default: '' },
21
+ overwrite: { short: 'y', type: 'boolean', default: false },
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
+ if (!positionals.length)
33
+ throw new Error('No images specified. See npx mediasnacks avif --help')
34
+
35
+ await assertUserHasFFmpeg()
36
+
37
+ console.log('AVIF…')
38
+ for (const g of positionals)
39
+ for (const file of await glob(g))
40
+ await toAvif({
41
+ file,
42
+ outFile: join(values['output-dir'], replaceExt(file, 'avif')),
43
+ overwrite: values.overwrite
44
+ })
45
+ }
46
+
47
+ async function toAvif({ file, outFile, overwrite }) {
48
+ const stImg = lstat(file)
49
+ const stAvif = lstat(outFile)
50
+
51
+ if (!overwrite && stAvif?.isFile()) {
52
+ console.log('(skipped: output file exists but --overwrite=false)', file)
53
+ return
54
+ }
55
+ if (stAvif?.mtimeMs > stImg?.mtimeMs) {
56
+ console.log('(skipped: avif is newer)', file)
57
+ return
58
+ }
59
+
60
+ // TODO test on linux
61
+ console.log(file)
62
+ await ffmpeg([
63
+ '-y', // overwrites
64
+ '-i', file,
65
+ '-c:v', 'libsvtav1',
66
+ '-svtav1-params', 'avif=1',
67
+ outFile
68
+ ])
69
+ }
70
+
71
+ main().catch(err => {
72
+ console.error(err.message)
73
+ process.exit(1)
74
+ })
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { join } from 'node:path'
4
+ import { parseArgs } from 'node:util'
5
+ import { unlink, rename } from 'node:fs/promises'
6
+
7
+ import { glob, makeTempDir, makeDirFor } from './fs-utils.js'
8
+ import { ffmpeg, assertUserHasFFmpeg } from './ffmpeg.js'
9
+
10
+
11
+ const USAGE = `
12
+ Usage: npx mediasnacks moov2front <videos>
13
+
14
+ Rearranges .mov and .mp4 metadata to the start of the file for fast-start streaming.
15
+
16
+ Files are overwritten.
17
+ `.trim()
18
+
19
+ async function main() {
20
+ const { positionals } = parseArgs({ allowPositionals: true })
21
+
22
+ if (!positionals.length)
23
+ throw new Error(USAGE)
24
+
25
+ await assertUserHasFFmpeg()
26
+ console.log('Optimizing videos for progressive download…')
27
+
28
+ for (const g of positionals)
29
+ for (const file of await glob(g))
30
+ await moov2front(file)
31
+ }
32
+
33
+
34
+ async function moov2front(file) {
35
+ if (!/\.(mp4|mov)$/i.test(file)) {
36
+ console.log('(skipped: not mp4/mov)', file)
37
+ return
38
+ }
39
+ if (await moovIsBeforeMdat(file)) {
40
+ console.log('(skipped: no changes needed)', file)
41
+ return
42
+ }
43
+
44
+ console.log(file)
45
+ const tmp = join(await makeTempDir(), file) // FFmpeg can’t edit in-place
46
+ await makeDirFor(tmp)
47
+
48
+ await ffmpeg([
49
+ '-hide_banner',
50
+ '-i', file,
51
+ '-movflags', '+faststart',
52
+ tmp
53
+ ])
54
+ await unlink(file)
55
+ await rename(tmp, file)
56
+ }
57
+
58
+ async function moovIsBeforeMdat(file) {
59
+ const { stderr } = await ffmpeg([
60
+ '-hide_banner',
61
+ '-v', 'trace',
62
+ '-i', file,
63
+ '-f', 'null', '-'
64
+ ])
65
+ const firstMatchedAtom = stderr.match(/type:'(moov|mdat)'/)?.[1]
66
+ return firstMatchedAtom === 'moov'
67
+ }
68
+
69
+
70
+ main().catch(err => {
71
+ console.error(err.message)
72
+ process.exit(1)
73
+ })
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { join } from 'node:path'
4
+ import { rename } from 'node:fs/promises'
5
+ import { parseArgs } from 'node:util'
6
+
7
+ import { glob, isFile, makeTempDir, makeDirFor } from './fs-utils.js'
8
+ import { ffmpeg, videoAttrs, assertUserHasFFmpeg } from './ffmpeg.js'
9
+
10
+
11
+
12
+ const USAGE = `
13
+ Usage: npx mediasnacks resize [--width=<num>] [--height=<num>] [-y | --overwrite] [--output-dir=<dir>] <files>
14
+
15
+ Resizes videos and images. The aspect ratio is preserved when only one dimension is specified.
16
+
17
+ Example: Overwrites the input file (-y)
18
+ npx mediasnacks resize -y --width 480 'dir-a/**/*.png' 'dir-b/**/*.mp4'
19
+
20
+ Example: Output directory (-o)
21
+ npx mediasnacks resize --height 240 --output-dir /tmp/out video.mov
22
+
23
+ Details:
24
+ --width and --height are -2 by default:
25
+ -1 = auto-compute while preserving the aspect ratio (may result in an odd number)
26
+ -2 = same as -1 but rounds to the nearest even number
27
+ `.trim()
28
+
29
+
30
+ async function main() {
31
+ const { values, positionals } = parseArgs({
32
+ options: {
33
+ width: { type: 'string', default: '-2' },
34
+ height: { type: 'string', default: '-2' },
35
+ 'output-dir': { type: 'string', default: '' },
36
+ overwrite: { short: 'y', type: 'boolean', default: false },
37
+ help: { short: 'h', type: 'boolean', default: false },
38
+ },
39
+ allowPositionals: true
40
+ })
41
+
42
+ if (values.help) {
43
+ console.log(USAGE)
44
+ process.exit(0)
45
+ }
46
+
47
+ const width = Number(values.width)
48
+ const height = Number(values.height)
49
+
50
+ if (width <= 0 && height <= 0)
51
+ throw new Error('--width or --height need to be greater than 0')
52
+
53
+ if (!positionals.length)
54
+ throw new Error('No video files specified')
55
+
56
+ await assertUserHasFFmpeg()
57
+
58
+ console.log('Resizing…')
59
+ for (const g of positionals)
60
+ for (const file of await glob(g))
61
+ await resize({
62
+ file,
63
+ outFile: join(values['output-dir'], file),
64
+ overwrite: values.overwrite,
65
+ width,
66
+ height,
67
+ })
68
+ }
69
+
70
+
71
+ async function resize({ file, outFile, overwrite, width, height }) {
72
+ const v = await videoAttrs(file, 'width', 'height')
73
+ if (width === v.width && height === v.height
74
+ || width < 0 && height === v.height
75
+ || height < 0 && width === v.width) {
76
+ console.log('(skipped: no changes needed)', file)
77
+ return
78
+ }
79
+
80
+ if (!overwrite && isFile(outFile)) {
81
+ console.log('(skipped: output file exists but --overwrite=false)', file)
82
+ return
83
+ }
84
+
85
+ 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
+
90
+ await ffmpeg([
91
+ '-i', file,
92
+ '-vf', `scale=${width}:${height}`,
93
+ '-movflags', '+faststart',
94
+ tmp
95
+ ])
96
+ await rename(tmp, outFile)
97
+ }
98
+
99
+
100
+ main().catch(err => {
101
+ console.error(err.message)
102
+ process.exit(1)
103
+ })
package/src/cli.js ADDED
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { join } from 'node:path'
4
+ import { spawn } from 'node:child_process'
5
+ import pkgJSON from '../package.json' with { type: 'json' }
6
+
7
+
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')
12
+ }
13
+
14
+ const USAGE = `
15
+ Usage: npx mediasnacks <command> <args>
16
+
17
+ Commands:
18
+ avif: Converts images to AVIF
19
+ resize: Resizes videos or images
20
+ moov2front: Rearranges .mov and .mp4 metadata for fast-start streaming
21
+ `.trim()
22
+
23
+
24
+ const [, , opt, ...args] = process.argv
25
+
26
+ if (opt === '-v' || opt === '--version') {
27
+ console.log(pkgJSON.version)
28
+ process.exit(0)
29
+ }
30
+
31
+ if (opt === '-h' || opt === '--help') {
32
+ console.log(USAGE)
33
+ process.exit(0)
34
+ }
35
+
36
+ if (!opt) {
37
+ console.log(USAGE)
38
+ process.exit(1)
39
+ }
40
+
41
+ if (!Object.hasOwn(COMMANDS, opt)) {
42
+ console.error(`'${opt}' is not a mediasnacks command. See npx mediasnacks --help\n`)
43
+ process.exit(1)
44
+ }
45
+
46
+ spawn(process.execPath, [COMMANDS[opt], ...args], { stdio: 'inherit' })
47
+ .on('exit', code => process.exit(code))
package/src/ffmpeg.js ADDED
@@ -0,0 +1,51 @@
1
+ import { spawn } from 'node:child_process'
2
+
3
+
4
+ export async function ffmpeg(args) {
5
+ return run('ffmpeg', args)
6
+ }
7
+
8
+ export async function assertUserHasFFmpeg() {
9
+ try {
10
+ await run('ffmpeg', ['-version'])
11
+ await run('ffprobe', ['-version'])
12
+ }
13
+ catch {
14
+ throw new Error('ffmpeg not found. Please install ffmpeg.')
15
+ }
16
+ }
17
+
18
+ export async function videoAttrs(v, ...props) {
19
+ const { stdout } = await run('ffprobe', [
20
+ '-v', 'error',
21
+ '-select_streams', 'v:0',
22
+ '-show_entries', `stream=${props.join(',')}`,
23
+ '-of', 'json',
24
+ v
25
+ ])
26
+ return JSON.parse(stdout).streams[0]
27
+ }
28
+
29
+
30
+ async function run(program, args) {
31
+ return new Promise((resolve, reject) => {
32
+ const stdout = []
33
+ const stderr = []
34
+
35
+ const p = spawn(program, args)
36
+ p.stdout.on('data', chunk => { stdout.push(chunk) })
37
+ p.stderr.on('data', chunk => { stderr.push(chunk) })
38
+
39
+ p.on('error', reject)
40
+ p.on('close', code => {
41
+ if (code === 0)
42
+ resolve({
43
+ stdout: Buffer.concat(stdout).toString(),
44
+ stderr: Buffer.concat(stderr).toString(),
45
+ })
46
+ else
47
+ reject(new Error(`${program} failed with code ${code}\n${Buffer.concat(stderr).toString()}`))
48
+ })
49
+ })
50
+ }
51
+
@@ -0,0 +1,22 @@
1
+ import { tmpdir } from 'node:os'
2
+ import { promisify } from 'node:util'
3
+ import { join, dirname } from 'node:path'
4
+ import { mkdtemp, mkdir } from 'node:fs/promises'
5
+ import { lstatSync, glob as _glob } from 'node:fs'
6
+
7
+
8
+ export const glob = promisify(_glob)
9
+
10
+ export const lstat = f => lstatSync(f, { throwIfNoEntry: false })
11
+ export const isFile = path => lstat(path)?.isFile()
12
+
13
+ export const makeDirFor = async file => mkdir(dirname(file), { recursive: true })
14
+ export const makeTempDir = async () => mkdtemp(join(tmpdir(), 'mediasnacks-'))
15
+
16
+ export const replaceExt = (f, ext) => {
17
+ const parts = f.split('.')
18
+ if (parts.length > 1 && parts[0])
19
+ parts.pop()
20
+ parts.push(ext)
21
+ return parts.join('.')
22
+ }
@@ -0,0 +1,21 @@
1
+ import { equal } from 'node:assert/strict'
2
+ import test, { describe } from 'node:test'
3
+ import { replaceExt } from './fs-utils.js'
4
+
5
+
6
+ describe('replaceExt', () => {
7
+ test('replaces a simple extension', () =>
8
+ equal(replaceExt('file.txt', 'md'), 'file.md'))
9
+
10
+ test('replaces a multi-part extension', () =>
11
+ equal(replaceExt('archive.tar.gz', 'zip'), 'archive.tar.zip'))
12
+
13
+ test('adds extension when none exists', () =>
14
+ equal(replaceExt('README', 'md'), 'README.md'))
15
+
16
+ test('handles empty filename', () =>
17
+ equal(replaceExt('', 'ext'), '.ext'))
18
+
19
+ test('handles dot-files', () =>
20
+ equal(replaceExt('.env', 'txt'), '.env.txt'))
21
+ })