diff --git a/.gitignore b/.gitignore index 6e4c2c90..d2996a7a 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Makefile b/Makefile index bb202379..dcef56d1 100644 --- a/Makefile +++ b/Makefile @@ -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) diff --git a/README.md b/README.md index 4750ac49..9372b7dc 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/gui/__init__.py b/gui/__init__.py new file mode 100644 index 00000000..25b219d4 --- /dev/null +++ b/gui/__init__.py @@ -0,0 +1,2 @@ +"""Desktop interface for h3-metal.""" + diff --git a/gui/app_paths.py b/gui/app_paths.py new file mode 100644 index 00000000..c591125a --- /dev/null +++ b/gui/app_paths.py @@ -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() diff --git a/gui/assets/h3-studio-ui.png b/gui/assets/h3-studio-ui.png new file mode 100644 index 00000000..5ecb69d6 Binary files /dev/null and b/gui/assets/h3-studio-ui.png differ diff --git a/gui/hardware.py b/gui/hardware.py new file mode 100644 index 00000000..98b5f5ae --- /dev/null +++ b/gui/hardware.py @@ -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, + ) diff --git a/gui/image_converter.py b/gui/image_converter.py new file mode 100644 index 00000000..f0227949 --- /dev/null +++ b/gui/image_converter.py @@ -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 diff --git a/gui/main.py b/gui/main.py new file mode 100644 index 00000000..bc57947e --- /dev/null +++ b/gui/main.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import sys +from pathlib import Path +from subprocess import SubprocessError + +if __package__ in (None, ""): + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from PySide6.QtCore import QTimer +from PySide6.QtWidgets import QApplication + +from gui.app_paths import locate_engine_dir +from gui.hardware import MacInfo, detect_mac_info +from gui.runner import H3Runner +from gui.window import MainWindow + + +def _mac_info() -> MacInfo: + try: + return detect_mac_info() + except (OSError, ValueError, SubprocessError): + return MacInfo( + chip="Mac not detected", + memory_gib=0.0, + architecture="—", + metal_support="Not detected", + ) + + +def main(argv: list[str] | None = None) -> int: + arguments = list(sys.argv[1:] if argv is None else argv) + smoke_test = "--smoke-test" in arguments + qt_arguments = [sys.argv[0], *(item for item in arguments if item != "--smoke-test")] + source_root = Path(__file__).resolve().parents[1] + engine_root = locate_engine_dir(source_root=source_root) + packaged = engine_root != source_root.resolve() + application = QApplication(qt_arguments) + application.setApplicationName("H3 Studio") + application.setOrganizationName("h3-metal") + window = MainWindow( + repo_root=engine_root, + mac_info=_mac_info(), + runner=H3Runner(engine_root / "h3"), + default_output_dir=( + Path.home() / "Movies" / "H3 Studio" if packaged else None + ), + ) + window.show() + if smoke_test: + QTimer.singleShot(100, application.quit) + return application.exec() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/gui/presets.py b/gui/presets.py new file mode 100644 index 00000000..75c310ad --- /dev/null +++ b/gui/presets.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from dataclasses import dataclass, replace + +from .hardware import MacInfo + + +@dataclass(frozen=True, slots=True) +class GenerationPreset: + name: str + label: str + description: str + width: int + height: int + render_width: int | None + render_height: int | None + seconds: int + steps: int + layers: int + reuse: int + core_reuse: int + ssd_streaming: bool + live_preview: bool = False + + +PRESET_ORDER = ("fast", "balanced", "quality") + +_PRESETS = { + "fast": GenerationPreset( + name="fast", + label="Fast", + description=( + "Quickly check the prompt, identity, and framing with a smaller " + "internal canvas." + ), + width=512, + height=512, + render_width=320, + render_height=320, + seconds=2, + steps=6, + layers=40, + reuse=1, + core_reuse=1, + ssd_streaming=True, + ), + "balanced": GenerationPreset( + name="balanced", + label="Balanced", + description="A good compromise for checking motion and likeness.", + width=512, + height=512, + render_width=None, + render_height=None, + seconds=4, + steps=20, + layers=45, + reuse=2, + core_reuse=1, + ssd_streaming=True, + ), + "quality": GenerationPreset( + name="quality", + label="Quality", + description="Final render with every layer and full denoising.", + width=512, + height=512, + render_width=None, + render_height=None, + seconds=6, + steps=50, + layers=50, + reuse=1, + core_reuse=1, + ssd_streaming=True, + ), +} + + +def recommended_preset_name(info: MacInfo) -> str: + if info.memory_gib >= 64 and ( + "M5" in info.chip.upper() or "METAL 4" in info.metal_support.upper() + ): + return "balanced" + return "fast" + + +def preset_for(name: str, info: MacInfo) -> GenerationPreset: + try: + preset = _PRESETS[name] + except KeyError as error: + raise ValueError(f"unknown preset: {name}") from error + if name == "fast" and info.memory_gib < 32: + return replace( + preset, + width=256, + height=256, + render_width=256, + render_height=256, + steps=4, + ) + return preset diff --git a/gui/runner.py b/gui/runner.py new file mode 100644 index 00000000..6c710c91 --- /dev/null +++ b/gui/runner.py @@ -0,0 +1,336 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import re +import subprocess +import threading +import time +from collections.abc import Callable +from typing import Any + + +@dataclass(frozen=True, slots=True) +class GenerationSettings: + model_dir: Path + prompt: str + output_path: Path + reference_image: Path | None + width: int + height: int + render_width: int | None + render_height: int | None + seconds: int + steps: int + layers: int + reuse: int + core_reuse: int + seed: int + ssd_streaming: bool + live_preview: bool + preview_dir: Path | None = None + additional_reference_images: tuple[Path, ...] = () + reference_image_size: str = "max" + token_reduction: bool = False + use_int8_row_fc2: bool = False + + @property + def reference_images(self) -> tuple[Path, ...]: + primary = (self.reference_image,) if self.reference_image is not None else () + return primary + self.additional_reference_images + + +@dataclass(frozen=True, slots=True) +class ProgressUpdate: + phase: str + stage: str + stage_index: int + completed: int + total: int + percent: float + eta_seconds: float | None + + +class ProgressTracker: + _pattern = re.compile(r"\s*(.*?)\s+(\d+)\s*/\s*(\d+)\s*") + _third = 100.0 / 3.0 + _phase_bands = { + "tokenizer": (0, 0.0, 2.0), + "audio VAE encoder": (0, 2.0, 8.0), + "video VAE encoder": (0, 2.0, 12.0), + "Qwen vision": (0, 12.0, 18.0), + "text encoder": (0, 18.0, 23.0), + "load transformer core": (0, 23.0, 30.0), + "precompute AdaLN": (0, 30.0, 31.0), + "refine text": (0, 31.0, 32.0), + "preview VAE load": (0, 32.0, _third), + "denoise enqueue": (1, _third, 2 * _third), + "denoise": (1, _third, 2 * _third), + "audio VAE": (2, 2 * _third, 74.0), + "video VAE load": (2, 74.0, 92.0), + "FFmpeg": (2, 92.0, 100.0), + } + _stage_names = ("Preparation", "Generation", "Decode & export") + + def __init__(self) -> None: + self._started: float | None = None + self._overall_percent = 0.0 + self._stage_index = 0 + self._active_phase: str | None = None + self._active_completed = 0 + self._seen_phases: set[str] = set() + self._phase_is_repeated = False + self._phase_cycle_start = 0.0 + self._eta_seconds: float | None = None + self._eta_updated_at: float | None = None + + def consume(self, text: str, *, now: float) -> ProgressUpdate | None: + match = self._pattern.fullmatch(text.strip("\r\n")) + if not match: + return None + phase = match.group(1).strip() + completed = int(match.group(2)) + total = int(match.group(3)) + if total <= 0 or completed > total: + return None + if self._started is None: + self._started = now + stage_index, band_start, band_end = self._band_for(phase) + phase_restarted = ( + phase == self._active_phase and completed < self._active_completed + ) + if phase != self._active_phase or phase_restarted: + self._phase_is_repeated = phase in self._seen_phases + self._seen_phases.add(phase) + self._active_phase = phase + self._phase_cycle_start = self._overall_percent + self._active_completed = completed + self._stage_index = max(self._stage_index, stage_index) + phase_fraction = completed / total + mapped_percent = band_start + (band_end - band_start) * phase_fraction + if self._phase_is_repeated and stage_index == self._stage_index: + stage_end = (stage_index + 1) * self._third + repeated_percent = self._phase_cycle_start + ( + stage_end - self._phase_cycle_start + ) * 0.2 * phase_fraction + mapped_percent = max(mapped_percent, repeated_percent) + self._overall_percent = max(self._overall_percent, mapped_percent) + elapsed = now - self._started + if self._overall_percent >= 100.0: + self._eta_seconds = 0.0 + elif elapsed > 0.0 and self._overall_percent > 0.0: + estimate = ( + elapsed + * (100.0 - self._overall_percent) + / self._overall_percent + ) + if self._eta_seconds is None or self._eta_updated_at is None: + self._eta_seconds = max(1.0, estimate) + else: + countdown = max( + 1.0, + self._eta_seconds - (now - self._eta_updated_at), + ) + self._eta_seconds = max( + 1.0, + 0.75 * countdown + 0.25 * estimate, + ) + self._eta_updated_at = now + return ProgressUpdate( + phase=phase, + stage=self._stage_names[self._stage_index], + stage_index=self._stage_index, + completed=completed, + total=total, + percent=self._overall_percent, + eta_seconds=self._eta_seconds, + ) + + def _band_for(self, phase: str) -> tuple[int, float, float]: + known = self._phase_bands.get(phase) + if known is not None: + return known + if "denoise" in phase.lower(): + return (1, self._third, 2 * self._third) + if self._stage_index >= 1: + return (2, 2 * self._third, 92.0) + return (0, 0.0, self._third) + + +@dataclass(frozen=True, slots=True) +class RunResult: + exit_code: int + cancelled: bool + output_path: Path + + +@dataclass(frozen=True, slots=True) +class RunnerCallbacks: + on_progress: Callable[[ProgressUpdate], Any] + on_output: Callable[[str], Any] + on_preview: Callable[[Path], Any] + on_finished: Callable[[RunResult], Any] + + +def _spawn_process(command: list[str], cwd: Path) -> subprocess.Popen[str]: + return subprocess.Popen( + command, + cwd=cwd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=0, + ) + + +class H3Runner: + def __init__( + self, + executable: Path, + *, + process_factory: Callable[[list[str], Path], Any] = _spawn_process, + clock: Callable[[], float] = time.monotonic, + ) -> None: + self._executable = executable + self._process_factory = process_factory + self._clock = clock + self._process: Any | None = None + self._thread: threading.Thread | None = None + self._cancel_requested = False + self._lock = threading.Lock() + + @property + def running(self) -> bool: + with self._lock: + return self._process is not None + + def start( + self, + settings: GenerationSettings, + callbacks: RunnerCallbacks, + ) -> None: + with self._lock: + if self._process is not None: + raise RuntimeError("a generation is already running") + settings.output_path.parent.mkdir(parents=True, exist_ok=True) + if settings.preview_dir is not None: + settings.preview_dir.mkdir(parents=True, exist_ok=True) + command = build_h3_command(self._executable, settings) + process = self._process_factory(command, self._executable.parent) + self._process = process + self._cancel_requested = False + self._thread = threading.Thread( + target=self._consume_process, + args=(process, settings, callbacks), + name="h3-runner", + daemon=True, + ) + self._thread.start() + + def stop(self, *, force_after: float = 8.0) -> None: + with self._lock: + process = self._process + if process is None: + return + self._cancel_requested = True + process.terminate() + timer = threading.Timer(force_after, self._kill_if_running, args=(process,)) + timer.daemon = True + timer.start() + + def _kill_if_running(self, process: Any) -> None: + if process.poll() is None: + process.kill() + + def _consume_process( + self, + process: Any, + settings: GenerationSettings, + callbacks: RunnerCallbacks, + ) -> None: + tracker = ProgressTracker() + buffer = "" + stream = process.stdout + if stream is not None: + while True: + character = stream.read(1) + if character == "": + if buffer: + self._consume_line(buffer, tracker, callbacks) + break + if character in "\r\n": + if buffer: + self._consume_line(buffer, tracker, callbacks) + buffer = "" + else: + buffer += character + exit_code = process.wait() + with self._lock: + cancelled = self._cancel_requested + if self._process is process: + self._process = None + callbacks.on_finished( + RunResult( + exit_code=exit_code, + cancelled=cancelled, + output_path=settings.output_path, + ) + ) + + def _consume_line( + self, + line: str, + tracker: ProgressTracker, + callbacks: RunnerCallbacks, + ) -> None: + callbacks.on_output(line) + update = tracker.consume(line, now=self._clock()) + if update is not None: + callbacks.on_progress(update) + preview_prefix = "h3: preview-file " + if line.startswith(preview_prefix): + callbacks.on_preview(Path(line[len(preview_prefix) :])) + + +def build_h3_command(executable: Path, settings: GenerationSettings) -> list[str]: + command = [str(executable), "--profile", "-d", str(settings.model_dir)] + for reference_image in settings.reference_images: + command.extend(("--ref-image", str(reference_image))) + if settings.reference_images: + command.extend(("--ref-image-size", settings.reference_image_size)) + command.extend(("-p", settings.prompt)) + command.extend(("--width", str(settings.width), "--height", str(settings.height))) + if settings.render_width is not None and settings.render_height is not None: + command.extend( + ( + "--render-width", + str(settings.render_width), + "--render-height", + str(settings.render_height), + ) + ) + command.extend( + ( + "--seconds", + str(settings.seconds), + "--steps", + str(settings.steps), + "--layers", + str(settings.layers), + "--reuse", + str(settings.reuse), + ) + ) + if settings.core_reuse > 1: + command.extend(("--core-reuse", str(settings.core_reuse))) + command.extend(("--seed", str(settings.seed))) + if settings.ssd_streaming: + command.append("--ssd-streaming") + if settings.token_reduction: + command.append("--token-reduction") + if settings.use_int8_row_fc2: + command.append("--use-int8-row-fc2") + if settings.live_preview and settings.preview_dir is not None: + command.extend(("--preview-dir", str(settings.preview_dir))) + command.extend(("-o", str(settings.output_path))) + return command diff --git a/gui/tests/__init__.py b/gui/tests/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/gui/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/gui/tests/test_app_paths.py b/gui/tests/test_app_paths.py new file mode 100644 index 00000000..2259cdfd --- /dev/null +++ b/gui/tests/test_app_paths.py @@ -0,0 +1,29 @@ +import tempfile +import unittest +from pathlib import Path + +from gui.app_paths import locate_engine_dir + + +class AppPathTests(unittest.TestCase): + def test_locates_engine_inside_a_macos_app_bundle(self) -> None: + with tempfile.TemporaryDirectory() as directory: + bundle = Path(directory) / "H3Studio.app" / "Contents" + executable = bundle / "MacOS" / "H3Studio" + resources = bundle / "Resources" + resources.mkdir(parents=True) + executable.parent.mkdir(parents=True) + (resources / "h3").touch() + (resources / "h3_shaders.metal").touch() + + result = locate_engine_dir( + source_root=Path(directory) / "source", + executable=executable, + environ={}, + ) + + self.assertEqual(result, resources.resolve()) + + +if __name__ == "__main__": + unittest.main() diff --git a/gui/tests/test_app_smoke.py b/gui/tests/test_app_smoke.py new file mode 100644 index 00000000..8274b24f --- /dev/null +++ b/gui/tests/test_app_smoke.py @@ -0,0 +1,24 @@ +import os +import subprocess +import sys +import unittest + + +class AppSmokeTests(unittest.TestCase): + def test_module_can_open_and_close_without_user_input(self) -> None: + environment = os.environ.copy() + environment["QT_QPA_PLATFORM"] = "offscreen" + + completed = subprocess.run( + (sys.executable, "-m", "gui.main", "--smoke-test"), + capture_output=True, + text=True, + env=environment, + timeout=5, + ) + + self.assertEqual(completed.returncode, 0, completed.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/gui/tests/test_cli_contract.py b/gui/tests/test_cli_contract.py new file mode 100644 index 00000000..e418d41a --- /dev/null +++ b/gui/tests/test_cli_contract.py @@ -0,0 +1,23 @@ +import subprocess +import unittest +from pathlib import Path + + +class CliContractTests(unittest.TestCase): + def test_cli_exposes_machine_readable_preview_directory(self) -> None: + executable = Path(__file__).resolve().parents[2] / "h3" + if not executable.exists(): + self.skipTest("h3 executable has not been built") + + completed = subprocess.run( + (str(executable), "--help"), + check=True, + capture_output=True, + text=True, + ) + + self.assertIn("--preview-dir PATH", completed.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/gui/tests/test_image_converter.py b/gui/tests/test_image_converter.py new file mode 100644 index 00000000..d850d789 --- /dev/null +++ b/gui/tests/test_image_converter.py @@ -0,0 +1,76 @@ +import struct +import tempfile +import unittest +from pathlib import Path + +from PySide6.QtGui import QColor, QImage, QImageIOHandler, QImageReader + + +def write_rotated_jpeg(path: Path) -> None: + image = QImage(40, 20, QImage.Format.Format_RGB32) + image.fill(QColor("#42c98c")) + if not image.save(str(path), "JPEG"): + raise AssertionError("Could not create the orientation test image") + jpeg = path.read_bytes() + tiff = ( + b"MM\x00\x2a\x00\x00\x00\x08" + b"\x00\x01" + b"\x01\x12\x00\x03\x00\x00\x00\x01\x00\x06\x00\x00" + b"\x00\x00\x00\x00" + ) + payload = b"Exif\x00\x00" + tiff + app1 = b"\xff\xe1" + struct.pack(">H", len(payload) + 2) + payload + path.write_bytes(jpeg[:2] + app1 + jpeg[2:]) + + +class ReferenceImageConversionTests(unittest.TestCase): + def test_converts_heic_to_physically_oriented_png_without_modifying_source( + self, + ) -> None: + from gui.image_converter import convert_reference_image + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "IMG_4621.HEIC" + write_rotated_jpeg(source) + original = source.read_bytes() + + converted = convert_reference_image( + source, + root / "converted", + ) + + self.assertEqual(source.read_bytes(), original) + self.assertEqual(converted.suffix, ".png") + self.assertEqual(converted.read_bytes()[:8], b"\x89PNG\r\n\x1a\n") + reader = QImageReader(str(converted)) + self.assertEqual((reader.size().width(), reader.size().height()), (20, 40)) + self.assertEqual( + reader.transformation(), + QImageIOHandler.Transformation.TransformationNone, + ) + + def test_same_iphone_filename_from_different_folders_does_not_collide( + self, + ) -> None: + from gui.image_converter import convert_reference_image + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + first = root / "trip-one" / "IMG_4621.HEIC" + second = root / "trip-two" / "IMG_4621.HEIC" + first.parent.mkdir() + second.parent.mkdir() + write_rotated_jpeg(first) + write_rotated_jpeg(second) + + first_png = convert_reference_image(first, root / "converted") + second_png = convert_reference_image(second, root / "converted") + + self.assertNotEqual(first_png, second_png) + self.assertTrue(first_png.is_file()) + self.assertTrue(second_png.is_file()) + + +if __name__ == "__main__": + unittest.main() diff --git a/gui/tests/test_presets.py b/gui/tests/test_presets.py new file mode 100644 index 00000000..63da239c --- /dev/null +++ b/gui/tests/test_presets.py @@ -0,0 +1,73 @@ +import unittest + +from gui.hardware import MacInfo, detect_mac_info +from gui.presets import preset_for, recommended_preset_name + + +class PresetTests(unittest.TestCase): + def setUp(self) -> None: + self.m4_pro = MacInfo( + chip="Apple M4 Pro", + memory_gib=48.0, + architecture="arm64", + metal_support="Metal supported", + ) + + def test_m4_pro_48_gib_defaults_to_fast_identity_preview(self) -> None: + self.assertEqual(recommended_preset_name(self.m4_pro), "fast") + + preset = preset_for("fast", self.m4_pro) + + self.assertEqual((preset.width, preset.height), (512, 512)) + self.assertEqual((preset.render_width, preset.render_height), (320, 320)) + self.assertEqual(preset.seconds, 2) + self.assertEqual(preset.steps, 6) + self.assertEqual(preset.layers, 40) + self.assertEqual(preset.reuse, 1) + self.assertTrue(preset.ssd_streaming) + self.assertFalse(preset.live_preview) + + def test_live_preview_is_never_enabled_automatically(self) -> None: + large_mac = MacInfo( + chip="Apple M5 Max", + memory_gib=128.0, + architecture="arm64", + metal_support="Metal 4", + ) + + for name in ("fast", "balanced", "quality"): + self.assertFalse(preset_for(name, large_mac).live_preview) + + self.assertEqual(recommended_preset_name(large_mac), "balanced") + + def test_low_memory_mac_gets_a_smaller_fast_canvas(self) -> None: + small_mac = MacInfo( + chip="Apple M3", + memory_gib=24.0, + architecture="arm64", + metal_support="Metal supported", + ) + + preset = preset_for("fast", small_mac) + + self.assertEqual((preset.width, preset.height), (256, 256)) + self.assertEqual((preset.render_width, preset.render_height), (256, 256)) + self.assertEqual(preset.steps, 4) + + def test_detects_mac_characteristics_from_system_commands(self) -> None: + outputs = { + ("sysctl", "-n", "machdep.cpu.brand_string"): "Apple M4 Pro\n", + ("sysctl", "-n", "hw.memsize"): "51539607552\n", + ("uname", "-m"): "arm64\n", + ("system_profiler", "SPDisplaysDataType"): ( + "Chipset Model: Apple M4 Pro\nMetal: Supported\n" + ), + } + + info = detect_mac_info(lambda command: outputs[tuple(command)]) + + self.assertEqual(info, self.m4_pro) + + +if __name__ == "__main__": + unittest.main() diff --git a/gui/tests/test_runner.py b/gui/tests/test_runner.py new file mode 100644 index 00000000..655d51b3 --- /dev/null +++ b/gui/tests/test_runner.py @@ -0,0 +1,271 @@ +import io +import threading +import unittest +from pathlib import Path + +from gui.runner import ( + GenerationSettings, + H3Runner, + ProgressTracker, + RunnerCallbacks, + build_h3_command, +) + + +def example_settings() -> GenerationSettings: + return GenerationSettings( + model_dir=Path("/models/MiniMax-H3"), + prompt="Un test", + output_path=Path("/tmp/h3-test-output.mp4"), + reference_image=None, + width=512, + height=512, + render_width=320, + render_height=320, + seconds=2, + steps=6, + layers=40, + reuse=1, + core_reuse=1, + seed=42, + ssd_streaming=True, + live_preview=True, + preview_dir=Path("/tmp/h3-previews"), + ) + + +class FakeProcess: + def __init__(self, output: str, *, blocked: bool = False) -> None: + self.stdout = io.StringIO(output) + self.terminated = False + self.killed = False + self._done = threading.Event() + self.returncode: int | None = None + if not blocked: + self.returncode = 0 + self._done.set() + + def wait(self) -> int: + self._done.wait(2) + return self.returncode if self.returncode is not None else -1 + + def poll(self) -> int | None: + return self.returncode + + def terminate(self) -> None: + self.terminated = True + self.returncode = 130 + self._done.set() + + def kill(self) -> None: + self.killed = True + self.returncode = -9 + self._done.set() + + +class CommandTests(unittest.TestCase): + def test_builds_reference_video_command_without_a_shell(self) -> None: + settings = GenerationSettings( + model_dir=Path("/models/MiniMax-H3"), + prompt="Michela cammina sulla spiaggia e sorride.", + output_path=Path("/videos/michela.mp4"), + reference_image=Path("/images/michela.png"), + width=512, + height=512, + render_width=320, + render_height=320, + seconds=2, + steps=6, + layers=40, + reuse=1, + core_reuse=1, + seed=42, + ssd_streaming=True, + live_preview=False, + additional_reference_images=( + Path("/images/michela-profile.png"), + Path("/images/michela-full-body.png"), + ), + reference_image_size="match", + token_reduction=True, + use_int8_row_fc2=True, + ) + + command = build_h3_command(Path("/repo/h3"), settings) + + self.assertEqual( + command, + [ + "/repo/h3", + "--profile", + "-d", + "/models/MiniMax-H3", + "--ref-image", + "/images/michela.png", + "--ref-image", + "/images/michela-profile.png", + "--ref-image", + "/images/michela-full-body.png", + "--ref-image-size", + "match", + "-p", + "Michela cammina sulla spiaggia e sorride.", + "--width", + "512", + "--height", + "512", + "--render-width", + "320", + "--render-height", + "320", + "--seconds", + "2", + "--steps", + "6", + "--layers", + "40", + "--reuse", + "1", + "--seed", + "42", + "--ssd-streaming", + "--token-reduction", + "--use-int8-row-fc2", + "-o", + "/videos/michela.mp4", + ], + ) + + +class ProgressTests(unittest.TestCase): + def test_estimates_remaining_time_from_completed_steps(self) -> None: + tracker = ProgressTracker() + + first = tracker.consume("\rdenoise 0/6 ", now=100.0) + second = tracker.consume("\rdenoise 1/6 ", now=110.0) + + self.assertIsNotNone(first) + self.assertIsNotNone(second) + assert second is not None + self.assertEqual(second.phase, "denoise") + self.assertEqual(second.stage, "Generation") + self.assertEqual((second.completed, second.total), (1, 6)) + self.assertAlmostEqual(second.percent, 350 / 9) + self.assertAlmostEqual(second.eta_seconds or 0.0, 110 / 7) + + def test_ignores_regular_diagnostic_output(self) -> None: + tracker = ProgressTracker() + + self.assertIsNone( + tracker.consume("h3: video VAE cache miss; decoder retained", now=2.0) + ) + + def test_maps_log_phases_to_three_global_progress_segments(self) -> None: + tracker = ProgressTracker() + + tracker.consume("tokenizer 0/1", now=100.0) + preparation = tracker.consume("video VAE encoder 154/154", now=120.0) + generation = tracker.consume("denoise 3/6", now=160.0) + export_started = tracker.consume("FFmpeg 0/96", now=190.0) + completed = tracker.consume("FFmpeg 96/96", now=200.0) + + assert preparation is not None + assert generation is not None + assert export_started is not None + assert completed is not None + self.assertEqual(preparation.stage, "Preparation") + self.assertLess(preparation.percent, 100 / 3) + self.assertGreater(preparation.eta_seconds or 0.0, 0.0) + self.assertEqual(generation.stage, "Generation") + self.assertGreaterEqual(generation.percent, 100 / 3) + self.assertLess(generation.percent, 200 / 3) + self.assertEqual(export_started.stage, "Decode & export") + self.assertGreaterEqual(export_started.percent, 200 / 3) + self.assertEqual(completed.percent, 100.0) + self.assertEqual(completed.eta_seconds, 0.0) + + def test_consecutive_reference_phase_resets_continue_to_advance(self) -> None: + tracker = ProgressTracker() + + updates = [ + tracker.consume("tokenizer 0/1", now=0.0), + tracker.consume("video VAE encoder 154/154", now=10.0), + tracker.consume("video VAE encoder 0/154", now=20.0), + tracker.consume("video VAE encoder 77/154", now=30.0), + tracker.consume("video VAE encoder 154/154", now=40.0), + tracker.consume("Qwen vision 80/80", now=50.0), + ] + progress = [item for item in updates if item is not None] + + self.assertGreater(progress[3].percent, progress[1].percent) + self.assertGreater(progress[4].percent, progress[3].percent) + self.assertGreater(progress[5].percent, progress[4].percent) + + def test_eta_recovers_from_an_early_underestimate(self) -> None: + tracker = ProgressTracker() + + tracker.consume("tokenizer 0/1", now=0.0) + early = tracker.consume("tokenizer 1/1", now=1.0) + delayed = tracker.consume("video VAE encoder 1/154", now=100.0) + + assert early is not None + assert delayed is not None + self.assertGreater(early.eta_seconds or 0.0, 1.0) + self.assertGreater(delayed.eta_seconds or 0.0, 1.0) + + +class RunnerTests(unittest.TestCase): + def test_reports_progress_preview_and_completion(self) -> None: + process = FakeProcess( + "\rdenoise 0/6 " + "\rdenoise 1/6 \n" + "h3: preview-file /tmp/h3-previews/preview-0001.ppm\n" + ) + done = threading.Event() + progress = [] + previews = [] + results = [] + runner = H3Runner( + Path("/repo/h3"), + process_factory=lambda command, cwd: process, + ) + + runner.start( + example_settings(), + RunnerCallbacks( + on_progress=progress.append, + on_output=lambda line: None, + on_preview=previews.append, + on_finished=lambda result: (results.append(result), done.set()), + ), + ) + + self.assertTrue(done.wait(2)) + self.assertEqual([(item.completed, item.total) for item in progress], [(0, 6), (1, 6)]) + self.assertEqual(previews, [Path("/tmp/h3-previews/preview-0001.ppm")]) + self.assertEqual(results[0].exit_code, 0) + self.assertFalse(results[0].cancelled) + + def test_stop_requests_process_termination(self) -> None: + process = FakeProcess("", blocked=True) + done = threading.Event() + runner = H3Runner( + Path("/repo/h3"), + process_factory=lambda command, cwd: process, + ) + callbacks = RunnerCallbacks( + on_progress=lambda update: None, + on_output=lambda line: None, + on_preview=lambda path: None, + on_finished=lambda result: done.set(), + ) + + runner.start(example_settings(), callbacks) + runner.stop() + + self.assertTrue(done.wait(2)) + self.assertTrue(process.terminated) + + +if __name__ == "__main__": + unittest.main() diff --git a/gui/tests/test_widgets.py b/gui/tests/test_widgets.py new file mode 100644 index 00000000..202138f1 --- /dev/null +++ b/gui/tests/test_widgets.py @@ -0,0 +1,57 @@ +import os +import struct +import tempfile +import unittest +from pathlib import Path + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtGui import QColor, QImage +from PySide6.QtWidgets import QApplication, QLabel + + +def write_rotated_jpeg(path: Path) -> None: + image = QImage(40, 20, QImage.Format.Format_RGB32) + image.fill(QColor("#42c98c")) + if not image.save(str(path), "JPEG"): + raise AssertionError("Could not create the orientation test image") + jpeg = path.read_bytes() + tiff = ( + b"MM\x00\x2a\x00\x00\x00\x08" + b"\x00\x01" + b"\x01\x12\x00\x03\x00\x00\x00\x01\x00\x06\x00\x00" + b"\x00\x00\x00\x00" + ) + payload = b"Exif\x00\x00" + tiff + app1 = b"\xff\xe1" + struct.pack(">H", len(payload) + 2) + payload + path.write_bytes(jpeg[:2] + app1 + jpeg[2:]) + + +class ReferenceInputPreviewTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.app = QApplication.instance() or QApplication([]) + + def test_applies_embedded_orientation_before_showing_reference(self) -> None: + from gui.widgets import ReferenceInputPreview + + with tempfile.TemporaryDirectory() as directory: + reference = Path(directory) / "portrait.jpg" + write_rotated_jpeg(reference) + preview = ReferenceInputPreview() + + preview.set_references((reference,)) + QApplication.processEvents() + + card_item = preview.cards_layout.itemAtPosition(0, 0) + assert card_item is not None + card = card_item.widget() + assert card is not None + image_label = card.findChildren(QLabel)[0] + pixmap = image_label.pixmap() + self.assertFalse(pixmap.isNull()) + self.assertGreater(pixmap.height(), pixmap.width()) + + +if __name__ == "__main__": + unittest.main() diff --git a/gui/tests/test_window.py b/gui/tests/test_window.py new file mode 100644 index 00000000..d927152b --- /dev/null +++ b/gui/tests/test_window.py @@ -0,0 +1,623 @@ +import os +import tempfile +import unittest +from pathlib import Path + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +try: + from PySide6.QtCore import QSettings + from PySide6.QtGui import QColor, QImage + from PySide6.QtWidgets import QApplication +except ImportError: # pragma: no cover - exercised without the optional GUI deps + QApplication = None + QColor = None + QImage = None + QSettings = None + +from gui.hardware import MacInfo + + +@unittest.skipIf(QApplication is None, "PySide6 is not installed") +class MainWindowTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + assert QApplication is not None + cls.app = QApplication.instance() or QApplication([]) + + def test_fast_preset_produces_expected_generation_settings(self) -> None: + from gui.window import MainWindow + + with tempfile.TemporaryDirectory() as directory: + repo = Path(directory) + runner = FakeRunner() + window = MainWindow( + repo_root=repo, + mac_info=MacInfo("Apple M4 Pro", 48.0, "arm64", "Metal 3"), + runner=runner, + load_preferences=False, + ) + window.set_paths( + model_dir=repo / "MiniMax-H3", + reference_image=repo / "michela.png", + output_path=repo / "outputs" / "video.mp4", + ) + window.set_prompt("Michela cammina sulla spiaggia.") + window.apply_preset("fast") + + settings = window.generation_settings() + + self.assertEqual((settings.width, settings.height), (512, 512)) + self.assertEqual( + (settings.render_width, settings.render_height), (320, 320) + ) + self.assertEqual((settings.seconds, settings.steps), (2, 6)) + self.assertEqual((settings.layers, settings.reuse), (40, 1)) + self.assertTrue(settings.ssd_streaming) + self.assertFalse(settings.live_preview) + window.close() + + def test_fast_portrait_render_preserves_output_aspect_ratio(self) -> None: + from gui.window import MainWindow + + with tempfile.TemporaryDirectory() as directory: + repo = Path(directory) + window = MainWindow( + repo_root=repo, + mac_info=MacInfo("Apple M4 Pro", 48.0, "arm64", "Metal supported"), + runner=FakeRunner(), + load_preferences=False, + ) + window.apply_preset("fast") + window.select_format(480, 864) + + settings = window.generation_settings() + + assert settings.render_width is not None + assert settings.render_height is not None + self.assertEqual( + settings.width * settings.render_height, + settings.height * settings.render_width, + ) + self.assertEqual( + (settings.render_width, settings.render_height), (320, 576) + ) + window.close() + + def test_additional_reference_images_keep_their_picture_order(self) -> None: + from gui.window import MainWindow + + with tempfile.TemporaryDirectory() as directory: + repo = Path(directory) + primary = repo / "front.png" + profile = repo / "profile.png" + full_body = repo / "full-body.png" + for image in (primary, profile, full_body): + image.touch() + window = MainWindow( + repo_root=repo, + mac_info=MacInfo("Apple M4 Pro", 48.0, "arm64", "Metal 4"), + runner=FakeRunner(), + load_preferences=False, + ) + window.set_paths( + model_dir=repo / "MiniMax-H3", + reference_image=primary, + output_path=repo / "outputs" / "video.mp4", + ) + + window.add_reference_images((profile, full_body)) + settings = window.generation_settings() + + self.assertEqual( + settings.reference_images, + (primary, profile.resolve(), full_body.resolve()), + ) + self.assertEqual(window.additional_references_list.count(), 2) + + window.additional_references_list.setCurrentRow(1) + window.move_reference_up_button.click() + self.assertEqual( + window.generation_settings().reference_images, + (primary, full_body.resolve(), profile.resolve()), + ) + window.remove_reference_button.click() + self.assertEqual( + window.generation_settings().reference_images, + (primary, profile.resolve()), + ) + window.close() + + def test_input_preview_shows_all_selected_reference_images(self) -> None: + from gui.window import MainWindow + + assert QApplication is not None + assert QColor is not None + assert QImage is not None + with tempfile.TemporaryDirectory() as directory: + repo = Path(directory) + first = repo / "front.png" + second = repo / "profile.png" + for path, color in ((first, "#42c98c"), (second, "#e9b866")): + image = QImage(320, 240, QImage.Format.Format_RGB32) + image.fill(QColor(color)) + self.assertTrue(image.save(str(path), "PNG")) + window = MainWindow( + repo_root=repo, + mac_info=MacInfo("Apple M4 Pro", 48.0, "arm64", "Metal 4"), + runner=FakeRunner(), + load_preferences=False, + ) + + window.set_reference_image(first) + window.add_reference_images((second,)) + QApplication.processEvents() + + self.assertEqual( + window.input_preview.reference_paths, + (first.resolve(), second.resolve()), + ) + self.assertIs(window.preview_tabs.currentWidget(), window.input_preview) + window.close() + + def test_primary_reference_cannot_exceed_the_nine_picture_limit(self) -> None: + from gui.window import MainWindow + + with tempfile.TemporaryDirectory() as directory: + repo = Path(directory) + images = tuple(repo / f"reference-{index}.png" for index in range(10)) + for image in images: + image.touch() + window = MainWindow( + repo_root=repo, + mac_info=MacInfo("Apple M4 Pro", 48.0, "arm64", "Metal supported"), + runner=FakeRunner(), + load_preferences=False, + ) + window.add_reference_images(images[:9]) + + with self.assertRaisesRegex(ValueError, "at most 9"): + window.set_reference_image(images[9]) + + self.assertEqual(window.reference_edit.text(), "") + self.assertEqual(len(window.generation_settings().reference_images), 9) + self.assertIn( + "Picture 9", + window.additional_references_list.item(8).text(), + ) + window.close() + + def test_blank_output_is_rejected_before_settings_are_built(self) -> None: + from gui.window import MainWindow + + with tempfile.TemporaryDirectory() as directory: + repo = Path(directory) + window = MainWindow( + repo_root=repo, + mac_info=MacInfo("Apple M4 Pro", 48.0, "arm64", "Metal supported"), + runner=FakeRunner(), + load_preferences=False, + ) + window.output_edit.clear() + + with self.assertRaisesRegex(ValueError, "output"): + window.generation_settings() + window.close() + + def test_selecting_heic_reference_uses_the_converted_png(self) -> None: + from gui.window import MainWindow + + conversions: list[tuple[Path, Path]] = [] + with tempfile.TemporaryDirectory() as directory: + repo = Path(directory) + source = repo / "IMG_4621.HEIC" + source.write_bytes(b"heic") + converted = repo / "outputs" / "reference-images" / "IMG_4621.png" + + def fake_converter(image: Path, output_dir: Path) -> Path: + conversions.append((image, output_dir)) + converted.parent.mkdir(parents=True) + converted.write_bytes(b"png") + return converted + + window = MainWindow( + repo_root=repo, + mac_info=MacInfo("Apple M4 Pro", 48.0, "arm64", "Metal supported"), + runner=FakeRunner(), + load_preferences=False, + reference_converter=fake_converter, + ) + chosen_output = repo / "custom-output" / "video.mp4" + window.output_edit.setText(str(chosen_output)) + + selected = window.set_reference_image(source) + + self.assertEqual(selected, converted) + self.assertEqual(window.reference_edit.text(), str(converted)) + self.assertEqual( + conversions, + [(source, chosen_output.resolve().parent / "reference-images")], + ) + self.assertIn("HEIC converted", window.reference_status.text()) + window.close() + + def test_changing_video_output_reconverts_heic_beside_new_output(self) -> None: + from gui.window import MainWindow + + conversions: list[Path] = [] + with tempfile.TemporaryDirectory() as directory: + repo = Path(directory).resolve() + (repo / "h3").touch() + model = repo / "MiniMax-H3" + model.mkdir() + source = repo / "IMG_4621.HEIC" + source.write_bytes(b"heic") + + def fake_converter(image: Path, output_dir: Path) -> Path: + conversions.append(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + converted = output_dir / "IMG_4621-converted.png" + converted.write_bytes(b"png") + return converted + + runner = FakeRunner() + window = MainWindow( + repo_root=repo, + mac_info=MacInfo("Apple M4 Pro", 48.0, "arm64", "Metal supported"), + runner=runner, + load_preferences=False, + reference_converter=fake_converter, + ) + first_output = repo / "first" / "video.mp4" + second_output = repo / "second" / "video.mp4" + window.set_paths( + model_dir=model, + reference_image=None, + output_path=first_output, + ) + window.set_prompt("Michela cammina sulla spiaggia.") + window.set_reference_image(source) + window.output_edit.setText(str(second_output)) + + window.generate_button.click() + + self.assertEqual( + conversions, + [ + first_output.parent / "reference-images", + second_output.parent / "reference-images", + ], + ) + self.assertEqual( + runner.settings.reference_image, + second_output.parent + / "reference-images" + / "IMG_4621-converted.png", + ) + runner.running = False + window.close() + + def test_all_heic_references_follow_the_final_output_folder(self) -> None: + from gui.window import MainWindow + + conversions: list[tuple[str, Path]] = [] + with tempfile.TemporaryDirectory() as directory: + repo = Path(directory).resolve() + (repo / "h3").touch() + model = repo / "MiniMax-H3" + model.mkdir() + front = repo / "front.HEIC" + profile = repo / "profile.HEIC" + front.write_bytes(b"heic") + profile.write_bytes(b"heic") + + def fake_converter(image: Path, output_dir: Path) -> Path: + conversions.append((image.name, output_dir)) + output_dir.mkdir(parents=True, exist_ok=True) + converted = output_dir / f"{image.stem}.png" + converted.write_bytes(b"png") + return converted + + runner = FakeRunner() + window = MainWindow( + repo_root=repo, + mac_info=MacInfo("Apple M4 Pro", 48.0, "arm64", "Metal 4"), + runner=runner, + load_preferences=False, + reference_converter=fake_converter, + ) + first_output = repo / "first" / "video.mp4" + final_output = repo / "final" / "video.mp4" + window.set_paths( + model_dir=model, + reference_image=None, + output_path=first_output, + ) + window.set_reference_image(front) + window.add_reference_images((profile,)) + window.set_prompt("Picture 1 and Picture 2 show the same person.") + window.output_edit.setText(str(final_output)) + + window.generate_button.click() + + self.assertEqual( + runner.settings.reference_images, + ( + final_output.parent / "reference-images" / "front.png", + final_output.parent / "reference-images" / "profile.png", + ), + ) + self.assertEqual( + conversions[-2:], + [ + ("front.HEIC", final_output.parent / "reference-images"), + ("profile.HEIC", final_output.parent / "reference-images"), + ], + ) + runner.running = False + window.close() + + def test_persists_technical_settings_but_starts_with_fresh_content(self) -> None: + from gui.window import MainWindow + + assert QSettings is not None + with tempfile.TemporaryDirectory() as directory: + repo = Path(directory) + preferences_path = repo / "preferences.ini" + preferences = QSettings( + str(preferences_path), QSettings.Format.IniFormat + ) + first = MainWindow( + repo_root=repo, + mac_info=MacInfo("Apple M5 Max", 48.0, "arm64", "Metal 4"), + runner=FakeRunner(), + settings_store=preferences, + ) + first.apply_preset("fast") + first.select_format(480, 864) + first.select_duration(4) + first.live_preview_check.setChecked(True) + first.steps_spin.setValue(12) + first.seed_spin.setValue(99) + first.ssd_streaming_check.setChecked(False) + first.reference_size_combo.setCurrentIndex(1) + first.token_reduction_check.setChecked(True) + first.int8_row_fc2_check.setChecked(True) + reference = repo / "previous-reference.png" + reference.touch() + first.set_reference_image(reference) + first.set_prompt("This prompt belongs only to the current session.") + first.close() + preferences.sync() + + restored = MainWindow( + repo_root=repo, + mac_info=MacInfo("Apple M5 Max", 48.0, "arm64", "Metal 4"), + runner=FakeRunner(), + settings_store=QSettings( + str(preferences_path), QSettings.Format.IniFormat + ), + ) + settings = restored.generation_settings() + + self.assertEqual((settings.width, settings.height), (480, 864)) + self.assertEqual(settings.seconds, 4) + self.assertEqual(settings.steps, 12) + self.assertEqual(settings.seed, 99) + self.assertTrue(settings.live_preview) + self.assertFalse(settings.ssd_streaming) + self.assertEqual(settings.reference_image_size, "match") + self.assertTrue(settings.token_reduction) + self.assertTrue(settings.use_int8_row_fc2) + self.assertEqual(restored.prompt_edit.toPlainText(), "") + self.assertEqual(restored.reference_edit.text(), "") + self.assertEqual(settings.reference_images, ()) + self.assertEqual(restored.input_preview.reference_paths, ()) + restored.close() + + def test_legacy_prompt_and_reference_preferences_are_erased_on_load(self) -> None: + from gui.window import MainWindow + + assert QSettings is not None + with tempfile.TemporaryDirectory() as directory: + repo = Path(directory) + preferences_path = repo / "preferences.ini" + preferences = QSettings( + str(preferences_path), + QSettings.Format.IniFormat, + ) + preferences.setValue("prompt", "A private previous prompt") + preferences.setValue("reference_image", "/private/previous.png") + preferences.setValue("reference_source", "/private/previous.HEIC") + preferences.sync() + + window = MainWindow( + repo_root=repo, + mac_info=MacInfo("Apple M4 Pro", 48.0, "arm64", "Metal 4"), + runner=FakeRunner(), + settings_store=preferences, + ) + + self.assertEqual(window.prompt_edit.toPlainText(), "") + self.assertEqual(window.reference_edit.text(), "") + for key in ("prompt", "reference_image", "reference_source"): + self.assertIsNone(preferences.value(key)) + window.close() + + def test_int8_row_fc2_requires_m5_metal4_and_ssd_streaming_off(self) -> None: + from gui.window import MainWindow + + with tempfile.TemporaryDirectory() as directory: + repo = Path(directory) + m4_window = MainWindow( + repo_root=repo, + mac_info=MacInfo("Apple M4 Pro", 48.0, "arm64", "Metal 4"), + runner=FakeRunner(), + load_preferences=False, + ) + self.assertFalse(m4_window.int8_row_fc2_check.isEnabled()) + self.assertIn("M5", m4_window.int8_row_fc2_check.toolTip()) + m4_window.close() + + m5_window = MainWindow( + repo_root=repo, + mac_info=MacInfo( + "Apple M5 Max", + 64.0, + "arm64", + "Metal supported", + ), + runner=FakeRunner(), + load_preferences=False, + ) + self.assertFalse(m5_window.int8_row_fc2_check.isEnabled()) + m5_window.ssd_streaming_check.setChecked(False) + self.assertTrue(m5_window.int8_row_fc2_check.isEnabled()) + m5_window.int8_row_fc2_check.setChecked(True) + m5_window.ssd_streaming_check.setChecked(True) + self.assertFalse(m5_window.int8_row_fc2_check.isChecked()) + self.assertFalse(m5_window.int8_row_fc2_check.isEnabled()) + m5_window.close() + + def test_live_preview_appears_and_resizes_with_the_window(self) -> None: + from gui.window import MainWindow + + assert QApplication is not None + assert QColor is not None + assert QImage is not None + with tempfile.TemporaryDirectory() as directory: + repo = Path(directory).resolve() + (repo / "h3").touch() + model = repo / "MiniMax-H3" + model.mkdir() + reference = repo / "reference.png" + reference.touch() + output = repo / "outputs" / "video.mp4" + preview = repo / "preview.ppm" + image = QImage(800, 600, QImage.Format.Format_RGB32) + image.fill(QColor("#42c98c")) + self.assertTrue(image.save(str(preview), "PPM")) + runner = FakeRunner() + window = MainWindow( + repo_root=repo, + mac_info=MacInfo("Apple M4 Pro", 48.0, "arm64", "Metal 4"), + runner=runner, + load_preferences=False, + ) + window.set_paths( + model_dir=model, + reference_image=reference, + output_path=output, + ) + window.set_prompt("A person powers up.") + window.live_preview_check.setChecked(True) + window.resize(820, 650) + window.show() + QApplication.processEvents() + window.generate_button.click() + + runner.callbacks.on_preview(preview) + QApplication.processEvents() + first_pixmap = window.preview_label.pixmap() + self.assertIs(window.preview_stack.currentWidget(), window.preview_label) + self.assertFalse(first_pixmap.isNull()) + first_width = first_pixmap.width() + + window.resize(1200, 900) + QApplication.processEvents() + resized_pixmap = window.preview_label.pixmap() + + self.assertGreater(resized_pixmap.width(), first_width) + runner.running = False + window.close() + + def test_custom_opens_advanced_controls_and_layout_is_responsive(self) -> None: + from PySide6.QtCore import Qt + + from gui.window import MainWindow + + assert QApplication is not None + with tempfile.TemporaryDirectory() as directory: + window = MainWindow( + repo_root=Path(directory), + mac_info=MacInfo("Apple M4 Pro", 48.0, "arm64", "Metal 4"), + runner=FakeRunner(), + load_preferences=False, + ) + window.show() + original_steps = window.steps_spin.value() + + window.custom_button.click() + + self.assertEqual(window.windowTitle(), "H3 Studio - by pierpaolov") + self.assertTrue(window.custom_button.isChecked()) + self.assertTrue(window.advanced_check.isChecked()) + self.assertTrue(window.advanced_group.isVisible()) + self.assertEqual(window.steps_spin.value(), original_steps) + + window.resize(700, 600) + QApplication.processEvents() + self.assertEqual( + window.content_splitter.orientation(), Qt.Orientation.Vertical + ) + window.resize(1200, 800) + QApplication.processEvents() + self.assertEqual( + window.content_splitter.orientation(), Qt.Orientation.Horizontal + ) + window.close() + + def test_preview_placeholder_reports_failure_before_first_frame(self) -> None: + from gui.runner import RunResult + from gui.window import MainWindow + + assert QApplication is not None + with tempfile.TemporaryDirectory() as directory: + repo = Path(directory).resolve() + (repo / "h3").touch() + model = repo / "MiniMax-H3" + model.mkdir() + output = repo / "outputs" / "video.mp4" + runner = FakeRunner() + window = MainWindow( + repo_root=repo, + mac_info=MacInfo("Apple M4 Pro", 48.0, "arm64", "Metal 4"), + runner=runner, + load_preferences=False, + ) + window.set_paths( + model_dir=model, + reference_image=None, + output_path=output, + ) + window.set_prompt("A person powers up.") + window.live_preview_check.setChecked(True) + window.generate_button.click() + + runner.callbacks.on_finished( + RunResult(exit_code=1, cancelled=False, output_path=output) + ) + QApplication.processEvents() + + self.assertIs( + window.preview_stack.currentWidget(), window.preview_placeholder + ) + self.assertIn("failed", window.preview_placeholder.text().lower()) + runner.running = False + window.close() + + +class FakeRunner: + running = False + + def start(self, settings, callbacks) -> None: + self.settings = settings + self.callbacks = callbacks + self.running = True + + def stop(self) -> None: + self.stopped = True + + +if __name__ == "__main__": + unittest.main() diff --git a/gui/widgets.py b/gui/widgets.py new file mode 100644 index 00000000..f40d5803 --- /dev/null +++ b/gui/widgets.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtCore import Qt +from PySide6.QtGui import QImageReader, QPixmap, QResizeEvent +from PySide6.QtWidgets import ( + QFrame, + QGridLayout, + QHBoxLayout, + QLabel, + QProgressBar, + QScrollArea, + QVBoxLayout, + QWidget, +) + + +class ScalablePreviewLabel(QLabel): + def __init__(self) -> None: + super().__init__() + self._source_pixmap: QPixmap | None = None + + def set_source_pixmap(self, pixmap: QPixmap) -> None: + self._source_pixmap = pixmap + self._fit_source() + + def resizeEvent(self, event: QResizeEvent) -> None: + super().resizeEvent(event) + self._fit_source() + + def _fit_source(self) -> None: + if self._source_pixmap is None or self._source_pixmap.isNull(): + return + self.setPixmap( + self._source_pixmap.scaled( + self.size(), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + ) + + +class ReferenceInputPreview(QWidget): + def __init__(self) -> None: + super().__init__() + self.reference_paths: tuple[Path, ...] = () + layout = QVBoxLayout(self) + layout.setContentsMargins(10, 10, 10, 10) + self.placeholder = QLabel( + "No reference images selected.\n\n" + "Selected inputs will appear here as Picture 1, Picture 2, …" + ) + self.placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.placeholder.setWordWrap(True) + self.placeholder.setObjectName("previewPlaceholder") + layout.addWidget(self.placeholder, 1) + + self.scroll_area = QScrollArea() + self.scroll_area.setObjectName("inputPreviewScroll") + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setFrameShape(QFrame.Shape.NoFrame) + self.cards = QWidget() + self.cards.setObjectName("inputPreviewCards") + self.cards_layout = QGridLayout(self.cards) + self.cards_layout.setContentsMargins(4, 4, 4, 4) + self.cards_layout.setSpacing(10) + self.scroll_area.setWidget(self.cards) + self.scroll_area.setVisible(False) + layout.addWidget(self.scroll_area, 1) + + def set_references(self, paths: tuple[Path, ...]) -> None: + self.reference_paths = paths + while self.cards_layout.count(): + item = self.cards_layout.takeAt(0) + if item is None: + continue + widget = item.widget() + if widget is not None: + widget.hide() + widget.setParent(None) + widget.deleteLater() + self.placeholder.setVisible(not paths) + self.scroll_area.setVisible(bool(paths)) + for index, path in enumerate(paths): + card = QFrame() + card.setObjectName("inputCard") + card_layout = QVBoxLayout(card) + image_label = QLabel() + image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + reader = QImageReader(str(path)) + reader.setAutoTransform(True) + image = reader.read() + pixmap = QPixmap.fromImage(image) + if pixmap.isNull(): + image_label.setText("Preview unavailable") + else: + image_label.setPixmap( + pixmap.scaled( + 240, + 180, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + ) + caption = QLabel(f"Picture {index + 1} · {path.name}") + caption.setAlignment(Qt.AlignmentFlag.AlignCenter) + caption.setWordWrap(True) + card_layout.addWidget(image_label, 1) + card_layout.addWidget(caption) + row, column = divmod(index, 2) + self.cards_layout.addWidget(card, row, column) + self.cards_layout.setColumnStretch(0, 1) + self.cards_layout.setColumnStretch(1, 1) + if paths: + self.cards_layout.setRowStretch((len(paths) - 1) // 2 + 1, 1) + + +class SegmentedProgressBar(QWidget): + def __init__(self) -> None: + super().__init__() + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(4) + self.segments: list[QProgressBar] = [] + for _ in range(3): + segment = QProgressBar() + segment.setRange(0, 1000) + segment.setValue(0) + segment.setTextVisible(False) + layout.addWidget(segment, 1) + self.segments.append(segment) + + def set_overall_percent(self, percent: float) -> None: + bounded = min(100.0, max(0.0, percent)) + for index, segment in enumerate(self.segments): + segment_start = index * (100.0 / 3.0) + local_percent = (bounded - segment_start) * 3.0 + segment.setValue(round(min(100.0, max(0.0, local_percent)) * 10)) + + def reset(self) -> None: + self.set_overall_percent(0.0) + + def complete(self) -> None: + self.set_overall_percent(100.0) diff --git a/gui/window.py b/gui/window.py new file mode 100644 index 00000000..aa5c8dbf --- /dev/null +++ b/gui/window.py @@ -0,0 +1,1188 @@ +from __future__ import annotations + +import tempfile +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from PySide6.QtCore import QObject, QSettings, Qt, QUrl, Signal +from PySide6.QtGui import QDesktopServices, QPixmap, QResizeEvent +from PySide6.QtWidgets import ( + QButtonGroup, + QCheckBox, + QComboBox, + QFileDialog, + QFormLayout, + QFrame, + QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QListWidget, + QMainWindow, + QMessageBox, + QPlainTextEdit, + QPushButton, + QScrollArea, + QSizePolicy, + QSpinBox, + QSplitter, + QStackedWidget, + QTabWidget, + QTextEdit, + QVBoxLayout, + QWidget, +) + +from .hardware import MacInfo +from .image_converter import ( + REFERENCE_IMAGE_FILE_FILTER, + ImageConversionError, + convert_reference_image, + requires_png_conversion, +) +from .presets import PRESET_ORDER, preset_for, recommended_preset_name +from .runner import ( + GenerationSettings, + H3Runner, + ProgressUpdate, + RunResult, + RunnerCallbacks, +) +from .widgets import ReferenceInputPreview, ScalablePreviewLabel, SegmentedProgressBar + + +class RunnerBridge(QObject): + progress = Signal(object) + output = Signal(str) + preview = Signal(str) + finished = Signal(object) + + +@dataclass(slots=True) +class ReferenceInput: + source: Path + converted: Path + + +class MainWindow(QMainWindow): + _transient_preference_keys = ( + "prompt", + "reference_image", + "reference_source", + "reference_images", + ) + + def __init__( + self, + *, + repo_root: Path, + mac_info: MacInfo, + runner: H3Runner | Any, + load_preferences: bool = True, + default_output_dir: Path | None = None, + settings_store: QSettings | None = None, + reference_converter: Callable[[Path, Path], Path] = convert_reference_image, + ) -> None: + super().__init__() + self.repo_root = repo_root.resolve() + self.mac_info = mac_info + self.runner = runner + self.default_output_dir = default_output_dir or self.repo_root / "outputs" + self.reference_converter = reference_converter + self._persistence_enabled = load_preferences + self._settings = settings_store or QSettings("h3-metal", "H3 Studio") + self._preview_temp: tempfile.TemporaryDirectory[str] | None = None + self._last_output: Path | None = None + self._preview_received = False + self._reference_source: Path | None = None + self._converted_reference: Path | None = None + self._additional_references: list[ReferenceInput] = [] + self._active_preset = recommended_preset_name(mac_info) + self._preset_buttons: dict[str, QPushButton] = {} + self._bridge = RunnerBridge(self) + self._bridge.progress.connect(self._on_progress) + self._bridge.output.connect(self._append_log) + self._bridge.preview.connect(self._on_preview) + self._bridge.finished.connect(self._on_finished) + + self.setWindowTitle("H3 Studio - by pierpaolov") + self.setMinimumSize(640, 480) + self.resize(1080, 760) + self._build_ui() + self._update_responsive_layout(self.width()) + self._apply_style() + self.apply_preset(self._active_preset) + self._set_default_paths() + if load_preferences: + self._load_preferences() + + def _build_ui(self) -> None: + content = QWidget(self) + content.setObjectName("contentRoot") + root = QVBoxLayout(content) + root.setContentsMargins(18, 16, 18, 16) + root.setSpacing(12) + self.content_scroll = QScrollArea(self) + self.content_scroll.setObjectName("contentScroll") + self.content_scroll.setWidgetResizable(True) + self.content_scroll.setFrameShape(QFrame.Shape.NoFrame) + self.content_scroll.setWidget(content) + self.setCentralWidget(self.content_scroll) + + hardware = QFrame() + hardware.setObjectName("hardwarePanel") + hardware_layout = QHBoxLayout(hardware) + hardware_layout.setContentsMargins(14, 10, 14, 10) + hardware_text = QVBoxLayout() + title = QLabel(self.mac_info.chip) + title.setObjectName("hardwareTitle") + details = QLabel( + f"{self.mac_info.memory_gib:.0f} GB unified memory · " + f"{self.mac_info.architecture} · {self.mac_info.metal_support}" + ) + details.setObjectName("secondaryText") + hardware_text.addWidget(title) + hardware_text.addWidget(details) + hardware_layout.addLayout(hardware_text) + hardware_layout.addStretch() + self.recommendation_label = QLabel() + self.recommendation_label.setObjectName("recommendation") + hardware_layout.addWidget(self.recommendation_label) + root.addWidget(hardware) + + self.content_splitter = QSplitter(Qt.Orientation.Horizontal) + self.content_splitter.setChildrenCollapsible(False) + self.content_splitter.setHandleWidth(8) + root.addWidget(self.content_splitter, 1) + + controls = QWidget() + controls.setObjectName("controlsPanel") + controls_layout = QVBoxLayout(controls) + controls_layout.setContentsMargins(0, 0, 0, 0) + controls_layout.setSpacing(10) + controls.setMinimumWidth(390) + self.content_splitter.addWidget(controls) + + controls_layout.addWidget(self._section_label("Preset")) + preset_row = QHBoxLayout() + preset_row.setSpacing(6) + preset_group = QButtonGroup(self) + preset_group.setExclusive(True) + for name in PRESET_ORDER: + button = QPushButton(preset_for(name, self.mac_info).label) + button.setCheckable(True) + button.setObjectName("presetButton") + button.clicked.connect(lambda checked=False, key=name: self.apply_preset(key)) + preset_group.addButton(button) + preset_row.addWidget(button) + self._preset_buttons[name] = button + self.custom_button = QPushButton("Custom") + self.custom_button.setCheckable(True) + self.custom_button.setObjectName("presetButton") + self.custom_button.clicked.connect(self.activate_custom) + preset_group.addButton(self.custom_button) + preset_row.addWidget(self.custom_button) + self._preset_buttons["custom"] = self.custom_button + controls_layout.addLayout(preset_row) + self.preset_description = QLabel() + self.preset_description.setWordWrap(True) + self.preset_description.setObjectName("secondaryText") + controls_layout.addWidget(self.preset_description) + + self.model_edit = self._path_field( + controls_layout, + "Model folder", + directory=True, + ) + self.reference_edit = self._path_field( + controls_layout, + "Reference image", + image=True, + optional=True, + ) + self.reference_edit.editingFinished.connect(self._convert_reference_field) + self.reference_status = QLabel( + "iPhone HEIC/HEIF photos are converted to PNG automatically." + ) + self.reference_status.setWordWrap(True) + self.reference_status.setObjectName("secondaryText") + controls_layout.addWidget(self.reference_status) + + controls_layout.addWidget(self._field_label("Prompt")) + self.prompt_edit = QTextEdit() + self.prompt_edit.setAcceptRichText(False) + self.prompt_edit.setPlaceholderText( + "Describe the scene, action, camera, and audio…" + ) + self.prompt_edit.setMinimumHeight(105) + controls_layout.addWidget(self.prompt_edit) + + format_row = QHBoxLayout() + format_form = QFormLayout() + self.format_combo = QComboBox() + self.format_combo.addItem("Compact · 256 × 256", (256, 256)) + self.format_combo.addItem("Square · 512 × 512", (512, 512)) + self.format_combo.addItem("Portrait · 480 × 864", (480, 864)) + self.format_combo.addItem("Landscape · 864 × 480", (864, 480)) + self.format_combo.currentIndexChanged.connect(self._on_format_changed) + format_form.addRow("Format", self.format_combo) + duration_form = QFormLayout() + self.duration_combo = QComboBox() + for seconds in (1, 2, 4, 6, 10): + self.duration_combo.addItem(f"{seconds} seconds", seconds) + duration_form.addRow("Duration", self.duration_combo) + format_row.addLayout(format_form, 1) + format_row.addLayout(duration_form, 1) + controls_layout.addLayout(format_row) + + self.live_preview_check = QCheckBox( + "Live preview during denoising (about +10 GB memory)" + ) + self.live_preview_check.setToolTip("Enable this before starting generation.") + self.live_preview_check.toggled.connect(self._update_preview_warning) + controls_layout.addWidget(self.live_preview_check) + self.preview_warning = QLabel() + self.preview_warning.setWordWrap(True) + self.preview_warning.setObjectName("warningText") + controls_layout.addWidget(self.preview_warning) + + self.advanced_check = QCheckBox("Show advanced parameters") + self.advanced_check.toggled.connect(self._set_advanced_visible) + controls_layout.addWidget(self.advanced_check) + self.advanced_group = QGroupBox("Advanced parameters") + advanced = QGridLayout(self.advanced_group) + self.steps_spin = self._spin(2, 1000) + self.layers_spin = self._spin(1, 50) + self.reuse_spin = self._spin(1, 3) + self.core_reuse_spin = self._spin(1, 20) + self.render_width_spin = self._spin(0, 2048, "Native") + self.render_height_spin = self._spin(0, 2048, "Native") + self.seed_spin = self._spin(0, 2_147_483_647) + self.ssd_streaming_check = QCheckBox("SSD streaming") + self.reference_size_combo = QComboBox() + self.reference_size_combo.addItem("Keep reference detail", "max") + self.reference_size_combo.addItem("Match output canvas", "match") + self.token_reduction_check = QCheckBox("Token reduction") + self.int8_row_fc2_check = QCheckBox("Fast INT8 row FC2 (M5)") + self.ssd_streaming_check.toggled.connect( + self._update_int8_row_fc2_availability + ) + fields = ( + ("Step", self.steps_spin), + ("Layer", self.layers_spin), + ("Reuse", self.reuse_spin), + ("Core reuse", self.core_reuse_spin), + ("Render W", self.render_width_spin), + ("Render H", self.render_height_spin), + ("Seed", self.seed_spin), + ) + for index, (label, widget) in enumerate(fields): + row, column = divmod(index, 3) + box = QVBoxLayout() + box.addWidget(self._field_label(label)) + box.addWidget(widget) + advanced.addLayout(box, row, column) + reference_size_box = QVBoxLayout() + reference_size_box.addWidget(self._field_label("Reference image sizing")) + reference_size_box.addWidget(self.reference_size_combo) + advanced.addLayout(reference_size_box, 3, 0) + advanced.addWidget(self.token_reduction_check, 3, 1) + advanced.addWidget(self.int8_row_fc2_check, 3, 2) + advanced.addWidget(self.ssd_streaming_check, 4, 0, 1, 3) + additional_box = QVBoxLayout() + additional_box.addWidget(self._field_label("Additional reference images")) + self.additional_references_list = QListWidget() + self.additional_references_list.setMinimumHeight(90) + additional_box.addWidget(self.additional_references_list) + additional_actions = QHBoxLayout() + self.add_reference_button = QPushButton("Add images…") + self.remove_reference_button = QPushButton("Remove") + self.move_reference_up_button = QPushButton("Move up") + self.move_reference_down_button = QPushButton("Move down") + self.add_reference_button.clicked.connect(self._choose_additional_references) + self.remove_reference_button.clicked.connect(self._remove_additional_reference) + self.move_reference_up_button.clicked.connect( + lambda: self._move_additional_reference(-1) + ) + self.move_reference_down_button.clicked.connect( + lambda: self._move_additional_reference(1) + ) + for button in ( + self.add_reference_button, + self.remove_reference_button, + self.move_reference_up_button, + self.move_reference_down_button, + ): + additional_actions.addWidget(button) + additional_box.addLayout(additional_actions) + additional_box.addWidget( + self._field_label( + "Up to 9 pictures total. Their order defines Picture 1, Picture 2, …" + ) + ) + advanced.addLayout(additional_box, 5, 0, 1, 3) + self._update_int8_row_fc2_availability() + self.advanced_group.setVisible(False) + controls_layout.addWidget(self.advanced_group) + controls_layout.addStretch() + + right = QWidget() + right.setObjectName("previewColumn") + right_layout = QVBoxLayout(right) + right_layout.setContentsMargins(0, 0, 0, 0) + right_layout.setSpacing(10) + right.setMinimumWidth(360) + self.content_splitter.addWidget(right) + self.content_splitter.setStretchFactor(0, 11) + self.content_splitter.setStretchFactor(1, 9) + self.content_splitter.setSizes((560, 460)) + right_layout.addWidget(self._section_label("Preview")) + + self.preview_tabs = QTabWidget() + self.preview_tabs.setObjectName("previewTabs") + self.input_preview = ReferenceInputPreview() + self.preview_tabs.addTab(self.input_preview, "Inputs") + + self.preview_stack = QStackedWidget() + self.preview_stack.setObjectName("previewPanel") + self.preview_placeholder = QLabel( + "No generation started\n\n" + "Enable Live preview before generating to see denoising frames here." + ) + self.preview_placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.preview_placeholder.setWordWrap(True) + self.preview_placeholder.setObjectName("previewPlaceholder") + self.preview_label = ScalablePreviewLabel() + self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.preview_label.setSizePolicy( + QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored + ) + self.preview_stack.addWidget(self.preview_placeholder) + self.preview_stack.addWidget(self.preview_label) + self.preview_tabs.addTab(self.preview_stack, "Generation") + right_layout.addWidget(self.preview_tabs, 1) + + status_row = QHBoxLayout() + self.phase_label = QLabel("Ready") + self.eta_label = QLabel("ETA —") + self.eta_label.setObjectName("secondaryText") + status_row.addWidget(self.phase_label) + status_row.addStretch() + status_row.addWidget(self.eta_label) + right_layout.addLayout(status_row) + self.progress_bar = SegmentedProgressBar() + right_layout.addWidget(self.progress_bar) + + self.output_edit = self._path_field( + right_layout, + "Output video", + save_file=True, + ) + + log_header = QHBoxLayout() + log_header.addWidget(self._section_label("Log")) + log_header.addStretch() + self.log_toggle = QPushButton("Show") + self.log_toggle.setCheckable(True) + self.log_toggle.toggled.connect(self._toggle_log) + log_header.addWidget(self.log_toggle) + right_layout.addLayout(log_header) + self.log_edit = QPlainTextEdit() + self.log_edit.setReadOnly(True) + self.log_edit.setMaximumBlockCount(500) + self.log_edit.setVisible(False) + self.log_edit.setMaximumHeight(150) + right_layout.addWidget(self.log_edit) + + action_row = QHBoxLayout() + self.generate_button = QPushButton("Generate video") + self.generate_button.setObjectName("primaryButton") + self.generate_button.clicked.connect(self._start_generation) + self.stop_button = QPushButton("Stop") + self.stop_button.setEnabled(False) + self.stop_button.clicked.connect(self._stop_generation) + self.open_button = QPushButton("Open video") + self.open_button.setEnabled(False) + self.open_button.clicked.connect(self._open_output) + action_row.addWidget(self.generate_button, 1) + action_row.addWidget(self.stop_button) + action_row.addWidget(self.open_button) + right_layout.addLayout(action_row) + + def _apply_style(self) -> None: + self.setStyleSheet( + """ + QMainWindow { background: #171816; } + QWidget#contentRoot, QWidget#controlsPanel, QWidget#previewColumn, + QWidget#inputPreviewCards, QScrollArea#contentScroll, + QScrollArea#contentScroll > QWidget > QWidget, + QScrollArea#inputPreviewScroll, + QScrollArea#inputPreviewScroll > QWidget > QWidget { + background: #171816; border: 0; + } + QWidget { color: #f1f3ed; font-size: 13px; } + QFrame#hardwarePanel, QFrame#inputCard, QGroupBox, + QStackedWidget#previewPanel, QTabWidget#previewTabs::pane { + background: #252723; border: 1px solid #41463e; border-radius: 9px; + } + QLabel#hardwareTitle { font-weight: 600; font-size: 15px; } + QLabel#secondaryText { color: #aeb5a7; } + QLabel#recommendation { color: #65c58e; } + QLabel#warningText { color: #e9b866; } + QLabel#previewPlaceholder { color: #aeb5a7; padding: 24px; } + QLineEdit, QTextEdit, QPlainTextEdit, QComboBox, QSpinBox { + background: #222320; border: 1px solid #41463e; border-radius: 6px; + padding: 7px; selection-background-color: #4a9c6d; + } + QPushButton { background: #2c2e2a; border: 1px solid #41463e; + border-radius: 6px; padding: 8px 12px; } + QPushButton:hover { background: #353832; } + QPushButton#presetButton:checked, QPushButton#primaryButton { + background: #65c58e; color: #102218; border-color: #65c58e; + } + QTabBar::tab { background: #2c2e2a; border: 1px solid #41463e; + padding: 7px 14px; } + QTabBar::tab:selected { background: #65c58e; color: #102218; + border-color: #65c58e; } + QProgressBar { background: #353832; border: 0; border-radius: 3px; } + QProgressBar::chunk { background: #65c58e; border-radius: 3px; } + QSplitter::handle { background: #171816; } + QCheckBox { spacing: 8px; } + QGroupBox { margin-top: 8px; padding: 10px; } + QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 4px; } + """ + ) + + def _section_label(self, text: str) -> QLabel: + label = QLabel(text) + font = label.font() + font.setBold(True) + label.setFont(font) + return label + + def _field_label(self, text: str) -> QLabel: + label = QLabel(text) + label.setObjectName("secondaryText") + return label + + def _spin(self, minimum: int, maximum: int, special: str = "") -> QSpinBox: + spin = QSpinBox() + spin.setRange(minimum, maximum) + if special: + spin.setSpecialValueText(special) + return spin + + def _path_field( + self, + layout: QVBoxLayout, + label: str, + *, + directory: bool = False, + image: bool = False, + save_file: bool = False, + optional: bool = False, + ) -> QLineEdit: + suffix = " (optional)" if optional else "" + layout.addWidget(self._field_label(label + suffix)) + row = QHBoxLayout() + edit = QLineEdit() + browse = QPushButton("Choose…") + + def choose() -> None: + if directory: + selected = QFileDialog.getExistingDirectory(self, label, edit.text()) + elif save_file: + selected, _ = QFileDialog.getSaveFileName( + self, label, edit.text(), "MP4 video (*.mp4)" + ) + elif image: + selected, _ = QFileDialog.getOpenFileName( + self, + label, + edit.text(), + REFERENCE_IMAGE_FILE_FILTER, + ) + else: + selected, _ = QFileDialog.getOpenFileName(self, label, edit.text()) + if selected: + if image: + try: + self.set_reference_image(Path(selected)) + except (ImageConversionError, ValueError) as error: + QMessageBox.warning( + self, + "Cannot select reference image", + str(error), + ) + else: + edit.setText(selected) + + browse.clicked.connect(choose) + row.addWidget(edit, 1) + row.addWidget(browse) + layout.addLayout(row) + return edit + + def _set_default_paths(self) -> None: + model = self.repo_root / "MiniMax-H3" + output = self.default_output_dir / "h3-studio.mp4" + self.set_paths( + model_dir=model, + reference_image=None, + output_path=output, + ) + + def set_paths( + self, + *, + model_dir: Path, + reference_image: Path | None, + output_path: Path, + ) -> None: + self._reference_source = None + self._converted_reference = None + self._additional_references.clear() + self._refresh_additional_reference_list() + self.model_edit.setText(str(model_dir)) + self.reference_edit.setText(str(reference_image) if reference_image else "") + self.output_edit.setText(str(output_path)) + self.model_edit.setCursorPosition(0) + self.reference_edit.setCursorPosition(0) + self.output_edit.setCursorPosition(0) + self._refresh_input_preview() + + def set_prompt(self, prompt: str) -> None: + self.prompt_edit.setPlainText(prompt) + + def set_reference_image(self, source: Path) -> Path: + if len(self._additional_references) >= 9: + raise ValueError("H3 supports at most 9 reference images.") + is_heic = requires_png_conversion(source) + converted = self.reference_converter( + source, + self._reference_output_dir(), + ) + self._reference_source = source.expanduser() if is_heic else None + self._converted_reference = converted.expanduser() if is_heic else None + self.reference_edit.setText(str(converted)) + self.reference_edit.setCursorPosition(0) + if is_heic: + self.reference_status.setText( + f"HEIC converted automatically: {converted}" + ) + else: + self.reference_status.setText( + "iPhone HEIC/HEIF photos are converted to PNG automatically." + ) + self._refresh_additional_reference_list() + return converted + + def add_reference_images(self, sources: tuple[Path, ...]) -> tuple[Path, ...]: + primary_count = 1 if self.reference_edit.text().strip() else 0 + if primary_count + len(self._additional_references) + len(sources) > 9: + raise ValueError("H3 supports at most 9 reference images.") + expanded_sources = tuple(source.expanduser().resolve() for source in sources) + new_references: list[ReferenceInput] = [] + for source in expanded_sources: + converted = self.reference_converter( + source, + self._reference_output_dir(), + ).expanduser() + new_references.append(ReferenceInput(source, converted)) + self._additional_references.extend(new_references) + self._refresh_additional_reference_list() + return tuple(reference.converted for reference in new_references) + + def _choose_additional_references(self) -> None: + selected, _ = QFileDialog.getOpenFileNames( + self, + "Add reference images", + "", + REFERENCE_IMAGE_FILE_FILTER, + ) + if not selected: + return + try: + self.add_reference_images(tuple(Path(path) for path in selected)) + except (ImageConversionError, ValueError) as error: + QMessageBox.warning(self, "Cannot add images", str(error)) + + def _refresh_additional_reference_list(self) -> None: + if not hasattr(self, "additional_references_list"): + return + selected_row = self.additional_references_list.currentRow() + self.additional_references_list.clear() + first_picture = 2 if self.reference_edit.text().strip() else 1 + for index, reference in enumerate( + self._additional_references, + start=first_picture, + ): + self.additional_references_list.addItem( + f"Picture {index} · {reference.converted.name}" + ) + if self._additional_references: + self.additional_references_list.setCurrentRow( + min(max(selected_row, 0), len(self._additional_references) - 1) + ) + self._refresh_input_preview() + + def _refresh_input_preview(self) -> None: + if not hasattr(self, "input_preview"): + return + primary_text = self.reference_edit.text().strip() + paths = ( + (Path(primary_text).expanduser(),) if primary_text else () + ) + tuple(reference.converted for reference in self._additional_references) + self.input_preview.set_references(paths) + self.preview_tabs.setCurrentWidget(self.input_preview) + + def _remove_additional_reference(self) -> None: + row = self.additional_references_list.currentRow() + if row < 0: + return + del self._additional_references[row] + self._refresh_additional_reference_list() + + def _move_additional_reference(self, offset: int) -> None: + row = self.additional_references_list.currentRow() + destination = row + offset + if row < 0 or destination < 0 or destination >= len( + self._additional_references + ): + return + references = self._additional_references + references[row], references[destination] = ( + references[destination], + references[row], + ) + self._refresh_additional_reference_list() + self.additional_references_list.setCurrentRow(destination) + + def _convert_additional_references(self) -> bool: + output_dir = self._reference_output_dir().resolve() + converted_any = False + for reference in self._additional_references: + if not requires_png_conversion(reference.source): + continue + if ( + reference.converted.resolve().parent == output_dir + and reference.converted.is_file() + ): + continue + try: + reference.converted = self.reference_converter( + reference.source, + output_dir, + ).expanduser() + except ImageConversionError as error: + QMessageBox.critical(self, "HEIC conversion failed", str(error)) + return False + converted_any = True + if converted_any: + self._refresh_additional_reference_list() + return True + + def _reference_output_dir(self) -> Path: + output_text = self.output_edit.text().strip() + output_dir = ( + Path(output_text).expanduser().resolve().parent + if output_text + else self.default_output_dir + ) + return output_dir / "reference-images" + + def _convert_reference_field(self) -> bool: + text = self.reference_edit.text().strip() + if not text: + self._reference_source = None + self._converted_reference = None + self._refresh_additional_reference_list() + return True + if len(self._additional_references) >= 9: + self.reference_edit.clear() + self._refresh_additional_reference_list() + QMessageBox.warning( + self, + "Cannot select reference image", + "H3 supports at most 9 reference images.", + ) + return False + current = Path(text).expanduser() + if not requires_png_conversion(current): + if ( + self._reference_source is None + or self._converted_reference is None + or current.resolve() != self._converted_reference.resolve() + ): + self._reference_source = None + self._converted_reference = None + self._refresh_additional_reference_list() + return True + if current.resolve().parent == self._reference_output_dir().resolve(): + self._refresh_additional_reference_list() + return True + source = ( + current if requires_png_conversion(current) else self._reference_source + ) + if source is None: + return True + try: + self.set_reference_image(source) + except ImageConversionError as error: + QMessageBox.critical(self, "HEIC conversion failed", str(error)) + return False + return True + + def apply_preset(self, name: str) -> None: + preset = preset_for(name, self.mac_info) + self._active_preset = name + self._preset_buttons[name].setChecked(True) + self.select_format(preset.width, preset.height) + self.preset_description.setText(preset.description) + self.recommendation_label.setText( + f"Recommended preset: {preset_for(recommended_preset_name(self.mac_info), self.mac_info).label}" + ) + self.steps_spin.setValue(preset.steps) + self.layers_spin.setValue(preset.layers) + self.reuse_spin.setValue(preset.reuse) + self.core_reuse_spin.setValue(preset.core_reuse) + self.render_width_spin.setValue(preset.render_width or 0) + self.render_height_spin.setValue(preset.render_height or 0) + self.seed_spin.setValue(42) + self.ssd_streaming_check.setChecked(preset.ssd_streaming) + self.reference_size_combo.setCurrentIndex(0) + self.token_reduction_check.setChecked(False) + self.int8_row_fc2_check.setChecked(False) + index = self.duration_combo.findData(preset.seconds) + if index >= 0: + self.duration_combo.setCurrentIndex(index) + self.live_preview_check.setChecked(preset.live_preview) + self._on_format_changed() + + def activate_custom(self, checked: bool = False) -> None: + del checked + self._active_preset = "custom" + self.custom_button.setChecked(True) + self.preset_description.setText( + "Adjust every generation setting in the advanced panel." + ) + self.advanced_check.setChecked(True) + + def select_format(self, width: int, height: int) -> None: + for index in range(self.format_combo.count()): + if self.format_combo.itemData(index) == (width, height): + self.format_combo.setCurrentIndex(index) + return + raise ValueError(f"unsupported format: {width}x{height}") + + def select_duration(self, seconds: int) -> None: + index = self.duration_combo.findData(seconds) + if index < 0: + raise ValueError(f"unsupported duration: {seconds}") + self.duration_combo.setCurrentIndex(index) + + def _on_format_changed(self) -> None: + if self._active_preset != "fast": + return + width, height = self.format_combo.currentData() + if width == height: + render = (256, 256) if width == 256 else (320, 320) + elif width > height: + render = (576, 320) + else: + render = (320, 576) + self.render_width_spin.setValue(render[0]) + self.render_height_spin.setValue(render[1]) + + def _update_preview_warning(self, enabled: bool) -> None: + self.preview_warning.setText( + "On a 48 GB Mac this can increase memory pressure and slow generation." + if enabled and self.mac_info.memory_gib <= 48 + else "" + ) + + def _supports_int8_row_fc2(self) -> bool: + metal_support = self.mac_info.metal_support.lower() + metal_available = not any( + unavailable in metal_support + for unavailable in ("not detected", "not supported", "unsupported") + ) + return "m5" in self.mac_info.chip.lower() and metal_available + + def _update_int8_row_fc2_availability(self, checked: bool = False) -> None: + del checked + supported = self._supports_int8_row_fc2() + available = supported and not self.ssd_streaming_check.isChecked() + if not available: + self.int8_row_fc2_check.setChecked(False) + self.int8_row_fc2_check.setEnabled(available) + if not supported: + tooltip = "Requires an M5-class Mac with Metal 4." + elif self.ssd_streaming_check.isChecked(): + tooltip = "Turn off SSD streaming to enable this option." + else: + tooltip = "Use the faster M5 row-wise INT8 FC2 path." + self.int8_row_fc2_check.setToolTip(tooltip) + + def _set_advanced_visible(self, visible: bool) -> None: + self.advanced_group.setVisible(visible) + + def _toggle_log(self, visible: bool) -> None: + self.log_edit.setVisible(visible) + self.log_toggle.setText("Hide" if visible else "Show") + + def generation_settings( + self, preview_dir: Path | None = None + ) -> GenerationSettings: + width, height = self.format_combo.currentData() + render_width = self.render_width_spin.value() or None + render_height = self.render_height_spin.value() or None + reference_text = self.reference_edit.text().strip() + model_text = self.model_edit.text().strip() + output_text = self.output_edit.text().strip() + if not model_text: + raise ValueError("Choose the model folder.") + if not output_text: + raise ValueError("Choose the output video file.") + return GenerationSettings( + model_dir=Path(model_text).expanduser(), + prompt=self.prompt_edit.toPlainText().strip(), + output_path=Path(output_text).expanduser(), + reference_image=Path(reference_text).expanduser() if reference_text else None, + width=width, + height=height, + render_width=render_width, + render_height=render_height, + seconds=int(self.duration_combo.currentData()), + steps=self.steps_spin.value(), + layers=self.layers_spin.value(), + reuse=self.reuse_spin.value(), + core_reuse=self.core_reuse_spin.value(), + seed=self.seed_spin.value(), + ssd_streaming=self.ssd_streaming_check.isChecked(), + live_preview=self.live_preview_check.isChecked(), + preview_dir=preview_dir, + additional_reference_images=tuple( + reference.converted for reference in self._additional_references + ), + reference_image_size=str(self.reference_size_combo.currentData()), + token_reduction=self.token_reduction_check.isChecked(), + use_int8_row_fc2=self.int8_row_fc2_check.isChecked(), + ) + + def _validate(self, settings: GenerationSettings) -> str | None: + executable = self.repo_root / "h3" + if not executable.is_file(): + return "The h3 executable was not found. Run ‘make h3’ first." + if not settings.model_dir.is_dir(): + return "The model folder does not exist." + if not settings.prompt: + return "Enter a prompt." + for reference_image in settings.reference_images: + if not reference_image.is_file(): + return f"Reference image does not exist: {reference_image}" + if len(settings.reference_images) > 9: + return "H3 supports at most 9 reference images." + if settings.use_int8_row_fc2 and not self._supports_int8_row_fc2(): + return "Fast INT8 row FC2 requires an M5-class Mac with Metal 4." + if settings.use_int8_row_fc2 and settings.ssd_streaming: + return "Fast INT8 row FC2 cannot be combined with SSD streaming." + if settings.reuse > 1 and settings.core_reuse > 1: + return "Reuse and core reuse cannot both be greater than 1." + return None + + def _start_generation(self) -> None: + if self.runner.running: + return + if ( + not self._convert_reference_field() + or not self._convert_additional_references() + ): + return + preview_dir = None + if self.live_preview_check.isChecked(): + self._preview_temp = tempfile.TemporaryDirectory(prefix="h3-studio-preview-") + preview_dir = Path(self._preview_temp.name) + try: + settings = self.generation_settings(preview_dir) + except ValueError as settings_error: + self._cleanup_preview_temp() + QMessageBox.warning(self, "Cannot generate", str(settings_error)) + return + validation_error = self._validate(settings) + if validation_error: + self._cleanup_preview_temp() + QMessageBox.warning(self, "Cannot generate", validation_error) + return + self.log_edit.clear() + self.progress_bar.reset() + self.phase_label.setText("Starting…") + self.eta_label.setText("ETA —") + self._preview_received = False + self.preview_stack.setCurrentWidget(self.preview_placeholder) + self.preview_placeholder.setText( + "Preparing live preview…\n\n" + "The first frame appears during stage 2/3: Generation." + if settings.live_preview + else "Live preview is off for this generation." + ) + if settings.live_preview: + self.preview_tabs.setCurrentWidget(self.preview_stack) + self.generate_button.setEnabled(False) + self.stop_button.setEnabled(True) + self.open_button.setEnabled(False) + self.live_preview_check.setEnabled(False) + callbacks = RunnerCallbacks( + on_progress=self._bridge.progress.emit, + on_output=self._bridge.output.emit, + on_preview=lambda path: self._bridge.preview.emit(str(path)), + on_finished=self._bridge.finished.emit, + ) + try: + self.runner.start(settings, callbacks) + except (OSError, RuntimeError) as error: + self._on_start_error(str(error)) + + def _on_start_error(self, message: str) -> None: + self.generate_button.setEnabled(True) + self.stop_button.setEnabled(False) + self.live_preview_check.setEnabled(True) + self.phase_label.setText("Start error") + if self.live_preview_check.isChecked() and not self._preview_received: + self.preview_placeholder.setText( + "Live preview is unavailable because generation could not start." + ) + self._cleanup_preview_temp() + QMessageBox.critical(self, "Error", message) + + def _stop_generation(self) -> None: + self.phase_label.setText("Stopping…") + self.stop_button.setEnabled(False) + self.runner.stop() + + def _on_progress(self, update: ProgressUpdate) -> None: + self.phase_label.setText( + f"{update.stage_index + 1}/3 {update.stage} · " + f"{update.phase} {update.completed}/{update.total}" + ) + self.progress_bar.set_overall_percent(update.percent) + if update.eta_seconds is None: + self.eta_label.setText("Calculating ETA…") + else: + self.eta_label.setText(f"ETA {self._format_duration(update.eta_seconds)}") + + def _append_log(self, line: str) -> None: + self.log_edit.appendPlainText(line) + + def _on_preview(self, path_text: str) -> None: + pixmap = QPixmap(path_text) + if pixmap.isNull(): + return + self.preview_label.set_source_pixmap(pixmap) + self.preview_stack.setCurrentWidget(self.preview_label) + self.preview_tabs.setCurrentWidget(self.preview_stack) + self._preview_received = True + + def _on_finished(self, result: RunResult) -> None: + self.generate_button.setEnabled(True) + self.stop_button.setEnabled(False) + self.live_preview_check.setEnabled(True) + if result.cancelled: + self.phase_label.setText("Generation stopped") + elif result.exit_code == 0: + self.progress_bar.complete() + self.phase_label.setText("Video complete") + self.eta_label.setText("ETA 0s") + self._last_output = result.output_path + self.open_button.setEnabled(result.output_path.exists()) + else: + self.phase_label.setText(f"Error · exit code {result.exit_code}") + self.log_edit.setVisible(True) + self.log_toggle.setChecked(True) + if self.live_preview_check.isChecked() and not self._preview_received: + if result.cancelled: + preview_message = "Generation stopped before a preview frame was ready." + elif result.exit_code == 0: + preview_message = "Generation completed without a preview frame." + else: + preview_message = "Live preview failed because generation ended with an error." + self.preview_placeholder.setText(preview_message) + self.preview_stack.setCurrentWidget(self.preview_placeholder) + self._cleanup_preview_temp() + + def _open_output(self) -> None: + if self._last_output and self._last_output.exists(): + QDesktopServices.openUrl(QUrl.fromLocalFile(str(self._last_output))) + + def _cleanup_preview_temp(self) -> None: + if self._preview_temp is not None: + self._preview_temp.cleanup() + self._preview_temp = None + + def _format_duration(self, seconds: float) -> str: + rounded = max(0, round(seconds)) + minutes, remaining = divmod(rounded, 60) + if minutes: + return f"{minutes}m {remaining:02d}s" + return f"{remaining}s" + + def resizeEvent(self, event: QResizeEvent) -> None: + super().resizeEvent(event) + self._update_responsive_layout(event.size().width()) + + def _update_responsive_layout(self, width: int) -> None: + if not hasattr(self, "content_splitter"): + return + orientation = ( + Qt.Orientation.Vertical if width < 900 else Qt.Orientation.Horizontal + ) + if self.content_splitter.orientation() == orientation: + return + self.content_splitter.setOrientation(orientation) + self.content_splitter.setSizes( + (620, 540) if orientation == Qt.Orientation.Vertical else (560, 460) + ) + + def _load_preferences(self) -> None: + for transient_key in self._transient_preference_keys: + self._settings.remove(transient_key) + self._settings.sync() + model = self._setting_text("model_dir", self.model_edit.text()) + output = self._setting_text("output_path", self.output_edit.text()) + preset = self._setting_text("preset", self._active_preset) + if preset == "custom": + self.activate_custom() + elif preset in self._preset_buttons: + self.apply_preset(preset) + width = self._setting_int("format_width", self.format_combo.currentData()[0]) + height = self._setting_int("format_height", self.format_combo.currentData()[1]) + try: + self.select_format(width, height) + except ValueError: + pass + try: + self.select_duration( + self._setting_int("seconds", int(self.duration_combo.currentData())) + ) + except ValueError: + pass + self.steps_spin.setValue(self._setting_int("steps", self.steps_spin.value())) + self.layers_spin.setValue(self._setting_int("layers", self.layers_spin.value())) + self.reuse_spin.setValue(self._setting_int("reuse", self.reuse_spin.value())) + self.core_reuse_spin.setValue( + self._setting_int("core_reuse", self.core_reuse_spin.value()) + ) + self.render_width_spin.setValue( + self._setting_int("render_width", self.render_width_spin.value()) + ) + self.render_height_spin.setValue( + self._setting_int("render_height", self.render_height_spin.value()) + ) + self.seed_spin.setValue(self._setting_int("seed", self.seed_spin.value())) + self.ssd_streaming_check.setChecked( + self._setting_bool("ssd_streaming", self.ssd_streaming_check.isChecked()) + ) + reference_size = self._setting_text( + "reference_image_size", + str(self.reference_size_combo.currentData()), + ) + reference_size_index = self.reference_size_combo.findData(reference_size) + if reference_size_index >= 0: + self.reference_size_combo.setCurrentIndex(reference_size_index) + self.token_reduction_check.setChecked( + self._setting_bool( + "token_reduction", + self.token_reduction_check.isChecked(), + ) + ) + self.int8_row_fc2_check.setChecked( + self.int8_row_fc2_check.isEnabled() + and self._setting_bool("use_int8_row_fc2", False) + ) + self.live_preview_check.setChecked( + self._setting_bool("live_preview", self.live_preview_check.isChecked()) + ) + self.advanced_check.setChecked( + self._setting_bool("advanced_visible", self.advanced_check.isChecked()) + ) + self.model_edit.setText(model) + self.output_edit.setText(output) + self.model_edit.setCursorPosition(0) + self.output_edit.setCursorPosition(0) + + def _setting_text(self, key: str, default: str) -> str: + value = self._settings.value(key, default) + return value if isinstance(value, str) else default + + def _setting_int(self, key: str, default: int) -> int: + value = self._settings.value(key, default) + if not isinstance(value, (int, str)): + return default + try: + return int(value) + except (TypeError, ValueError): + return default + + def _setting_bool(self, key: str, default: bool) -> bool: + value = self._settings.value(key, default) + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.lower() in ("1", "true", "yes", "on") + return default + + def _save_preferences(self) -> None: + if not self._persistence_enabled: + return + self._settings.setValue("model_dir", self.model_edit.text()) + self._settings.setValue("output_path", self.output_edit.text()) + for transient_key in self._transient_preference_keys: + self._settings.remove(transient_key) + self._settings.setValue("preset", self._active_preset) + width, height = self.format_combo.currentData() + self._settings.setValue("format_width", width) + self._settings.setValue("format_height", height) + self._settings.setValue("seconds", self.duration_combo.currentData()) + self._settings.setValue("steps", self.steps_spin.value()) + self._settings.setValue("layers", self.layers_spin.value()) + self._settings.setValue("reuse", self.reuse_spin.value()) + self._settings.setValue("core_reuse", self.core_reuse_spin.value()) + self._settings.setValue("render_width", self.render_width_spin.value()) + self._settings.setValue("render_height", self.render_height_spin.value()) + self._settings.setValue("seed", self.seed_spin.value()) + self._settings.setValue( + "ssd_streaming", self.ssd_streaming_check.isChecked() + ) + self._settings.setValue( + "reference_image_size", + self.reference_size_combo.currentData(), + ) + self._settings.setValue( + "token_reduction", + self.token_reduction_check.isChecked(), + ) + self._settings.setValue( + "use_int8_row_fc2", + self.int8_row_fc2_check.isChecked(), + ) + self._settings.setValue("live_preview", self.live_preview_check.isChecked()) + self._settings.setValue("advanced_visible", self.advanced_check.isChecked()) + self._settings.sync() + + def closeEvent(self, event) -> None: + if self.runner.running: + answer = QMessageBox.question( + self, + "Generation in progress", + "Stop generation and close H3 Studio?", + ) + if answer != QMessageBox.StandardButton.Yes: + event.ignore() + return + self.runner.stop() + self._save_preferences() + self._cleanup_preview_temp() + event.accept() diff --git a/main.c b/main.c index 7f11e470..d1825ef7 100644 --- a/main.c +++ b/main.c @@ -55,6 +55,7 @@ static void usage(const char *program) { " --ref-video-audio VIDEO AUDIO Append video + soundtrack\n" " --ref-audio PATH Append an ordered standalone audio clip\n" " --frames-dir PATH Write generated frames as PPM files\n" + " --preview-dir PATH Write denoising previews as PPM files\n" " --show Display a frame after every denoising step (M5)\n" " --zoom N Terminal image zoom (default: 2 for Retina)\n" " --profile Print per-phase Metal timing and allocation data\n" @@ -150,6 +151,7 @@ typedef struct { h3_terminal_protocol terminal; int display_failed; const char *frames_dir; + const char *preview_dir; int frame_write_failed; } cli_state; @@ -171,35 +173,52 @@ static int cli_progress(const char *phase, int completed, int total, return 0; } +static int write_ppm(const char *path, const h3_frame *frame) { + FILE *output = fopen(path, "wb"); + if (!output || + fprintf(output, "P6\n%d %d\n255\n", frame->width, + frame->height) < 0) { + if (output) fclose(output); + return 0; + } + size_t row_bytes = (size_t)frame->width * 3; + int ok = 1; + for (int row = 0; row < frame->height; row++) { + if (fwrite(frame->rgb + (size_t)row * frame->stride, 1, + row_bytes, output) != row_bytes) { + ok = 0; + break; + } + } + if (fclose(output) != 0) ok = 0; + return ok; +} + static int cli_frame(const h3_frame *frame, void *opaque) { cli_state *state = opaque; int preview = frame->denoise_step >= 0; + if (preview && state->preview_dir && !state->frame_write_failed) { + char path[1024]; + int length = snprintf(path, sizeof(path), "%s/preview-%04d.ppm", + state->preview_dir, frame->denoise_step + 1); + if (length <= 0 || (size_t)length >= sizeof(path) || + !write_ppm(path, frame)) { + fprintf(stderr, "h3: cannot write preview %d to %s\n", + frame->denoise_step + 1, state->preview_dir); + state->frame_write_failed = 1; + } else { + fprintf(stderr, "h3: preview-file %s\n", path); + } + } if (!preview && state->frames_dir && !state->frame_write_failed) { char path[1024]; int length = snprintf(path, sizeof(path), "%s/frame-%04d.ppm", state->frames_dir, frame->frame_index); - FILE *output = length > 0 && (size_t)length < sizeof(path) ? - fopen(path, "wb") : NULL; - if (!output || - fprintf(output, "P6\n%d %d\n255\n", frame->width, - frame->height) < 0) { + if (length <= 0 || (size_t)length >= sizeof(path) || + !write_ppm(path, frame)) { fprintf(stderr, "h3: cannot write frame %d to %s\n", frame->frame_index, state->frames_dir); - if (output) fclose(output); state->frame_write_failed = 1; - } else { - size_t row_bytes = (size_t)frame->width * 3; - for (int row = 0; row < frame->height; row++) { - if (fwrite(frame->rgb + (size_t)row * frame->stride, 1, - row_bytes, output) != row_bytes) { - state->frame_write_failed = 1; - break; - } - } - if (fclose(output) != 0) state->frame_write_failed = 1; - if (state->frame_write_failed) - fprintf(stderr, "h3: incomplete frame %d in %s\n", - frame->frame_index, state->frames_dir); } } if (state->frame_write_failed) return 1; @@ -250,7 +269,7 @@ int main(int argc, char **argv) { OPT_SEED, OPT_FIRST, OPT_LAST, OPT_REF_IMAGE, OPT_REF_IMAGE_SIZE, OPT_REF_VIDEO, OPT_REF_SILENT_VIDEO, OPT_REF_VIDEO_AUDIO, - OPT_REF_AUDIO, OPT_FRAMES_DIR, OPT_SHOW, OPT_ZOOM, + OPT_REF_AUDIO, OPT_FRAMES_DIR, OPT_PREVIEW_DIR, OPT_SHOW, OPT_ZOOM, OPT_PROFILE, OPT_INFO }; static const struct option options[] = { {"model-dir", required_argument, NULL, 'd'}, @@ -300,6 +319,7 @@ int main(int argc, char **argv) { {"ref-video-audio", required_argument, NULL, OPT_REF_VIDEO_AUDIO}, {"ref-audio", required_argument, NULL, OPT_REF_AUDIO}, {"frames-dir", required_argument, NULL, OPT_FRAMES_DIR}, + {"preview-dir", required_argument, NULL, OPT_PREVIEW_DIR}, {"show", no_argument, NULL, OPT_SHOW}, {"zoom", required_argument, NULL, OPT_ZOOM}, {"profile", no_argument, NULL, OPT_PROFILE}, @@ -313,7 +333,11 @@ int main(int argc, char **argv) { h3_params params = H3_PARAMS_DEFAULT; h3_reference references[12]; size_t reference_count = 0; - cli_state cli = {{0}, 0, -1, -1, H3_TERM_NONE, 0, NULL, 0}; + cli_state cli = { + .terminal = H3_TERM_NONE, + .frames_dir = NULL, + .preview_dir = NULL, + }; int show = 0; int profile = 0; int info = 0; @@ -452,6 +476,7 @@ int main(int argc, char **argv) { break; } case OPT_FRAMES_DIR: cli.frames_dir = optarg; break; + case OPT_PREVIEW_DIR: cli.preview_dir = optarg; break; case OPT_SHOW: show = 1; break; case OPT_ZOOM: if (!h3_terminal_set_zoom(parse_int(optarg, "zoom"))) { @@ -486,6 +511,12 @@ int main(int argc, char **argv) { cli.frames_dir, strerror(errno)); return 1; } + if (cli.preview_dir && mkdir(cli.preview_dir, 0755) != 0 && + errno != EEXIST) { + fprintf(stderr, "h3: cannot create preview directory %s: %s\n", + cli.preview_dir, strerror(errno)); + return 1; + } if (profile) setenv("H3_PROFILE", "1", 1); h3_ctx *ctx = h3_load_dir(model_dir); if (!ctx) { @@ -497,7 +528,8 @@ int main(int argc, char **argv) { params.output_path = output; params.on_progress = cli_progress; params.callback_opaque = &cli; - if (cli.frames_dir) params.on_frame = cli_frame; + if (cli.frames_dir || cli.preview_dir) params.on_frame = cli_frame; + if (cli.preview_dir) params.preview_denoise = 1; if (show) { cli.terminal = h3_terminal_detect(); if (cli.terminal == H3_TERM_NONE) { @@ -521,6 +553,8 @@ int main(int argc, char **argv) { if (output && *output) fprintf(stderr, "h3: wrote %s\n", output); if (cli.frames_dir) fprintf(stderr, "h3: wrote frames to %s\n", cli.frames_dir); + if (cli.preview_dir) + fprintf(stderr, "h3: wrote previews to %s\n", cli.preview_dir); } else if (!info) { int cli_status = h3_cli_run(ctx, model_dir, ¶ms, show, seed_given); h3_free(ctx); diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..de142a80 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "h3-studio" +version = "0.1.0" +description = "Optional PySide6 desktop interface for h3-metal" +requires-python = ">=3.10" +dependencies = ["PySide6>=6.10.1,<7"] +license = "MIT" + +[project.optional-dependencies] +dev = ["mypy>=1.19,<2"] + +[project.gui-scripts] +h3-studio = "gui.main:main" + +[tool.setuptools.packages.find] +include = ["gui*"] +exclude = ["gui.tests*"] + +[tool.mypy] +python_version = "3.10" +files = ["gui"] +exclude = ["gui/tests"] +check_untyped_defs = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true