Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<effect>` 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
Expand Down
100 changes: 50 additions & 50 deletions packages/cli/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <stem>_hanced<ext> next to each input when -o omitted", () => {
const result = parseArgs(["a.mov", "b.mov"]);
it("defaults outputs to <stem>_hanced<ext> 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",
Expand All @@ -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();
});
});

Expand Down
105 changes: 105 additions & 0 deletions packages/cli/__tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
22 changes: 19 additions & 3 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,6 +26,9 @@ hance <input> [<input> ...] [options]

${EFFECT_HELP_TEXT}

Config:
--no-config Ignore config file

General:
--help, -h Show this help
--version, -v Print version and exit
Expand Down Expand Up @@ -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<ParsedArgs> {
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) {
Expand Down Expand Up @@ -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);
Expand Down
Loading