Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ packages/ui/dist/
# npm platform package binaries (populated by CI)
npm/@orva-studio/*/bin/

# Generated preset index (rebuild with: bun run scripts/build-preset-index.ts)
/presets/index.json

# Worktrees
.worktrees/

Expand Down
34 changes: 30 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,24 @@

> ⚠️ **Alpha software.** Hance is early-stage and has mainly been tested on macOS by a single developer. Expect rough edges on Linux/Windows, and pin versions if you use it in anything important.

**A cinematic film-look engine for video and stills.** Dial in a look in the browser UI, then apply it headlessly across a whole folder from the CLI — GPU-accelerated colour, halation, bloom, grain, vignette, split-tone, aberration, and camera shake in a single pass. One binary, no plugins, no subscriptions.
**Preview a cinematic film look in the browser, then batch-apply it from the CLI.** GPU-accelerated colour, halation, bloom, grain, vignette, split-tone, aberration, and camera shake — one binary, no plugins, no subscriptions.

### Who is this for?

- **Creators whose editors don't support LUTs** — CapCut, ScreenFlow, iMovie, browser-based editors, and mobile NLEs have no LUT pipeline. Hance gives you cinematic film looks that weren't possible before. Process your clips before you import them — done.
- **Creators who want more than a LUT** — halation, grain, bloom, aberration, and camera shake are spatial effects that LUTs literally cannot do. Hance bundles colour grading and film texture into one step, no plugins required.
- **Automation pipelines** — agencies, studios, or platforms that need to batch-apply a consistent look across hundreds of clips with no GUI in the loop.
- **Developers** building apps that need film effects programmatically — social video platforms, AI video pipelines, content tools.

Hance is not a replacement for professional colour grading. It's the tool you reach for when you don't want to open a colour grading app at all.

### Why hance?

- **One-pass processing** — all effects compose into a single GPU render graph. No intermediate files, no re-encoding chains.
- **Preview + pipeline** — dial in a look visually with `hance ui`, save it as a preset, then batch-apply across footage from the command line. No other tool gives you both.
- **GPU-accelerated** — native wgpu sidecar renders effects on the GPU. Fast enough for batch workflows.
- **Pipeline-first** — a single binary with CLI flags, presets, and batch input. Script it, cron it, plug it into your ingest pipeline.
- **Optional browser UI** — `hance ui` launches a local preview app when you want to dial in a look interactively.
- **One-pass processing** — all effects compose into a single GPU render graph. No intermediate files, no re-encoding chains.
- **Scriptable** — a single binary with CLI flags, presets, and batch input. Script it, cron it, plug it into your ingest pipeline.
- **Agent-friendly** — ships with a Claude Code skill. Describe the look you want in plain English — no CLI knowledge needed.
- **No vendor lock-in** — runs on your machine, processes your files locally. Your footage never leaves your disk.

### Effects
Expand Down Expand Up @@ -157,6 +167,22 @@ hance ui --port 5000 # custom port
hance ui --no-open # don't auto-open browser
```

### AI agent friendly

Hance ships with a Claude Code skill. No CLI knowledge needed — just describe the look you want in natural language.

```
> /hance run Kodak Portra 400 on my-video.mp4
> /hance try a warm 70s portra look on sunset.mp4
> /hance batch apply a vintage look to everything in ./footage
```

Install the skill outside this repo:

```bash
ln -s path/to/hancer/skills/hance ~/.claude/skills/hance
```

---

## Output quality
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/preset.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { userPresetsDir, listPresetNames } from "@hance/core";
import { userPresetsDir, listPresetNames, rebuildPresetIndex } from "@hance/core";
import type { PresetData } from "@hance/core";
import { parseEffectFlags, EFFECT_HELP_TEXT } from "../effect-flags";

Expand Down Expand Up @@ -58,6 +58,7 @@ async function runSave(argv: string[]): Promise<void> {
}

writeFileSync(file, JSON.stringify({ hance_version: VERSION, name: parsed.name, params: parsed.overrides }, null, 2));
try { rebuildPresetIndex(); } catch (err) { console.error("preset index rebuild failed:", (err as Error).message); }
process.stdout.write(path.resolve(file) + "\n");
}

Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,7 @@ export { EFFECT_SCHEMA, getDefaults } from "./schema";
export type { PresetData } from "./presets";
export { loadPreset, applyPreset, builtinPresetsDir, userPresetsDir, listPresetNames } from "./presets";

export type { PresetIndexEntry } from "./preset-index";
export { buildPresetIndex, rebuildPresetIndex } from "./preset-index";

export { probe, parseProbeOutput } from "./probe";
62 changes: 62 additions & 0 deletions packages/core/src/preset-index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { existsSync, readdirSync, readFileSync, writeFileSync, statSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { builtinPresetsDir, userPresetsDir } from "./presets";

export interface PresetIndexEntry {
name: string;
description: string;
keywords: string[];
characteristics: string[];
path: string;
}

function scanDir(dir: string, label: "builtin" | "user"): PresetIndexEntry[] {
if (!existsSync(dir)) return [];
const out: PresetIndexEntry[] = [];
for (const file of readdirSync(dir).sort()) {
if (!file.endsWith(".hlook")) continue;
const full = join(dir, file);
if (!statSync(full).isFile()) continue;
let data: Record<string, unknown>;
try {
data = JSON.parse(readFileSync(full, "utf-8"));
} catch {
continue;
}
out.push({
name: typeof data.name === "string" ? data.name : file.replace(/\.hlook$/, ""),
description: typeof data.description === "string" ? data.description : "",
keywords: Array.isArray(data.keywords) ? (data.keywords as string[]) : [],
characteristics: Array.isArray(data.characteristics) ? (data.characteristics as string[]) : [],
path: label === "builtin" ? `presets/${file}` : full,
});
}
return out;
}

export interface BuildOptions {
includeUser?: boolean;
includeBuiltin?: boolean;
}

export function buildPresetIndex(opts: BuildOptions = {}): PresetIndexEntry[] {
const { includeUser = true, includeBuiltin = true } = opts;
const builtin = includeBuiltin ? scanDir(builtinPresetsDir(), "builtin") : [];
const user = includeUser ? scanDir(userPresetsDir(), "user") : [];
const seen = new Set<string>();
const merged: PresetIndexEntry[] = [];
for (const e of [...user, ...builtin]) {
if (seen.has(e.name)) continue;
seen.add(e.name);
merged.push(e);
}
return merged.sort((a, b) => a.name.localeCompare(b.name));
}

export function rebuildPresetIndex(outPath?: string, opts: BuildOptions = {}): string {
const index = buildPresetIndex(opts);
const target = outPath ?? join(userPresetsDir(), "index.json");
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, JSON.stringify(index, null, 2) + "\n");
return target;
}
35 changes: 26 additions & 9 deletions packages/ui/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,26 +156,38 @@ export function App() {

// Fetch schema and looks on mount — external server data
useEffect(() => {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this needed? the useEffect and the .then?

fetchJson<EffectGroup[]>("/api/schema")
.then((groups) => {
async function init() {
try {
const groups = await fetchJson<EffectGroup[]>("/api/schema");
setSchema(groups);
// Start with no effects applied (No Look)
const disableAll: Record<string, boolean> = {};
for (const group of groups) {
disableAll[group.enableKey] = true;
}
const lookPath = new URLSearchParams(window.location.search).get("look");
const lookName = lookPath?.split("/").pop()?.replace(/\.hlook$/, "") ?? null;
if (lookName) {
try {
const lookParams = await loadLook(lookName);
setParams(lookParams);
history.replace({ params: lookParams, activeLook: lookName });
return;
} catch {
// Look failed to load — fall through to default (all effects off).
}
}
setParams(disableAll);
// Replace (not commit) the initial present so we don't leave the
// pre-schema empty `{}` snapshot reachable via undo.
history.replace({ params: disableAll, activeLook: null });
})
.catch((err: Error) => {
} catch (err) {
console.error("Failed to load effect schema:", err);
setSchemaError(`Could not load effect controls: ${err.message}`);
});
setSchemaError(`Could not load effect controls: ${(err as Error).message}`);
}
}
init();
refreshLooks();
}, []);

const isCompareEdit = new URLSearchParams(window.location.search).has("look");
const leftPanel = useResizable({ defaultSize: 240, minSize: 200, maxSize: 400, direction: "horizontal" });
const rightPanel = useResizable({ defaultSize: 350, minSize: 250, maxSize: 500, direction: "horizontal", reverse: true });
const bottomPanel = useResizable({ defaultSize: 180, minSize: 100, maxSize: 250, direction: "vertical", reverse: true });
Expand Down Expand Up @@ -432,6 +444,11 @@ export function App() {

return (
<div className="h-screen flex flex-col bg-zinc-950">
{isCompareEdit && (
<div className="bg-indigo-600 px-4 py-3 text-center text-sm font-medium text-white">
When you're happy with your edits, tell your agent to apply this look.
</div>
)}
<TopBar
filename={file?.name || null}
file={file}
Expand Down
5 changes: 3 additions & 2 deletions packages/ui/app/components/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export function Canvas(props: Props) {
previewWidth: previewSize.width,
previewHeight: previewSize.height,
});
renderer.setSource(video);
await renderer.setSource(video);
renderer.setParams(paramsRef.current);
rendererRef.current = renderer;
onRendererReady(renderer);
Expand All @@ -109,6 +109,7 @@ export function Canvas(props: Props) {
};
if (img.complete && img.naturalWidth > 0) { clearTimeout(timeout); resolve(); }
});
await img.decode();
const sourceWidth = img.naturalWidth;
const sourceHeight = img.naturalHeight;
const previewSize = fitPreviewSize(sourceWidth, sourceHeight);
Expand All @@ -120,7 +121,7 @@ export function Canvas(props: Props) {
previewWidth: previewSize.width,
previewHeight: previewSize.height,
});
renderer.setSource(img);
await renderer.setSource(img);
renderer.setParams(paramsRef.current);
renderer.renderFrame();
rendererRef.current = renderer;
Expand Down
78 changes: 78 additions & 0 deletions packages/ui/app/components/ComparePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { useMemo } from "react";

interface Variant {
label: string;
src: string | null;
lookPath: string | null;
}

function fileUrl(path: string | null): string | null {
if (!path) return null;
return `/api/local-file?path=${encodeURIComponent(path)}`;
}

export function ComparePage() {
const params = useMemo(() => new URLSearchParams(window.location.search), []);
const kind = params.get("kind") === "video" ? "video" : "image";
const original = params.get("original");
const variants: Variant[] = [
{ label: "A", src: params.get("v1"), lookPath: params.get("v1Look") },
{ label: "B", src: params.get("v2"), lookPath: params.get("v2Look") },
{ label: "C", src: params.get("v3"), lookPath: params.get("v3Look") },
];

function editVariant(i: number) {
const v = variants[i];
if (!v.lookPath || !original) return;
window.location.href = `/?look=${encodeURIComponent(v.lookPath)}`;
}

function Cell({ title, src, action }: { title: string; src: string | null; action?: React.ReactNode }) {
return (
<div className="flex flex-col bg-zinc-900 rounded-md overflow-hidden border border-zinc-800">
<div className="flex items-center justify-between px-3 py-2 border-b border-zinc-800">
<span className="text-xs text-zinc-300">{title}</span>
{action}
</div>
<div className="flex-1 flex items-center justify-center bg-black min-h-0">
{src ? (
kind === "video" ? (
<video src={src} controls className="max-w-full max-h-full" />
) : (
<img src={src} alt={title} className="max-w-full max-h-full object-contain" />
)
) : (
<span className="text-xs text-zinc-600">missing</span>
)}
</div>
</div>
);
}

return (
<div className="h-screen bg-zinc-950 text-zinc-100 flex flex-col p-4 gap-3">
<div className="rounded-lg bg-indigo-600 px-4 py-3 text-center text-sm font-medium text-white">
Tell your agent which option you'd like to use — A, B, or C.
</div>
<div className="flex-1 grid grid-cols-2 grid-rows-2 gap-3 min-h-0">
<Cell title="Original" src={fileUrl(original)} />
{variants.map((v, i) => (
<Cell
key={i}
title={v.label}
src={fileUrl(v.src)}
action={
<button
onClick={() => editVariant(i)}
disabled={!v.lookPath || !original}
className="text-xs text-white bg-accent hover:bg-accent-hover disabled:opacity-50 rounded-sm px-2 py-0.5"
>
Edit
</button>
}
/>
))}
</div>
</div>
);
}
14 changes: 10 additions & 4 deletions packages/ui/app/gpu/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export interface PreviewParams {
}

export interface Renderer {
setSource(source: HTMLVideoElement | HTMLImageElement): void;
setSource(source: HTMLVideoElement | HTMLImageElement): Promise<void>;
setSourceFromBuffer(data: Uint8Array, width: number, height: number): void;
setParams(params: PreviewParams): void;
renderFrame(): void;
Expand Down Expand Up @@ -116,6 +116,7 @@ export async function createRenderer(canvas: HTMLCanvasElement, init: RendererIn
const bloomBlendUB = createUniformBuffer(device, 16);

let source: HTMLVideoElement | HTMLImageElement | null = null;
let imageBitmap: ImageBitmap | null = null;
let bufferSource: { data: Uint8Array; width: number; height: number } | null = null;
let params: PreviewParams = {};
let frameCount = 0;
Expand Down Expand Up @@ -166,9 +167,9 @@ export async function createRenderer(canvas: HTMLCanvasElement, init: RendererIn
{ width: sourceWidth, height: sourceHeight },
);
frame.close();
} else {
} else if (imageBitmap) {
device.queue.copyExternalImageToTexture(
{ source },
{ source: imageBitmap },
{ texture: srcTex },
{ width: sourceWidth, height: sourceHeight },
);
Expand Down Expand Up @@ -440,9 +441,13 @@ export async function createRenderer(canvas: HTMLCanvasElement, init: RendererIn
}

return {
setSource(s: HTMLVideoElement | HTMLImageElement) {
async setSource(s: HTMLVideoElement | HTMLImageElement) {
source = s;
bufferSource = null;
if (imageBitmap) { imageBitmap.close(); imageBitmap = null; }
if (s instanceof HTMLImageElement) {
imageBitmap = await createImageBitmap(s);
}
},
setSourceFromBuffer,
setParams(p: PreviewParams) {
Expand All @@ -451,6 +456,7 @@ export async function createRenderer(canvas: HTMLCanvasElement, init: RendererIn
renderFrame,
readPixels,
destroy() {
if (imageBitmap) { imageBitmap.close(); imageBitmap = null; }
texA.destroy();
texB.destroy();
halfA.destroy();
Expand Down
8 changes: 5 additions & 3 deletions packages/ui/app/hooks/useUpload.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useCallback } from "react";
import { useState, useCallback, useRef } from "react";

export const MAX_UPLOAD_BYTES = 16 * 1024 * 1024 * 1024;

Expand Down Expand Up @@ -29,17 +29,19 @@ export function useUpload() {
file: null, objectUrl: null, proxyUrl: null, isVideo: false, error: null,
});

const prevUrlRef = useRef<string | null>(null);
const upload = useCallback((file: File) => {
const sizeError = validateUploadSize(file.size);
if (sizeError) {
setState(s => ({ ...s, error: sizeError }));
return;
}
if (state.objectUrl) URL.revokeObjectURL(state.objectUrl);
if (prevUrlRef.current) URL.revokeObjectURL(prevUrlRef.current);
const url = URL.createObjectURL(file);
prevUrlRef.current = url;
const isVideo = file.type.startsWith("video/");
setState({ file, objectUrl: url, proxyUrl: null, isVideo, error: null });
}, [state.objectUrl]);
}, []);

const setProxyUrl = useCallback((proxyUrl: string) => {
setState(s => ({ ...s, proxyUrl }));
Expand Down
Loading