Skip to content
Open
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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ misc/
MiniMax-H3/
outputs/
.ruff_cache/
.venv-gui/
gui/H3Studio.app/
gui/deployment/
gui/pysidedeploy.spec
*.egg-info/
__pycache__/

# Compiler and test outputs.
*.o
Expand Down
32 changes: 31 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,40 @@ LIB_M := h3_metal.m h3_gpu.m h3_tokenizer.m
LIB_OBJ := $(LIB_C:.c=.o) $(LIB_M:.m=.o)
CLI_OBJ := main.o h3_cli.o linenoise.o

.PHONY: all test parity real-parity clean
.PHONY: all test parity real-parity gui-setup gui gui-test gui-app clean

PYTHON ?= python3
GUI_VENV ?= .venv-gui
GUI_PYTHON := $(GUI_VENV)/bin/python

all: h3 libh3.a

gui-setup:
@test -x $(GUI_PYTHON) || $(PYTHON) -m venv $(GUI_VENV)
$(GUI_PYTHON) -m pip install -e .

gui: h3 gui-setup
$(GUI_PYTHON) -m gui.main

gui-test: h3 gui-setup
$(GUI_PYTHON) -m pip install -e ".[dev]"
$(GUI_PYTHON) -m mypy gui
QT_QPA_PLATFORM=offscreen $(GUI_PYTHON) -m unittest discover -s gui/tests -t .

gui-app: h3 gui-setup
$(GUI_VENV)/bin/pyside6-deploy gui/main.py --name H3Studio --force
mkdir -p gui/H3Studio.app/Contents/Resources
cp h3 h3_shaders.metal gui/H3Studio.app/Contents/Resources/
chmod +x gui/H3Studio.app/Contents/Resources/h3
/usr/libexec/PlistBuddy -c "Set :CFBundleName H3 Studio" gui/H3Studio.app/Contents/Info.plist
/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName H3 Studio" gui/H3Studio.app/Contents/Info.plist
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier io.github.antirez.h3studio" gui/H3Studio.app/Contents/Info.plist
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString 0.1.0" gui/H3Studio.app/Contents/Info.plist
codesign --force --deep --sign - gui/H3Studio.app
codesign --verify --deep --strict gui/H3Studio.app
QT_QPA_PLATFORM=offscreen gui/H3Studio.app/Contents/MacOS/main --smoke-test
@echo "Built gui/H3Studio.app"

h3: $(CLI_OBJ) $(LIB_OBJ)
$(CC) -o $@ $^ $(LDLIBS)

Expand Down
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,51 @@ mkdir -p outputs
mapping all weights or generating media. Run `./h3 --help` for the complete CLI
reference.

#### Optional macOS desktop interface

H3 Studio is a PySide6 interface for choosing the model, reference images,
prompt, output format, duration, speed preset, and advanced denoising options.
It detects the Mac chip, unified memory, architecture, and Metal support; the
default fast preset keeps live previews off because the resident preview VAE
adds roughly 10 GiB of temporary model storage.

The interface is English-only and responsive: it uses two resizable columns on
wide windows, stacks them with scrolling on smaller windows, and includes a
Custom preset that opens every advanced control without changing its current
value. The advanced panel can add, remove, and reorder up to nine image
references; their order is passed to Ref2VA as `Picture 1`, `Picture 2`, and so
on. It also exposes reference sizing, token reduction, M5 INT8 row FC2, SSD
streaming, seed, render canvas, layer count, and both reuse controls. The Preview
column has separate Inputs and Generation tabs, so selected
photos remain inspectable before denoising begins. Progress is shown as three
global stages (Preparation, Generation, and Decode & export), while the optional
live preview follows the available panel size.

Build `h3`, create the isolated GUI environment, and open the window with:

```sh
make gui
```

The first run downloads PySide6 into `.venv-gui`. To create a double-clickable
Apple Silicon application containing the `h3` executable and Metal shader:

```sh
make gui-app
open gui/H3Studio.app
```

![H3 Studio running with its hardware-aware Fast preset](gui/assets/h3-studio-ui.png)

The model weights remain external. Select `MiniMax-H3` on first launch; H3
Studio remembers model, output, preset, and technical generation choices through
macOS preferences. Prompts and image references are deliberately session-only,
so every new window starts with both fields empty. iPhone `.heic` and `.heif`
references are decoded with their embedded orientation and saved as upright PNG
copies using macOS `sips` and Qt; originals are left intact and the generated
files are stored in the `reference-images` folder beside the output selected in
the GUI. Run the GUI contract tests with `make gui-test`.

Without `-p`, the same binary starts an Iris-style interactive session:

```sh
Expand Down Expand Up @@ -340,6 +385,9 @@ prompt, seed, resolution, frame count, and step count.
factor without resizing the generated video or the encoded terminal image.
- `--frames-dir DIR` writes final callback frames as PPM files. Intermediate
`--show` previews are not written there.
- `--preview-dir DIR` writes one complete PPM preview after every denoising
transition. It is intended for graphical front ends and has the same preview
VAE memory and decode cost as `--show`.
- `-o ''` disables MP4 encoding; combine it with `--frames-dir` when FFmpeg is
unavailable.
- `--profile` reports phase wall time, Metal encoding/wait time, peak live
Expand Down
2 changes: 2 additions & 0 deletions gui/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"""Desktop interface for h3-metal."""

28 changes: 28 additions & 0 deletions gui/app_paths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from __future__ import annotations

import os
import sys
from collections.abc import Mapping
from pathlib import Path


def locate_engine_dir(
*,
source_root: Path,
executable: Path | None = None,
environ: Mapping[str, str] = os.environ,
) -> Path:
candidates: list[Path] = []
configured = environ.get("H3_ENGINE_DIR")
if configured:
candidates.append(Path(configured).expanduser())
candidates.append(source_root)
executable = executable or Path(sys.executable)
if len(executable.parents) >= 2:
candidates.append(executable.resolve().parents[1] / "Resources")
for candidate in candidates:
if (candidate / "h3").is_file() and (
candidate / "h3_shaders.metal"
).is_file():
return candidate.resolve()
return source_root.resolve()
Binary file added gui/assets/h3-studio-ui.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
54 changes: 54 additions & 0 deletions gui/hardware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from __future__ import annotations

from dataclasses import dataclass
import re
import subprocess
from collections.abc import Callable, Sequence


CommandRunner = Callable[[Sequence[str]], str]


@dataclass(frozen=True, slots=True)
class MacInfo:
chip: str
memory_gib: float
architecture: str
metal_support: str

@property
def summary(self) -> str:
return f"{self.chip} · {self.memory_gib:.0f} GB unified memory"


def _run_command(command: Sequence[str]) -> str:
completed = subprocess.run(
command,
check=True,
capture_output=True,
text=True,
timeout=20,
)
return completed.stdout


def detect_mac_info(run: CommandRunner = _run_command) -> MacInfo:
chip = run(("sysctl", "-n", "machdep.cpu.brand_string")).strip()
memory_bytes = int(run(("sysctl", "-n", "hw.memsize")).strip())
architecture = run(("uname", "-m")).strip()
display_info = run(("system_profiler", "SPDisplaysDataType"))
metal_match = re.search(
r"^\s*Metal(?: Support)?:\s*(.+?)\s*$", display_info, re.MULTILINE
)
if not metal_match:
metal_support = "Not detected"
elif metal_match.group(1).strip().lower() == "supported":
metal_support = "Metal supported"
else:
metal_support = metal_match.group(1).strip()
return MacInfo(
chip=chip,
memory_gib=memory_bytes / (1024**3),
architecture=architecture,
metal_support=metal_support,
)
141 changes: 141 additions & 0 deletions gui/image_converter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
from __future__ import annotations

import hashlib
import subprocess
import sys
import uuid
from collections.abc import Callable
from pathlib import Path

from PySide6.QtGui import (
QImage,
QImageIOHandler,
QImageReader,
QImageWriter,
QTransform,
)


class ImageConversionError(RuntimeError):
"""A reference image could not be converted into a format H3 accepts."""


CommandRunner = Callable[[tuple[str, ...]], None]
CONVERTIBLE_REFERENCE_SUFFIXES = (".heic", ".heif")
REFERENCE_IMAGE_SUFFIXES = (
".png",
".jpg",
".jpeg",
*CONVERTIBLE_REFERENCE_SUFFIXES,
".webp",
)
REFERENCE_IMAGE_FILE_FILTER = "Images (" + " ".join(
f"*{suffix}" for suffix in REFERENCE_IMAGE_SUFFIXES
) + ")"


def requires_png_conversion(source: Path) -> bool:
return source.suffix.lower() in CONVERTIBLE_REFERENCE_SUFFIXES


def _run_command(command: tuple[str, ...]) -> None:
subprocess.run(command, check=True, capture_output=True, text=True)


def _apply_orientation(
image: QImage,
transformation: QImageIOHandler.Transformation,
) -> QImage:
if transformation == QImageIOHandler.Transformation.TransformationNone:
return image
if transformation == QImageIOHandler.Transformation.TransformationMirror:
return image.mirrored(True, False)
if transformation == QImageIOHandler.Transformation.TransformationFlip:
return image.mirrored(False, True)
if transformation == QImageIOHandler.Transformation.TransformationRotate180:
return image.transformed(QTransform().rotate(180))
if (
transformation
== QImageIOHandler.Transformation.TransformationMirrorAndRotate90
):
image = image.mirrored(True, False)
elif (
transformation
== QImageIOHandler.Transformation.TransformationFlipAndRotate90
):
image = image.mirrored(False, True)
angle = (
270
if transformation == QImageIOHandler.Transformation.TransformationRotate270
else 90
)
return image.transformed(QTransform().rotate(angle))


def convert_reference_image(
source: Path,
output_dir: Path,
*,
run: CommandRunner = _run_command,
) -> Path:
"""Decode an iPhone photo upright and save a metadata-neutral PNG copy."""
source = source.expanduser().resolve()
if not requires_png_conversion(source):
return source
if not source.is_file():
raise ImageConversionError(f"HEIC image not found: {source}")

output_dir = output_dir.expanduser().resolve()
source_id = hashlib.sha256(str(source).encode()).hexdigest()[:8]
destination = output_dir / f"{source.stem}-{source_id}.png"
temporary = output_dir / f".{destination.stem}-{uuid.uuid4().hex}.tmp.png"
decoded = output_dir / f".{destination.stem}-{uuid.uuid4().hex}.sips.png"
command = (
"/usr/bin/sips",
"-s",
"format",
"png",
str(source),
"--out",
str(decoded),
)
try:
output_dir.mkdir(parents=True, exist_ok=True)
orientation_reader = QImageReader(str(source))
transformation = orientation_reader.transformation()
run(command)
image = QImage(str(decoded))
if image.isNull():
raise ImageConversionError(
"HEIC conversion did not produce a readable PNG image."
)
image = _apply_orientation(image, transformation)
writer = QImageWriter(str(temporary), b"png")
if not writer.write(image):
raise ImageConversionError(
"Could not save the orientation-normalized PNG image: "
f"{writer.errorString()}"
)
signature = temporary.read_bytes()[:8]
if signature != b"\x89PNG\r\n\x1a\n":
raise ImageConversionError(
"HEIC conversion did not produce a valid PNG file."
)
temporary.replace(destination)
return destination
except ImageConversionError:
raise
except (OSError, subprocess.SubprocessError) as error:
raise ImageConversionError(
"Could not convert or save the HEIC image."
) from error
finally:
active_error = sys.exc_info()[0] is not None
try:
temporary.unlink(missing_ok=True)
decoded.unlink(missing_ok=True)
except OSError as cleanup_error:
if not active_error:
raise ImageConversionError(
"Could not remove the temporary HEIC file."
) from cleanup_error
Loading