diff --git a/README.md b/README.md index f877b96..0ecfd3b 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,25 @@ Hance ships with a handful of built-in presets (see `presets/*.hlook`). Pass `-- Run `hance --help` for the full list of effect flags (color, halation, bloom, grain, vignette, split-tone, camera-shake, aberration, etc.). Every effect group also has a `--no-` switch to disable it. +### Config file + +Create a `.hancerc.json` in your project directory to set default flags so you don't have to repeat them: + +```json +{ + "codec": "prores", + "crf": 12, + "preset": "cinematic", + "grain-amount": 0.2 +} +``` + +Keys are the same as CLI flags without the `--` prefix. CLI flags always override config values. + +Hance searches for `.hancerc.json` starting from the current directory and walking up to the filesystem root. If no local config is found, it falls back to `~/.config/hance/config.json` as a global default. + +Use `--no-config` to ignore config files for a single run. + ### Interactive UI ```bash diff --git a/packages/cli/__tests__/cli.test.ts b/packages/cli/__tests__/cli.test.ts index 75df4e6..41bb7e2 100644 --- a/packages/cli/__tests__/cli.test.ts +++ b/packages/cli/__tests__/cli.test.ts @@ -10,64 +10,64 @@ describe("resolveSubcommand", () => { }); describe("parseArgs", () => { - it("parses input file as first positional arg", () => { - const result = parseArgs(["input.mp4"]); + it("parses input file as first positional arg", async () => { + const result = await parseArgs(["input.mp4"]); expect(result.inputs).toEqual(["input.mp4"]); }); - it("parses --output flag", () => { - const result = parseArgs(["input.mp4", "--output", "out.mp4"]); + it("parses --output flag", async () => { + const result = await parseArgs(["input.mp4", "--output", "out.mp4"]); expect(result.outputs).toEqual(["out.mp4"]); }); - it("parses -o shorthand", () => { - const result = parseArgs(["input.mp4", "-o", "out.mp4"]); + it("parses -o shorthand", async () => { + const result = await parseArgs(["input.mp4", "-o", "out.mp4"]); expect(result.outputs).toEqual(["out.mp4"]); }); - it("collects multiple positional args as inputs", () => { - const result = parseArgs(["a.mov", "b.mov", "c.mov"]); + it("collects multiple positional args as inputs", async () => { + const result = await parseArgs(["a.mov", "b.mov", "c.mov"]); expect(result.inputs).toEqual(["a.mov", "b.mov", "c.mov"]); }); - it("defaults outputs to _hanced next to each input when -o omitted", () => { - const result = parseArgs(["a.mov", "b.mov"]); + it("defaults outputs to _hanced next to each input when -o omitted", async () => { + const result = await parseArgs(["a.mov", "b.mov"]); expect(result.outputs).toEqual(["a_hanced.mov", "b_hanced.mov"]); }); - it("treats -o as output directory when multiple inputs given", () => { - const result = parseArgs(["dir/a.mov", "dir/b.mov", "-o", "./out"]); + it("treats -o as output directory when multiple inputs given", async () => { + const result = await parseArgs(["dir/a.mov", "dir/b.mov", "-o", "./out"]); expect(result.outputs).toEqual(["out/a_hanced.mov", "out/b_hanced.mov"]); }); - it("keeps -o as file path with a single input", () => { - const result = parseArgs(["a.mov", "-o", "custom.mp4"]); + it("keeps -o as file path with a single input", async () => { + const result = await parseArgs(["a.mov", "-o", "custom.mp4"]); expect(result.outputs).toEqual(["custom.mp4"]); }); - it("parses color settings flags", () => { - const result = parseArgs(["input.mp4", "--exposure", "0.12", "--contrast", "1.2"]); + it("parses color settings flags", async () => { + const result = await parseArgs(["input.mp4", "--exposure", "0.12", "--contrast", "1.2"]); expect(result.colorSettings.exposure).toBe(0.12); expect(result.colorSettings.contrast).toBe(1.2); }); - it("parses halation flags with new names", () => { - const result = parseArgs(["input.mp4", "--halation-amount", "0.5"]); + it("parses halation flags with new names", async () => { + const result = await parseArgs(["input.mp4", "--halation-amount", "0.5"]); expect(result.halation.amount).toBe(0.5); }); - it("parses --no-halation to disable", () => { - const result = parseArgs(["input.mp4", "--no-halation"]); + it("parses --no-halation to disable", async () => { + const result = await parseArgs(["input.mp4", "--no-halation"]); expect(result.halation.enabled).toBe(false); }); - it("parses --blend for global blend", () => { - const result = parseArgs(["input.mp4", "--blend", "0.5"]); + it("parses --blend for global blend", async () => { + const result = await parseArgs(["input.mp4", "--blend", "0.5"]); expect(result.blend).toBe(0.5); }); - it("parses new effect flags", () => { - const result = parseArgs([ + it("parses new effect flags", async () => { + const result = await parseArgs([ "input.mp4", "--bloom-amount", "0.3", "--grain-amount", "0.2", @@ -78,75 +78,75 @@ describe("parseArgs", () => { expect(result.vignette.amount).toBe(0.4); }); - it("parses --preset to load a named preset", () => { - const result = parseArgs(["input.mp4", "--preset", "portra-400"]); + it("parses --preset to load a named preset", async () => { + const result = await parseArgs(["input.mp4", "--preset", "portra-400"]); expect(result.halation.amount).toBe(0.2); }); - it("CLI flags override preset values", () => { - const result = parseArgs(["input.mp4", "--preset", "portra-400", "--aberration", "0.8"]); + it("CLI flags override preset values", async () => { + const result = await parseArgs(["input.mp4", "--preset", "portra-400", "--aberration", "0.8"]); expect(result.aberration.amount).toBe(0.8); }); - it("parses --encode-preset for FFmpeg speed", () => { - const result = parseArgs(["input.mp4", "--encode-preset", "fast"]); + it("parses --encode-preset for FFmpeg speed", async () => { + const result = await parseArgs(["input.mp4", "--encode-preset", "fast"]); expect(result.encodePreset).toBe("fast"); }); - it("throws on unknown flag", () => { - expect(() => parseArgs(["input.mp4", "--unknown"])).toThrow(); + it("throws on unknown flag", async () => { + expect(parseArgs(["input.mp4", "--unknown"])).rejects.toThrow(); }); - it("throws on out-of-range value", () => { - expect(() => parseArgs(["input.mp4", "--exposure", "10"])).toThrow(); + it("throws on out-of-range value", async () => { + expect(parseArgs(["input.mp4", "--exposure", "10"])).rejects.toThrow(); }); - it("throws with no input", () => { - expect(() => parseArgs([])).toThrow(); + it("throws with no input", async () => { + expect(parseArgs([])).rejects.toThrow(); }); - it("detects --help flag", () => { - const result = parseArgs(["--help"]); + it("detects --help flag", async () => { + const result = await parseArgs(["--help"]); expect(result.help).toBe(true); }); - it("parses --export low", () => { - const result = parseArgs(["input.mp4", "--export", "low"]); + it("parses --export low", async () => { + const result = await parseArgs(["input.mp4", "--export", "low"]); expect(result.codec).toBe("h264"); expect(result.crf).toBe(23); expect(result.encodePreset).toBe("fast"); }); - it("parses --export high", () => { - const result = parseArgs(["input.mp4", "--export", "high"]); + it("parses --export high", async () => { + const result = await parseArgs(["input.mp4", "--export", "high"]); expect(result.codec).toBe("h265"); expect(result.crf).toBe(16); expect(result.encodePreset).toBe("slow"); expect(result.pixelFormat).toBe("yuv420p10le"); }); - it("parses --export medium and returns correct pixelFormat", () => { - const result = parseArgs(["input.mp4", "--export", "medium"]); + it("parses --export medium and returns correct pixelFormat", async () => { + const result = await parseArgs(["input.mp4", "--export", "medium"]); expect(result.codec).toBe("h264"); expect(result.crf).toBe(18); expect(result.encodePreset).toBe("medium"); expect(result.pixelFormat).toBe("yuv420p"); }); - it("--export with individual override: codec wins", () => { - const result = parseArgs(["input.mp4", "--export", "high", "--codec", "h264"]); + it("--export with individual override: codec wins", async () => { + const result = await parseArgs(["input.mp4", "--export", "high", "--codec", "h264"]); expect(result.codec).toBe("h264"); expect(result.crf).toBe(16); expect(result.encodePreset).toBe("slow"); }); - it("--export with individual override: crf wins", () => { - const result = parseArgs(["input.mp4", "--export", "high", "--crf", "20"]); + it("--export with individual override: crf wins", async () => { + const result = await parseArgs(["input.mp4", "--export", "high", "--crf", "20"]); expect(result.crf).toBe(20); }); - it("throws on invalid --export value", () => { - expect(() => parseArgs(["input.mp4", "--export", "ultra"])).toThrow(); + it("throws on invalid --export value", async () => { + expect(parseArgs(["input.mp4", "--export", "ultra"])).rejects.toThrow(); }); }); diff --git a/packages/cli/__tests__/config.test.ts b/packages/cli/__tests__/config.test.ts new file mode 100644 index 0000000..a0833a2 --- /dev/null +++ b/packages/cli/__tests__/config.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { configToArgv, loadConfig, findLocalConfig } from "../src/config"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { tmpdir } from "node:os"; + +describe("configToArgv", () => { + test("converts string values to flag pairs", () => { + expect(configToArgv({ codec: "prores", preset: "cinematic" })).toEqual([ + "--codec", "prores", + "--preset", "cinematic", + ]); + }); + + test("converts numeric values to flag pairs", () => { + expect(configToArgv({ crf: 22, blend: 0.8 })).toEqual([ + "--crf", "22", + "--blend", "0.8", + ]); + }); + + test("converts boolean true to standalone flag", () => { + expect(configToArgv({ "no-grain": true })).toEqual(["--no-grain"]); + }); + + test("skips false and undefined values", () => { + expect(configToArgv({ "no-grain": false })).toEqual([]); + }); + + test("handles empty config", () => { + expect(configToArgv({})).toEqual([]); + }); +}); + +describe("findLocalConfig", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = path.join(tmpdir(), `hance-test-${Date.now()}`); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("finds config in the given directory", () => { + const configPath = path.join(tempDir, ".hancerc.json"); + writeFileSync(configPath, '{"codec": "prores"}'); + expect(findLocalConfig(tempDir)).toBe(configPath); + }); + + test("walks up to find config in parent directory", () => { + const child = path.join(tempDir, "sub", "deep"); + mkdirSync(child, { recursive: true }); + const configPath = path.join(tempDir, ".hancerc.json"); + writeFileSync(configPath, '{"codec": "prores"}'); + expect(findLocalConfig(child)).toBe(configPath); + }); + + test("returns null when no config exists", () => { + expect(findLocalConfig(tempDir)).toBeNull(); + }); +}); + +describe("loadConfig", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = path.join(tmpdir(), `hance-test-${Date.now()}`); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + test("loads config from directory", async () => { + writeFileSync(path.join(tempDir, ".hancerc.json"), '{"codec": "prores", "crf": 12}'); + const { config, source } = await loadConfig(tempDir); + expect(config.codec).toBe("prores"); + expect(config.crf).toBe(12); + expect(source).toBe(path.join(tempDir, ".hancerc.json")); + }); + + test("returns empty config when no file exists", async () => { + const { config, source } = await loadConfig(tempDir); + expect(config).toEqual({}); + expect(source).toBeNull(); + }); + + test("warns and skips invalid JSON", async () => { + writeFileSync(path.join(tempDir, ".hancerc.json"), "not json"); + const { config, source } = await loadConfig(tempDir); + expect(config).toEqual({}); + expect(source).toBeNull(); + }); + + test("warns and skips non-object JSON (array)", async () => { + writeFileSync(path.join(tempDir, ".hancerc.json"), "[1, 2, 3]"); + const { config, source } = await loadConfig(tempDir); + expect(config).toEqual({}); + expect(source).toBeNull(); + }); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index df66fa8..6c73fd7 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -3,6 +3,7 @@ import { probe, applyPreset, resolveExportPreset } from "@hance/core"; import type { PresetData, FilmOptions } from "@hance/core"; import { runGpuExport } from "./pipeline"; import { parseEffectFlags, EFFECT_HELP_TEXT } from "./effect-flags"; +import { loadConfig, configToArgv } from "./config"; import path from "node:path"; declare const HANCE_VERSION: string | undefined; @@ -25,6 +26,9 @@ hance [ ...] [options] ${EFFECT_HELP_TEXT} + Config: + --no-config Ignore config file + General: --help, -h Show this help --version, -v Print version and exit @@ -79,8 +83,20 @@ interface ParsedArgs extends FilmOptions { outputArg: string; } -export function parseArgs(argv: string[]): ParsedArgs { - const r = parseEffectFlags(argv); +export async function parseArgs(argv: string[]): Promise { + const noConfig = argv.includes("--no-config"); + const filteredArgv = noConfig ? argv.filter(a => a !== "--no-config") : argv; + + let mergedArgv: string[]; + if (noConfig) { + mergedArgv = filteredArgv; + } else { + const { config } = await loadConfig(); + const configArgv = configToArgv(config); + mergedArgv = [...configArgv, ...filteredArgv]; + } + + const r = parseEffectFlags(mergedArgv); const inputs = r.positional; if (!r.help && inputs.length === 0) { @@ -174,7 +190,7 @@ async function main() { let parsed: ParsedArgs; try { - parsed = parseArgs(args); + parsed = await parseArgs(args); } catch (err) { console.error(`Error: ${(err as Error).message}`); process.exit(1); diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts new file mode 100644 index 0000000..ea83eba --- /dev/null +++ b/packages/cli/src/config.ts @@ -0,0 +1,54 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import { homedir } from "node:os"; + +const LOCAL_CONFIG_NAME = ".hancerc.json"; +const GLOBAL_CONFIG_PATH = path.join(homedir(), ".config", "hance", "config.json"); + +export function findLocalConfig(startDir: string): string | null { + let dir = startDir; + while (true) { + const candidate = path.join(dir, LOCAL_CONFIG_NAME); + if (existsSync(candidate)) return candidate; + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +export interface HanceConfig { + [key: string]: string | number | boolean; +} + +export async function loadConfig(startDir = process.cwd()): Promise<{ config: HanceConfig; source: string | null }> { + const localPath = findLocalConfig(startDir); + + for (const configPath of [localPath, GLOBAL_CONFIG_PATH]) { + if (configPath && existsSync(configPath)) { + try { + const raw = await Bun.file(configPath).json(); + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + console.warn(`Warning: ignoring invalid config at ${configPath}`); + continue; + } + return { config: raw as HanceConfig, source: configPath }; + } catch { + console.warn(`Warning: failed to parse config at ${configPath}`); + } + } + } + + return { config: {}, source: null }; +} + +export function configToArgv(config: HanceConfig): string[] { + const argv: string[] = []; + for (const [key, value] of Object.entries(config)) { + if (value === true) { + argv.push(`--${key}`); + } else if (value !== false && value !== undefined) { + argv.push(`--${key}`, String(value)); + } + } + return argv; +} diff --git a/packages/cli/src/effect-flags.ts b/packages/cli/src/effect-flags.ts index 6045ad3..eb2e63c 100644 --- a/packages/cli/src/effect-flags.ts +++ b/packages/cli/src/effect-flags.ts @@ -71,12 +71,14 @@ const KNOWN_FLAGS = new Set([ "--split-tone-mode", "--split-tone-protect-neutrals", "--split-tone-amount", "--split-tone-hue", "--split-tone-pivot", "--no-split-tone", "--camera-shake-amount", "--camera-shake-rate", "--no-camera-shake", + "--no-config", "--help", "-h", ]); const BOOLEAN_FLAGS = new Set([ "--no-color-settings", "--no-halation", "--no-aberration", "--no-bloom", "--no-grain", "--no-vignette", "--no-split-tone", "--no-camera-shake", + "--no-config", "--halation-highlights-only", "--split-tone-protect-neutrals", ]);