gif-tools
Version:
A robust, zero-dependency TypeScript library for creating GIF files with support for both static and animated GIFs. Built with modern TypeScript features and designed to work in both Node.js and browser environments.
267 lines (266 loc) • 8.93 kB
JavaScript
/**
* CLI utilities — argument parsing, color parsing, and frame processing helpers.
*
* These helpers are intentionally framework-free so the CLI inherits the
* zero-dependency posture of the library itself.
*/
import { GifReader } from '../reader.js';
import { createAnimatedGif, createStaticGif } from '../helpers.js';
/** Error class for user-facing CLI failures (bad flags, missing input, etc). */
export class CliError extends Error {
constructor(message) {
super(message);
this.name = 'CliError';
}
}
/**
* Minimal argv parser supporting:
* --flag → boolean true
* --key value → string
* --key=value → string
* -k value / -k → same, single-char aliases
* anything else → positional
*
* Negative numbers (`-0.5`, `-3`) are treated as values, not as flags, so
* `--brightness -0.3` works as expected.
*/
function looksLikeFlag(arg) {
if (!arg.startsWith('-') || arg === '-')
return false;
// Negative numbers like -0.5 or -3 are values, not flags.
if (/^-\.?\d/.test(arg))
return false;
return true;
}
export function parseArgs(argv) {
const positional = [];
const options = new Map();
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--') {
// Everything after `--` is positional
positional.push(...argv.slice(i + 1));
break;
}
if (arg.startsWith('--')) {
const body = arg.slice(2);
const eq = body.indexOf('=');
if (eq >= 0) {
options.set(body.slice(0, eq), body.slice(eq + 1));
continue;
}
const next = argv[i + 1];
if (next === undefined || looksLikeFlag(next)) {
options.set(body, true);
}
else {
options.set(body, next);
i++;
}
continue;
}
if (arg.startsWith('-') && arg.length > 1) {
const body = arg.slice(1);
const next = argv[i + 1];
if (next === undefined || looksLikeFlag(next)) {
options.set(body, true);
}
else {
options.set(body, next);
i++;
}
continue;
}
positional.push(arg);
}
return { positional, options };
}
function getRaw(args, keys) {
for (const k of keys) {
if (args.options.has(k))
return args.options.get(k);
}
return undefined;
}
export function getString(args, ...keys) {
const v = getRaw(args, keys);
if (v === undefined)
return undefined;
if (typeof v !== 'string') {
throw new CliError(`Option --${keys[0]} requires a value`);
}
return v;
}
export function requireString(args, ...keys) {
const v = getString(args, ...keys);
if (v === undefined)
throw new CliError(`Missing required option --${keys[0]}`);
return v;
}
export function getNumber(args, ...keys) {
const v = getString(args, ...keys);
if (v === undefined)
return undefined;
const n = Number(v);
if (!Number.isFinite(n)) {
throw new CliError(`Option --${keys[0]} must be numeric, got: ${v}`);
}
return n;
}
export function requireNumber(args, ...keys) {
const v = getNumber(args, ...keys);
if (v === undefined)
throw new CliError(`Missing required option --${keys[0]}`);
return v;
}
export function getInt(args, ...keys) {
const v = getNumber(args, ...keys);
if (v === undefined)
return undefined;
if (!Number.isInteger(v)) {
throw new CliError(`Option --${keys[0]} must be an integer, got: ${v}`);
}
return v;
}
export function requireInt(args, ...keys) {
const v = getInt(args, ...keys);
if (v === undefined)
throw new CliError(`Missing required option --${keys[0]}`);
return v;
}
export function getBool(args, ...keys) {
const v = getRaw(args, keys);
if (v === undefined)
return false;
if (typeof v === 'boolean')
return v;
const lower = v.toLowerCase();
if (lower === 'true' || lower === '1' || lower === 'yes')
return true;
if (lower === 'false' || lower === '0' || lower === 'no')
return false;
throw new CliError(`Option --${keys[0]} expects a boolean, got: ${v}`);
}
export function getChoice(args, keys, choices) {
const v = getString(args, ...keys);
if (v === undefined)
return undefined;
if (!choices.includes(v)) {
throw new CliError(`Option --${keys[0]} must be one of: ${choices.join(', ')} (got: ${v})`);
}
return v;
}
/**
* Parses a color string. Accepts:
* - "r,g,b" e.g. "255,0,0"
* - "#rrggbb" e.g. "#ff0000"
* - "#rgb" e.g. "#f00"
*/
export function parseColor(input) {
const trimmed = input.trim();
if (trimmed.startsWith('#')) {
const hex = trimmed.slice(1);
if (!/^[0-9a-fA-F]+$/.test(hex)) {
throw new CliError(`Invalid hex color: ${input}`);
}
if (hex.length === 3) {
return {
red: parseInt(hex[0] + hex[0], 16),
green: parseInt(hex[1] + hex[1], 16),
blue: parseInt(hex[2] + hex[2], 16),
};
}
if (hex.length === 6) {
return {
red: parseInt(hex.slice(0, 2), 16),
green: parseInt(hex.slice(2, 4), 16),
blue: parseInt(hex.slice(4, 6), 16),
};
}
throw new CliError(`Hex color must be #rgb or #rrggbb: ${input}`);
}
const parts = trimmed.split(',').map(s => s.trim());
if (parts.length !== 3) {
throw new CliError(`Color must be "r,g,b" or "#rrggbb", got: ${input}`);
}
const components = parts.map((p, idx) => {
const n = Number(p);
const label = ['red', 'green', 'blue'][idx];
if (!Number.isInteger(n) || n < 0 || n > 255) {
throw new CliError(`Invalid ${label} component "${p}" (must be 0-255)`);
}
return n;
});
return { red: components[0], green: components[1], blue: components[2] };
}
export function getColor(args, ...keys) {
const v = getString(args, ...keys);
return v === undefined ? undefined : parseColor(v);
}
/** Parses "color1;color2;..." (semicolons because commas separate RGB). */
export function getColorList(args, ...keys) {
const v = getString(args, ...keys);
if (v === undefined)
return undefined;
return v.split(';').map(s => parseColor(s));
}
/** Reads a file into a Uint8Array, surfacing a friendly error if it's missing. */
export async function readGifFile(path) {
const fs = await import('fs');
try {
const buf = await fs.promises.readFile(path);
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
}
catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new CliError(`Failed to read ${path}: ${message}`);
}
}
/** Ensures the parent directory of `outputPath` exists before writing. */
export async function ensureParentDir(outputPath) {
const fs = await import('fs');
const path = await import('path');
const dir = path.dirname(outputPath);
if (dir && dir !== '.' && dir !== '/') {
await fs.promises.mkdir(dir, { recursive: true });
}
}
/**
* Reads a GIF, applies a per-frame transform, and writes the result.
*
* For single-frame GIFs the output is a static GIF; for animated GIFs the
* original delays and loop count are preserved.
*/
export async function processGifFrames(inputPath, outputPath, transform, options = {}) {
const data = await readGifFile(inputPath);
const reader = new GifReader(data);
const info = reader.getInfo();
const frames = reader.getFrames();
if (frames.length === 0) {
throw new CliError('Input GIF contains no frames');
}
const transformed = frames.map(f => transform(f.imageData));
let result;
if (transformed.length === 1) {
result = createStaticGif(transformed[0], { maxColors: options.maxColors });
}
else {
result = createAnimatedGif(transformed, {
maxColors: options.maxColors,
loops: info.loops,
imageOptions: frames.map(f => ({ delay: f.delay })),
});
}
await ensureParentDir(outputPath);
await result.saveToFile(outputPath);
return result;
}
/** Formats a byte count for human display ("1.5 KB"). */
export function formatBytes(bytes) {
if (bytes === 0)
return '0 Bytes';
const k = 1024;
const units = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(k)));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${units[i]}`;
}