teqo-backup/scripts/lib/imageResize.mjs
2026-08-16 00:49:24 -03:00

182 lines
6.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Pure derivation for `scripts/resize-images.mjs` — argument parsing,
* format/quality policy and output-name derivation, unit-tested like the
* rest of `scripts/lib/`. The CLI itself only threads `sharp` through these
* decisions, so nothing here imports sharp or touches the filesystem.
*/
import { parse } from 'node:path'
/** 2× the reference desktop viewport (1920px) — retina-grade site assets. */
const MAX_WEB_WIDTH = 3840
/**
* Max width per format: AVIF serves the site at retina grade (2× desktop),
* while WebP/JPEG are the draft/compatibility companions (PenPot, email,
* older browsers) and can stay at 1× — a quarter of the pixels, so the
* design tool never has to chew on multi-MB files.
*/
export const FORMAT_MAX_WIDTHS = { avif: MAX_WEB_WIDTH, webp: 1920, jpeg: 1920 }
/** Width to use for a format — an explicit `--max-width` wins over the per-format default. */
export const maxWidthFor = (format, override) =>
override ?? FORMAT_MAX_WIDTHS[format] ?? MAX_WEB_WIDTH
/** Output formats sharp can encode for the browser, in order of preference. */
const SUPPORTED_FORMATS = ['avif', 'webp', 'jpeg']
/**
* The default output set: AVIF for the site (lightest modern format) plus
* WebP as the broadly accepted companion — design tools such as PenPot and
* older browsers do not open AVIF.
*/
const DEFAULT_FORMATS = ['avif', 'webp']
/** Extension per normalized format (jpeg keeps the compact `.jpg`). */
const FORMAT_EXTENSIONS = { avif: '.avif', webp: '.webp', jpeg: '.jpg' }
/** Sharp quality defaults per format (AVIF compresses far better than WebP). */
const FORMAT_QUALITY_DEFAULTS = { avif: 60, webp: 75, jpeg: 82 }
/** Accepted input extensions when a positional argument is a directory. */
export const INPUT_EXTENSIONS = new Set([
'.jpg',
'.jpeg',
'.png',
'.webp',
'.avif',
'.tif',
'.tiff',
'.gif',
'.heic',
'.bmp',
])
/**
* Normalizes a `--format` value (aliases + leading dot, case-insensitive).
* Returns `null` when the value is not encodable.
*
* @param {string} value
* @returns {'avif' | 'webp' | 'jpeg' | null}
*/
export const normalizeImageFormat = (value) => {
const normalized = value.trim().toLowerCase().replace(/^\./, '')
const alias = { jpg: 'jpeg', jpe: 'jpeg', jfif: 'jpeg' }[normalized]
const format = alias ?? normalized
return SUPPORTED_FORMATS.includes(format) ? format : null
}
/** @param {'avif' | 'webp' | 'jpeg'} format */
export const defaultQualityFor = (format) => FORMAT_QUALITY_DEFAULTS[format] ?? 60
/**
* The web asset keeps the source basename, only the extension changes
* (`foto.jpg` → `foto.avif`), so re-running with another format never
* collides with the source file.
*
* @param {string} inputPath
* @param {'avif' | 'webp' | 'jpeg'} format
*/
export const deriveOutputFileName = (inputPath, format) =>
`${parse(inputPath).name}${FORMAT_EXTENSIONS[format]}`
const FLAGS = new Set(['--max-width', '--quality', '--format', '--out'])
const BOOLEAN_FLAGS = new Set(['--remove-bg'])
const HELP_FLAGS = new Set(['--help', '-h'])
/**
* Parses `--format` into a deduped, order-preserving list. Accepts a
* comma-separated set (`avif,webp`) and the singular spellings (`jpg`).
* Returns `null` when every item is missing or any item is unknown.
*
* @param {string} value
* @returns {('avif' | 'webp' | 'jpeg')[] | null}
*/
const parseFormatList = (value) => {
const formats = value
.split(',')
.map((part) => part.trim())
.filter((part) => part !== '')
.map(normalizeImageFormat)
if (formats.length === 0 || formats.some((format) => format === null)) {
return null
}
return [...new Set(formats)]
}
/**
* Parses CLI argv into options. Throws with a human-readable message on any
* unknown flag or invalid value (fail closed — a typo must not silently
* fall back to a default). `quality` and `maxWidth` stay undefined when
* absent so each format applies its own default at conversion time.
*
* @param {string[]} argv - `process.argv.slice(2)`
* @returns {{ paths: string[], maxWidth: number | undefined, quality: number | undefined, formats: ('avif' | 'webp' | 'jpeg')[], outDir: string, removeBg: boolean, help: boolean }}
*/
export const parseResizeArgs = (argv) => {
const options = {
help: false,
paths: [],
maxWidth: undefined,
quality: undefined,
formats: [...DEFAULT_FORMATS],
outDir: 'public',
removeBg: false,
}
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]
if (HELP_FLAGS.has(arg)) {
options.help = true
continue
}
if (BOOLEAN_FLAGS.has(arg)) {
options.removeBg = true
continue
}
const [flag, inlineValue] = arg.startsWith('--') ? arg.split('=') : [null, null]
if (flag !== null && FLAGS.has(flag)) {
const value = inlineValue ?? argv[i + 1]
if (inlineValue === undefined) i += 1
if (value === undefined || value === '' || value.startsWith('--')) {
throw new Error(`flag ${flag} espera um valor (ex.: ${flag}=3840 ou ${flag} 3840)`)
}
if (flag === '--max-width') {
const width = Number(value)
if (!Number.isInteger(width) || width < 1) {
throw new Error(`--max-width inválido: ${JSON.stringify(value)} (esperado inteiro > 0)`)
}
options.maxWidth = width
} else if (flag === '--quality') {
const quality = Number(value)
if (!Number.isInteger(quality) || quality < 1 || quality > 100) {
throw new Error(`--quality inválido: ${JSON.stringify(value)} (esperado 1..100)`)
}
options.quality = quality
} else if (flag === '--format') {
const formats = parseFormatList(value)
if (formats === null) {
throw new Error(
`--format inválido: ${JSON.stringify(value)} (esperado ${SUPPORTED_FORMATS.join('|')}, lista separada por vírgula)`,
)
}
options.formats = formats
} else {
options.outDir = value
}
continue
}
if (flag !== null && !HELP_FLAGS.has(arg)) {
throw new Error(`flag desconhecida: ${JSON.stringify(arg)} (veja --help)`)
}
options.paths.push(arg)
}
if (!options.help && options.paths.length === 0) {
throw new Error('nenhum caminho informado — passe arquivos de imagem ou pastas (veja --help)')
}
return options
}