From 86f164f22b71c6529e7652becd44d25aab10102e Mon Sep 17 00:00:00 2001 From: Pierpaolo Vendittelli Date: Thu, 20 Aug 2026 20:10:55 +0200 Subject: [PATCH 01/13] Add H3 Studio macOS GUI --- .gitignore | 6 + Makefile | 26 +- README.md | 29 ++ gui/__init__.py | 2 + gui/app_paths.py | 28 ++ gui/hardware.py | 47 +++ gui/main.py | 56 +++ gui/presets.py | 86 +++++ gui/runner.py | 255 +++++++++++++ gui/tests/__init__.py | 1 + gui/tests/test_app_paths.py | 29 ++ gui/tests/test_app_smoke.py | 24 ++ gui/tests/test_cli_contract.py | 23 ++ gui/tests/test_presets.py | 57 +++ gui/tests/test_runner.py | 204 ++++++++++ gui/tests/test_window.py | 68 ++++ gui/window.py | 658 +++++++++++++++++++++++++++++++++ main.c | 78 ++-- pyproject.toml | 30 ++ 19 files changed, 1684 insertions(+), 23 deletions(-) create mode 100644 gui/__init__.py create mode 100644 gui/app_paths.py create mode 100644 gui/hardware.py create mode 100644 gui/main.py create mode 100644 gui/presets.py create mode 100644 gui/runner.py create mode 100644 gui/tests/__init__.py create mode 100644 gui/tests/test_app_paths.py create mode 100644 gui/tests/test_app_smoke.py create mode 100644 gui/tests/test_cli_contract.py create mode 100644 gui/tests/test_presets.py create mode 100644 gui/tests/test_runner.py create mode 100644 gui/tests/test_window.py create mode 100644 gui/window.py create mode 100644 pyproject.toml 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..dab305fd 100644 --- a/Makefile +++ b/Makefile @@ -17,10 +17,34 @@ 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 + codesign --force --deep --sign - gui/H3Studio.app + @echo "Built gui/H3Studio.app" + h3: $(CLI_OBJ) $(LIB_OBJ) $(CC) -o $@ $^ $(LDLIBS) diff --git a/README.md b/README.md index 4750ac49..ea3f000c 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,32 @@ 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 image, +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. + +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 +``` + +The model weights remain external. Select `MiniMax-H3` on first launch; H3 +Studio remembers the model, reference, prompt, output, and preset paths through +macOS preferences. Run the GUI contract tests with `make gui-test`. + Without `-p`, the same binary starts an Iris-style interactive session: ```sh @@ -340,6 +366,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/hardware.py b/gui/hardware.py new file mode 100644 index 00000000..f765962a --- /dev/null +++ b/gui/hardware.py @@ -0,0 +1,47 @@ +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 memoria unificata" + + +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"Metal Support:\s*(.+)", display_info) + metal_support = metal_match.group(1).strip() if metal_match else "Non rilevato" + return MacInfo( + chip=chip, + memory_gib=memory_bytes / (1024**3), + architecture=architecture, + metal_support=metal_support, + ) diff --git a/gui/main.py b/gui/main.py new file mode 100644 index 00000000..7334458a --- /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 non rilevato", + memory_gib=0.0, + architecture="—", + metal_support="Non rilevato", + ) + + +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..45081d45 --- /dev/null +++ b/gui/presets.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from dataclasses import dataclass + +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 + + +def recommended_preset_name(info: MacInfo) -> str: + del info + return "fast" + + +def preset_for(name: str, info: MacInfo) -> GenerationPreset: + del info + presets = { + "fast": GenerationPreset( + name="fast", + label="Veloce", + description=( + "Controlla rapidamente prompt, identità e inquadratura con " + "un canvas interno ridotto." + ), + 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="Bilanciato", + description="Buon compromesso per verificare movimento e somiglianza.", + 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="Qualità", + description="Render finale con tutti i layer e denoising completo.", + width=512, + height=512, + render_width=None, + render_height=None, + seconds=6, + steps=50, + layers=50, + reuse=1, + core_reuse=1, + ssd_streaming=True, + ), + } + try: + return presets[name] + except KeyError as error: + raise ValueError(f"preset sconosciuto: {name}") from error diff --git a/gui/runner.py b/gui/runner.py new file mode 100644 index 00000000..736919e2 --- /dev/null +++ b/gui/runner.py @@ -0,0 +1,255 @@ +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 + + +@dataclass(frozen=True, slots=True) +class ProgressUpdate: + phase: str + completed: int + total: int + percent: float + eta_seconds: float | None + + +class ProgressTracker: + _pattern = re.compile(r"\s*(.*?)\s+(\d+)\s*/\s*(\d+)\s*") + + def __init__(self) -> None: + self._phase: str | None = None + self._phase_started = 0.0 + self._phase_started_at = 0 + + 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 phase != self._phase or completed < self._phase_started_at: + self._phase = phase + self._phase_started = now + self._phase_started_at = completed + delta = completed - self._phase_started_at + eta_seconds = None + if delta > 0: + seconds_per_unit = (now - self._phase_started) / delta + eta_seconds = max(0.0, seconds_per_unit * (total - completed)) + return ProgressUpdate( + phase=phase, + completed=completed, + total=total, + percent=(completed / total) * 100.0, + eta_seconds=eta_seconds, + ) + + +@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("una generazione è già in corso") + 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)] + if settings.reference_image is not None: + command.extend( + ( + "--ref-image", + str(settings.reference_image), + "--ref-image-size", + "max", + ) + ) + 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.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_presets.py b/gui/tests/test_presets.py new file mode 100644 index 00000000..0a7461f2 --- /dev/null +++ b/gui/tests/test_presets.py @@ -0,0 +1,57 @@ +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 3", + ) + + 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) + + 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 Support: Metal 3\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..e62c20d0 --- /dev/null +++ b/gui/tests/test_runner.py @@ -0,0 +1,204 @@ +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, + ) + + 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-size", + "max", + "-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", + "-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.completed, second.total), (1, 6)) + self.assertAlmostEqual(second.percent, 100 / 6) + self.assertAlmostEqual(second.eta_seconds or 0.0, 50.0) + + def test_ignores_regular_diagnostic_output(self) -> None: + tracker = ProgressTracker() + + self.assertIsNone( + tracker.consume("h3: video VAE cache miss; decoder retained", now=2.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_window.py b/gui/tests/test_window.py new file mode 100644 index 00000000..dba5a765 --- /dev/null +++ b/gui/tests/test_window.py @@ -0,0 +1,68 @@ +import os +import tempfile +import unittest +from pathlib import Path + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +try: + from PySide6.QtWidgets import QApplication +except ImportError: # pragma: no cover - exercised without the optional GUI deps + QApplication = 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.assertFalse(settings.live_preview) + 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/window.py b/gui/window.py new file mode 100644 index 00000000..88616910 --- /dev/null +++ b/gui/window.py @@ -0,0 +1,658 @@ +from __future__ import annotations + +import tempfile +from pathlib import Path +from typing import Any + +from PySide6.QtCore import QObject, QSettings, Qt, QUrl, Signal +from PySide6.QtGui import QDesktopServices, QPixmap +from PySide6.QtWidgets import ( + QButtonGroup, + QCheckBox, + QComboBox, + QFileDialog, + QFormLayout, + QFrame, + QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QMainWindow, + QMessageBox, + QPlainTextEdit, + QProgressBar, + QPushButton, + QSizePolicy, + QSpinBox, + QStackedWidget, + QTextEdit, + QVBoxLayout, + QWidget, +) + +from .hardware import MacInfo +from .presets import preset_for, recommended_preset_name +from .runner import ( + GenerationSettings, + H3Runner, + ProgressUpdate, + RunResult, + RunnerCallbacks, +) + + +class RunnerBridge(QObject): + progress = Signal(object) + output = Signal(str) + preview = Signal(str) + finished = Signal(object) + + +class MainWindow(QMainWindow): + def __init__( + self, + *, + repo_root: Path, + mac_info: MacInfo, + runner: H3Runner | Any, + load_preferences: bool = True, + default_output_dir: Path | None = None, + ) -> 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._persistence_enabled = load_preferences + self._settings = QSettings("h3-metal", "H3 Studio") + self._preview_temp: tempfile.TemporaryDirectory[str] | None = None + self._last_output: Path | None = None + 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") + self.setMinimumSize(820, 650) + self.resize(980, 720) + self._build_ui() + self._apply_style() + self.apply_preset(self._active_preset) + self._set_default_paths() + if load_preferences: + self._load_preferences() + + def _build_ui(self) -> None: + central = QWidget(self) + root = QVBoxLayout(central) + root.setContentsMargins(18, 16, 18, 16) + root.setSpacing(12) + self.setCentralWidget(central) + + 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 memoria unificata · " + 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) + + columns = QHBoxLayout() + columns.setSpacing(14) + root.addLayout(columns, 1) + + controls = QWidget() + controls_layout = QVBoxLayout(controls) + controls_layout.setContentsMargins(0, 0, 0, 0) + controls_layout.setSpacing(10) + columns.addWidget(controls, 11) + + controls_layout.addWidget(self._section_label("Preset")) + preset_row = QHBoxLayout() + preset_row.setSpacing(6) + preset_group = QButtonGroup(self) + preset_group.setExclusive(True) + for name, label in ( + ("fast", "Veloce"), + ("balanced", "Bilanciato"), + ("quality", "Qualità"), + ): + button = QPushButton(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 + 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, + "Cartella modello", + directory=True, + ) + self.reference_edit = self._path_field( + controls_layout, + "Immagine di riferimento", + image=True, + optional=True, + ) + + controls_layout.addWidget(self._field_label("Prompt")) + self.prompt_edit = QTextEdit() + self.prompt_edit.setAcceptRichText(False) + self.prompt_edit.setPlaceholderText("Descrivi scena, azione, camera e 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("Quadrato · 512 × 512", (512, 512)) + self.format_combo.addItem("Verticale · 480 × 864", (480, 864)) + self.format_combo.addItem("Orizzontale · 864 × 480", (864, 480)) + self.format_combo.currentIndexChanged.connect(self._on_format_changed) + format_form.addRow("Formato", self.format_combo) + duration_form = QFormLayout() + self.duration_combo = QComboBox() + for seconds in (1, 2, 4, 6, 10): + self.duration_combo.addItem(f"{seconds} secondi", seconds) + duration_form.addRow("Durata", 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( + "Preview durante il denoising (circa +10 GB di memoria)" + ) + 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("Mostra parametri avanzati") + self.advanced_check.toggled.connect(self._set_advanced_visible) + controls_layout.addWidget(self.advanced_check) + self.advanced_group = QGroupBox("Parametri avanzati") + 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) + 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) + self.advanced_group.setVisible(False) + controls_layout.addWidget(self.advanced_group) + controls_layout.addStretch() + + right = QWidget() + right_layout = QVBoxLayout(right) + right_layout.setContentsMargins(0, 0, 0, 0) + right_layout.setSpacing(10) + columns.addWidget(right, 9) + right_layout.addWidget(self._section_label("Anteprima")) + + self.preview_stack = QStackedWidget() + self.preview_stack.setObjectName("previewPanel") + placeholder = QLabel( + "Nessuna generazione avviata\n\n" + "Qui apparirà un frame intermedio quando la preview è attiva." + ) + placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter) + placeholder.setWordWrap(True) + placeholder.setObjectName("previewPlaceholder") + self.preview_label = QLabel() + self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.preview_label.setSizePolicy( + QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored + ) + self.preview_stack.addWidget(placeholder) + self.preview_stack.addWidget(self.preview_label) + right_layout.addWidget(self.preview_stack, 1) + + status_row = QHBoxLayout() + self.phase_label = QLabel("Pronto") + 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 = QProgressBar() + self.progress_bar.setRange(0, 1000) + self.progress_bar.setValue(0) + self.progress_bar.setTextVisible(False) + right_layout.addWidget(self.progress_bar) + + self.output_edit = self._path_field( + right_layout, + "Video di output", + save_file=True, + ) + + log_header = QHBoxLayout() + log_header.addWidget(self._section_label("Log")) + log_header.addStretch() + self.log_toggle = QPushButton("Mostra") + 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("Genera 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("Apri 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 { color: #f1f3ed; font-size: 13px; } + QFrame#hardwarePanel, QGroupBox, QStackedWidget#previewPanel { + 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; + } + QProgressBar { background: #353832; border: 0; border-radius: 3px; } + QProgressBar::chunk { background: #65c58e; border-radius: 3px; } + 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 = " (opzionale)" if optional else "" + layout.addWidget(self._field_label(label + suffix)) + row = QHBoxLayout() + edit = QLineEdit() + browse = QPushButton("Scegli…") + + def choose() -> None: + if directory: + selected = QFileDialog.getExistingDirectory(self, label, edit.text()) + elif save_file: + selected, _ = QFileDialog.getSaveFileName( + self, label, edit.text(), "Video MP4 (*.mp4)" + ) + elif image: + selected, _ = QFileDialog.getOpenFileName( + self, + label, + edit.text(), + "Immagini (*.png *.jpg *.jpeg *.heic *.webp)", + ) + else: + selected, _ = QFileDialog.getOpenFileName(self, label, edit.text()) + if selected: + 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" + reference = self.repo_root.parent / "reference-images" / "michela-cutout-transparent.png" + output = self.default_output_dir / "h3-studio.mp4" + self.set_paths( + model_dir=model, + reference_image=reference if reference.exists() else None, + output_path=output, + ) + + def set_paths( + self, + *, + model_dir: Path, + reference_image: Path | None, + output_path: Path, + ) -> None: + 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) + + def set_prompt(self, prompt: str) -> None: + self.prompt_edit.setPlainText(prompt) + + 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.preset_description.setText(preset.description) + self.recommendation_label.setText( + f"Preset consigliato: {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) + 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 _on_format_changed(self) -> None: + if self._active_preset != "fast": + return + width, height = self.format_combo.currentData() + if width == height: + render = (320, 320) + elif width > height: + render = (512, 288) + else: + render = (288, 512) + 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( + "Sul Mac da 48 GB può aumentare la pressione sulla memoria e rallentare." + if enabled and self.mac_info.memory_gib <= 48 + else "" + ) + + 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("Nascondi" if visible else "Mostra") + + 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() + return GenerationSettings( + model_dir=Path(self.model_edit.text().strip()).expanduser(), + prompt=self.prompt_edit.toPlainText().strip(), + output_path=Path(self.output_edit.text().strip()).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=True, + live_preview=self.live_preview_check.isChecked(), + preview_dir=preview_dir, + ) + + def _validate(self, settings: GenerationSettings) -> str | None: + executable = self.repo_root / "h3" + if not executable.is_file(): + return "Eseguibile h3 non trovato. Esegui prima ‘make h3’." + if not settings.model_dir.is_dir(): + return "La cartella del modello non esiste." + if not settings.prompt: + return "Inserisci un prompt." + if not str(settings.output_path): + return "Scegli il file video di output." + if settings.reference_image is not None and not settings.reference_image.is_file(): + return "L’immagine di riferimento non esiste." + if settings.reuse > 1 and settings.core_reuse > 1: + return "Reuse e core reuse non possono essere entrambi maggiori di 1." + return None + + def _start_generation(self) -> None: + if self.runner.running: + 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) + settings = self.generation_settings(preview_dir) + error = self._validate(settings) + if error: + self._cleanup_preview_temp() + QMessageBox.warning(self, "Impossibile generare", error) + return + self.log_edit.clear() + self.progress_bar.setValue(0) + self.phase_label.setText("Avvio…") + self.eta_label.setText("ETA —") + self.generate_button.setEnabled(False) + self.stop_button.setEnabled(True) + self.open_button.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.phase_label.setText("Errore di avvio") + self._cleanup_preview_temp() + QMessageBox.critical(self, "Errore", message) + + def _stop_generation(self) -> None: + self.phase_label.setText("Interruzione…") + self.stop_button.setEnabled(False) + self.runner.stop() + + def _on_progress(self, update: ProgressUpdate) -> None: + self.phase_label.setText( + f"{update.phase} · {update.completed}/{update.total}" + ) + self.progress_bar.setValue(round(update.percent * 10)) + if update.eta_seconds is None: + self.eta_label.setText("ETA in calcolo…") + 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 + available = self.preview_stack.size() + self.preview_label.setPixmap( + pixmap.scaled( + available, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + ) + self.preview_stack.setCurrentWidget(self.preview_label) + + def _on_finished(self, result: RunResult) -> None: + self.generate_button.setEnabled(True) + self.stop_button.setEnabled(False) + self.progress_bar.setValue(1000 if result.exit_code == 0 else self.progress_bar.value()) + if result.cancelled: + self.phase_label.setText("Generazione interrotta") + elif result.exit_code == 0: + self.phase_label.setText("Video completato") + 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"Errore · codice {result.exit_code}") + self.log_edit.setVisible(True) + self.log_toggle.setChecked(True) + 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 _load_preferences(self) -> None: + model = self._setting_text("model_dir", self.model_edit.text()) + reference = self._setting_text( + "reference_image", self.reference_edit.text() + ) + output = self._setting_text("output_path", self.output_edit.text()) + prompt = self._setting_text("prompt", "") + preset = self._setting_text("preset", self._active_preset) + if preset in self._preset_buttons: + self.apply_preset(preset) + self.model_edit.setText(model) + self.reference_edit.setText(reference) + self.output_edit.setText(output) + self.model_edit.setCursorPosition(0) + self.reference_edit.setCursorPosition(0) + self.output_edit.setCursorPosition(0) + self.prompt_edit.setPlainText(prompt) + + def _setting_text(self, key: str, default: str) -> str: + value = self._settings.value(key, default) + return value if isinstance(value, str) else default + + def _save_preferences(self) -> None: + if not self._persistence_enabled: + return + self._settings.setValue("model_dir", self.model_edit.text()) + self._settings.setValue("reference_image", self.reference_edit.text()) + self._settings.setValue("output_path", self.output_edit.text()) + self._settings.setValue("prompt", self.prompt_edit.toPlainText()) + self._settings.setValue("preset", self._active_preset) + + def closeEvent(self, event) -> None: + if self.runner.running: + answer = QMessageBox.question( + self, + "Generazione in corso", + "Interrompere la generazione e chiudere 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 From 7e76cbe95387c5721842161f907d42747f9e0c7b Mon Sep 17 00:00:00 2001 From: Pierpaolo Vendittelli Date: Thu, 20 Aug 2026 20:19:32 +0200 Subject: [PATCH 02/13] Fix adaptive presets and persisted settings --- Makefile | 6 ++ gui/hardware.py | 11 +++- gui/presets.py | 48 +++++++++----- gui/tests/test_presets.py | 20 +++++- gui/tests/test_window.py | 91 ++++++++++++++++++++++++++ gui/window.py | 132 ++++++++++++++++++++++++++++++++------ 6 files changed, 268 insertions(+), 40 deletions(-) diff --git a/Makefile b/Makefile index dab305fd..dcef56d1 100644 --- a/Makefile +++ b/Makefile @@ -42,7 +42,13 @@ gui-app: h3 gui-setup 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) diff --git a/gui/hardware.py b/gui/hardware.py index f765962a..9c7ed80e 100644 --- a/gui/hardware.py +++ b/gui/hardware.py @@ -37,8 +37,15 @@ def detect_mac_info(run: CommandRunner = _run_command) -> MacInfo: 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"Metal Support:\s*(.+)", display_info) - metal_support = metal_match.group(1).strip() if metal_match else "Non rilevato" + metal_match = re.search( + r"^\s*Metal(?: Support)?:\s*(.+?)\s*$", display_info, re.MULTILINE + ) + if not metal_match: + metal_support = "Non rilevato" + elif metal_match.group(1).strip().lower() == "supported": + metal_support = "Metal supportato" + else: + metal_support = metal_match.group(1).strip() return MacInfo( chip=chip, memory_gib=memory_bytes / (1024**3), diff --git a/gui/presets.py b/gui/presets.py index 45081d45..9a8d922c 100644 --- a/gui/presets.py +++ b/gui/presets.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace from .hardware import MacInfo @@ -23,15 +23,10 @@ class GenerationPreset: live_preview: bool = False -def recommended_preset_name(info: MacInfo) -> str: - del info - return "fast" - +PRESET_ORDER = ("fast", "balanced", "quality") -def preset_for(name: str, info: MacInfo) -> GenerationPreset: - del info - presets = { - "fast": GenerationPreset( +_PRESETS = { + "fast": GenerationPreset( name="fast", label="Veloce", description=( @@ -48,8 +43,8 @@ def preset_for(name: str, info: MacInfo) -> GenerationPreset: reuse=1, core_reuse=1, ssd_streaming=True, - ), - "balanced": GenerationPreset( + ), + "balanced": GenerationPreset( name="balanced", label="Bilanciato", description="Buon compromesso per verificare movimento e somiglianza.", @@ -63,8 +58,8 @@ def preset_for(name: str, info: MacInfo) -> GenerationPreset: reuse=2, core_reuse=1, ssd_streaming=True, - ), - "quality": GenerationPreset( + ), + "quality": GenerationPreset( name="quality", label="Qualità", description="Render finale con tutti i layer e denoising completo.", @@ -78,9 +73,30 @@ def preset_for(name: str, info: MacInfo) -> GenerationPreset: 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: - return presets[name] + preset = _PRESETS[name] except KeyError as error: raise ValueError(f"preset sconosciuto: {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/tests/test_presets.py b/gui/tests/test_presets.py index 0a7461f2..da66b935 100644 --- a/gui/tests/test_presets.py +++ b/gui/tests/test_presets.py @@ -10,7 +10,7 @@ def setUp(self) -> None: chip="Apple M4 Pro", memory_gib=48.0, architecture="arm64", - metal_support="Metal 3", + metal_support="Metal supportato", ) def test_m4_pro_48_gib_defaults_to_fast_identity_preview(self) -> None: @@ -38,13 +38,29 @@ def test_live_preview_is_never_enabled_automatically(self) -> None: 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 supportato", + ) + + 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 Support: Metal 3\n" + "Chipset Model: Apple M4 Pro\nMetal: Supported\n" ), } diff --git a/gui/tests/test_window.py b/gui/tests/test_window.py index dba5a765..849c3b86 100644 --- a/gui/tests/test_window.py +++ b/gui/tests/test_window.py @@ -6,9 +6,11 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") try: + from PySide6.QtCore import QSettings from PySide6.QtWidgets import QApplication except ImportError: # pragma: no cover - exercised without the optional GUI deps QApplication = None + QSettings = None from gui.hardware import MacInfo @@ -48,9 +50,98 @@ def test_fast_preset_produces_expected_generation_settings(self) -> None: ) 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 supportato"), + 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_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 supportato"), + runner=FakeRunner(), + load_preferences=False, + ) + window.output_edit.clear() + + with self.assertRaisesRegex(ValueError, "output"): + window.generation_settings() + window.close() + + def test_persists_complete_generation_configuration(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 M4 Pro", 48.0, "arm64", "Metal supportato"), + 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.close() + preferences.sync() + + restored = MainWindow( + repo_root=repo, + mac_info=MacInfo("Apple M4 Pro", 48.0, "arm64", "Metal supportato"), + 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) + restored.close() + class FakeRunner: running = False diff --git a/gui/window.py b/gui/window.py index 88616910..c25f0b7a 100644 --- a/gui/window.py +++ b/gui/window.py @@ -32,7 +32,7 @@ ) from .hardware import MacInfo -from .presets import preset_for, recommended_preset_name +from .presets import PRESET_ORDER, preset_for, recommended_preset_name from .runner import ( GenerationSettings, H3Runner, @@ -58,6 +58,7 @@ def __init__( runner: H3Runner | Any, load_preferences: bool = True, default_output_dir: Path | None = None, + settings_store: QSettings | None = None, ) -> None: super().__init__() self.repo_root = repo_root.resolve() @@ -65,7 +66,7 @@ def __init__( self.runner = runner self.default_output_dir = default_output_dir or self.repo_root / "outputs" self._persistence_enabled = load_preferences - self._settings = QSettings("h3-metal", "H3 Studio") + 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._active_preset = recommended_preset_name(mac_info) @@ -129,12 +130,8 @@ def _build_ui(self) -> None: preset_row.setSpacing(6) preset_group = QButtonGroup(self) preset_group.setExclusive(True) - for name, label in ( - ("fast", "Veloce"), - ("balanced", "Bilanciato"), - ("quality", "Qualità"), - ): - button = QPushButton(label) + 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)) @@ -169,6 +166,7 @@ def _build_ui(self) -> None: format_row = QHBoxLayout() format_form = QFormLayout() self.format_combo = QComboBox() + self.format_combo.addItem("Compatto · 256 × 256", (256, 256)) self.format_combo.addItem("Quadrato · 512 × 512", (512, 512)) self.format_combo.addItem("Verticale · 480 × 864", (480, 864)) self.format_combo.addItem("Orizzontale · 864 × 480", (864, 480)) @@ -205,6 +203,7 @@ def _build_ui(self) -> None: 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") fields = ( ("Step", self.steps_spin), ("Layer", self.layers_spin), @@ -220,6 +219,7 @@ def _build_ui(self) -> None: box.addWidget(self._field_label(label)) box.addWidget(widget) advanced.addLayout(box, row, column) + advanced.addWidget(self.ssd_streaming_check, 3, 0, 1, 3) self.advanced_group.setVisible(False) controls_layout.addWidget(self.advanced_group) controls_layout.addStretch() @@ -421,6 +421,7 @@ 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"Preset consigliato: {preset_for(recommended_preset_name(self.mac_info), self.mac_info).label}" @@ -432,22 +433,36 @@ def apply_preset(self, name: str) -> None: 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) 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 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"formato non disponibile: {width}x{height}") + + def select_duration(self, seconds: int) -> None: + index = self.duration_combo.findData(seconds) + if index < 0: + raise ValueError(f"durata non disponibile: {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 = (320, 320) + render = (256, 256) if width == 256 else (320, 320) elif width > height: - render = (512, 288) + render = (576, 320) else: - render = (288, 512) + render = (320, 576) self.render_width_spin.setValue(render[0]) self.render_height_spin.setValue(render[1]) @@ -472,10 +487,16 @@ def generation_settings( 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("Scegli la cartella del modello.") + if not output_text: + raise ValueError("Scegli il file video di output.") return GenerationSettings( - model_dir=Path(self.model_edit.text().strip()).expanduser(), + model_dir=Path(model_text).expanduser(), prompt=self.prompt_edit.toPlainText().strip(), - output_path=Path(self.output_edit.text().strip()).expanduser(), + output_path=Path(output_text).expanduser(), reference_image=Path(reference_text).expanduser() if reference_text else None, width=width, height=height, @@ -487,7 +508,7 @@ def generation_settings( reuse=self.reuse_spin.value(), core_reuse=self.core_reuse_spin.value(), seed=self.seed_spin.value(), - ssd_streaming=True, + ssd_streaming=self.ssd_streaming_check.isChecked(), live_preview=self.live_preview_check.isChecked(), preview_dir=preview_dir, ) @@ -500,8 +521,6 @@ def _validate(self, settings: GenerationSettings) -> str | None: return "La cartella del modello non esiste." if not settings.prompt: return "Inserisci un prompt." - if not str(settings.output_path): - return "Scegli il file video di output." if settings.reference_image is not None and not settings.reference_image.is_file(): return "L’immagine di riferimento non esiste." if settings.reuse > 1 and settings.core_reuse > 1: @@ -515,11 +534,16 @@ def _start_generation(self) -> None: if self.live_preview_check.isChecked(): self._preview_temp = tempfile.TemporaryDirectory(prefix="h3-studio-preview-") preview_dir = Path(self._preview_temp.name) - settings = self.generation_settings(preview_dir) - error = self._validate(settings) - if error: + try: + settings = self.generation_settings(preview_dir) + except ValueError as settings_error: + self._cleanup_preview_temp() + QMessageBox.warning(self, "Impossibile generare", str(settings_error)) + return + validation_error = self._validate(settings) + if validation_error: self._cleanup_preview_temp() - QMessageBox.warning(self, "Impossibile generare", error) + QMessageBox.warning(self, "Impossibile generare", validation_error) return self.log_edit.clear() self.progress_bar.setValue(0) @@ -621,6 +645,40 @@ def _load_preferences(self) -> None: preset = self._setting_text("preset", self._active_preset) if 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()) + ) + 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.reference_edit.setText(reference) self.output_edit.setText(output) @@ -633,6 +691,23 @@ 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 @@ -641,6 +716,23 @@ def _save_preferences(self) -> None: self._settings.setValue("output_path", self.output_edit.text()) self._settings.setValue("prompt", self.prompt_edit.toPlainText()) 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("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: From a5d05fe1fafe0d1f32e7b7db586bcc947753252c Mon Sep 17 00:00:00 2001 From: Pierpaolo Vendittelli Date: Thu, 20 Aug 2026 20:27:40 +0200 Subject: [PATCH 03/13] Add automatic HEIC reference conversion --- README.md | 5 ++- gui/image_converter.py | 55 +++++++++++++++++++++++++++++++ gui/tests/test_image_converter.py | 46 ++++++++++++++++++++++++++ gui/tests/test_window.py | 35 ++++++++++++++++++++ gui/window.py | 44 +++++++++++++++++++++++++ 5 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 gui/image_converter.py create mode 100644 gui/tests/test_image_converter.py diff --git a/README.md b/README.md index ea3f000c..6d262477 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,10 @@ open gui/H3Studio.app The model weights remain external. Select `MiniMax-H3` on first launch; H3 Studio remembers the model, reference, prompt, output, and preset paths through -macOS preferences. Run the GUI contract tests with `make gui-test`. +macOS preferences. iPhone `.heic` and `.heif` references are converted +automatically with the macOS `sips` utility; the original photo is left intact +and the generated PNG is stored in the `reference-images` folder beside the +GUI outputs. Run the GUI contract tests with `make gui-test`. Without `-p`, the same binary starts an Iris-style interactive session: diff --git a/gui/image_converter.py b/gui/image_converter.py new file mode 100644 index 00000000..276a54ca --- /dev/null +++ b/gui/image_converter.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import subprocess +from collections.abc import Callable +from pathlib import Path + + +class ImageConversionError(RuntimeError): + """A reference image could not be converted into a format H3 accepts.""" + + +CommandRunner = Callable[[tuple[str, ...]], None] + + +def _run_command(command: tuple[str, ...]) -> None: + subprocess.run(command, check=True, capture_output=True, text=True) + + +def convert_reference_image( + source: Path, + output_dir: Path, + *, + run: CommandRunner = _run_command, +) -> Path: + """Convert an iPhone HEIC/HEIF reference to PNG, preserving the original.""" + source = source.expanduser().resolve() + if source.suffix.lower() not in (".heic", ".heif"): + return source + if not source.is_file(): + raise ImageConversionError(f"Immagine HEIC non trovata: {source}") + + output_dir = output_dir.expanduser().resolve() + destination = output_dir / f"{source.stem}.png" + command = ( + "/usr/bin/sips", + "-s", + "format", + "png", + str(source), + "--out", + str(destination), + ) + try: + output_dir.mkdir(parents=True, exist_ok=True) + run(command) + signature = destination.read_bytes()[:8] + except (OSError, subprocess.SubprocessError) as error: + raise ImageConversionError( + "Non sono riuscito a convertire l’immagine HEIC con sips." + ) from error + if signature != b"\x89PNG\r\n\x1a\n": + raise ImageConversionError( + "La conversione HEIC non ha prodotto un file PNG valido." + ) + return destination diff --git a/gui/tests/test_image_converter.py b/gui/tests/test_image_converter.py new file mode 100644 index 00000000..737aa851 --- /dev/null +++ b/gui/tests/test_image_converter.py @@ -0,0 +1,46 @@ +import tempfile +import unittest +from pathlib import Path + + +class ReferenceImageConversionTests(unittest.TestCase): + def test_converts_heic_to_png_without_modifying_the_source(self) -> None: + from gui.image_converter import convert_reference_image + + commands: list[tuple[str, ...]] = [] + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "IMG_4621.HEIC" + source.write_bytes(b"original-heic") + + def fake_run(command: tuple[str, ...]) -> None: + commands.append(command) + Path(command[-1]).write_bytes(b"\x89PNG\r\n\x1a\nconverted") + + converted = convert_reference_image( + source, + root / "converted", + run=fake_run, + ) + + self.assertEqual(source.read_bytes(), b"original-heic") + self.assertEqual(converted.suffix, ".png") + self.assertEqual(converted.read_bytes()[:8], b"\x89PNG\r\n\x1a\n") + self.assertEqual( + commands, + [ + ( + "/usr/bin/sips", + "-s", + "format", + "png", + str(source.resolve()), + "--out", + str(converted), + ) + ], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/gui/tests/test_window.py b/gui/tests/test_window.py index 849c3b86..a9d04030 100644 --- a/gui/tests/test_window.py +++ b/gui/tests/test_window.py @@ -98,6 +98,41 @@ def test_blank_output_is_rejected_before_settings_are_built(self) -> None: 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 supportato"), + runner=FakeRunner(), + load_preferences=False, + reference_converter=fake_converter, + ) + + selected = window.set_reference_image(source) + + self.assertEqual(selected, converted) + self.assertEqual(window.reference_edit.text(), str(converted)) + self.assertEqual( + conversions, + [(source, repo.resolve() / "outputs" / "reference-images")], + ) + self.assertIn("HEIC convertito", window.reference_status.text()) + window.close() + def test_persists_complete_generation_configuration(self) -> None: from gui.window import MainWindow diff --git a/gui/window.py b/gui/window.py index c25f0b7a..064aa18b 100644 --- a/gui/window.py +++ b/gui/window.py @@ -1,6 +1,7 @@ from __future__ import annotations import tempfile +from collections.abc import Callable from pathlib import Path from typing import Any @@ -32,6 +33,7 @@ ) from .hardware import MacInfo +from .image_converter import ImageConversionError, convert_reference_image from .presets import PRESET_ORDER, preset_for, recommended_preset_name from .runner import ( GenerationSettings, @@ -59,12 +61,14 @@ def __init__( 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 @@ -155,6 +159,13 @@ def _build_ui(self) -> None: image=True, optional=True, ) + self.reference_edit.editingFinished.connect(self._convert_reference_field) + self.reference_status = QLabel( + "Le foto HEIC/HEIF dell’iPhone vengono convertite automaticamente in PNG." + ) + 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() @@ -383,6 +394,8 @@ def choose() -> None: selected, _ = QFileDialog.getOpenFileName(self, label, edit.text()) if selected: edit.setText(selected) + if image: + self._convert_reference_field() browse.clicked.connect(choose) row.addWidget(edit, 1) @@ -417,6 +430,35 @@ def set_paths( def set_prompt(self, prompt: str) -> None: self.prompt_edit.setPlainText(prompt) + def set_reference_image(self, source: Path) -> Path: + is_heic = source.suffix.lower() in (".heic", ".heif") + converted = self.reference_converter( + source, + self.default_output_dir / "reference-images", + ) + self.reference_edit.setText(str(converted)) + self.reference_edit.setCursorPosition(0) + if is_heic: + self.reference_status.setText( + f"HEIC convertito automaticamente: {converted}" + ) + else: + self.reference_status.setText( + "Le foto HEIC/HEIF dell’iPhone vengono convertite automaticamente in PNG." + ) + return converted + + def _convert_reference_field(self) -> bool: + text = self.reference_edit.text().strip() + if not text or Path(text).suffix.lower() not in (".heic", ".heif"): + return True + try: + self.set_reference_image(Path(text).expanduser()) + except ImageConversionError as error: + QMessageBox.critical(self, "Conversione HEIC non riuscita", str(error)) + return False + return True + def apply_preset(self, name: str) -> None: preset = preset_for(name, self.mac_info) self._active_preset = name @@ -530,6 +572,8 @@ def _validate(self, settings: GenerationSettings) -> str | None: def _start_generation(self) -> None: if self.runner.running: return + if not self._convert_reference_field(): + return preview_dir = None if self.live_preview_check.isChecked(): self._preview_temp = tempfile.TemporaryDirectory(prefix="h3-studio-preview-") From 21efc190ff854129d47b5978261d31cc06b8c1c9 Mon Sep 17 00:00:00 2001 From: Pierpaolo Vendittelli Date: Thu, 20 Aug 2026 20:31:19 +0200 Subject: [PATCH 04/13] Avoid HEIC conversion collisions --- README.md | 2 +- gui/image_converter.py | 44 ++++++++++++++++++++----------- gui/tests/test_image_converter.py | 41 +++++++++++++++++++--------- gui/tests/test_window.py | 4 ++- gui/window.py | 21 +++++++++++---- 5 files changed, 78 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 6d262477..277f6f46 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Studio remembers the model, reference, prompt, output, and preset paths through macOS preferences. iPhone `.heic` and `.heif` references are converted automatically with the macOS `sips` utility; the original photo is left intact and the generated PNG is stored in the `reference-images` folder beside the -GUI outputs. Run the GUI contract tests with `make gui-test`. +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: diff --git a/gui/image_converter.py b/gui/image_converter.py index 276a54ca..3435eb1d 100644 --- a/gui/image_converter.py +++ b/gui/image_converter.py @@ -1,6 +1,8 @@ from __future__ import annotations +import hashlib import subprocess +import uuid from collections.abc import Callable from pathlib import Path @@ -10,6 +12,12 @@ class ImageConversionError(RuntimeError): CommandRunner = Callable[[tuple[str, ...]], None] +CONVERTIBLE_REFERENCE_SUFFIXES = frozenset((".heic", ".heif")) +REFERENCE_IMAGE_FILE_FILTER = "Immagini (*.png *.jpg *.jpeg *.heic *.heif *.webp)" + + +def requires_png_conversion(source: Path) -> bool: + return source.suffix.lower() in CONVERTIBLE_REFERENCE_SUFFIXES def _run_command(command: tuple[str, ...]) -> None: @@ -24,13 +32,15 @@ def convert_reference_image( ) -> Path: """Convert an iPhone HEIC/HEIF reference to PNG, preserving the original.""" source = source.expanduser().resolve() - if source.suffix.lower() not in (".heic", ".heif"): + if not requires_png_conversion(source): return source if not source.is_file(): raise ImageConversionError(f"Immagine HEIC non trovata: {source}") output_dir = output_dir.expanduser().resolve() - destination = output_dir / f"{source.stem}.png" + 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" command = ( "/usr/bin/sips", "-s", @@ -38,18 +48,22 @@ def convert_reference_image( "png", str(source), "--out", - str(destination), + str(temporary), ) try: - output_dir.mkdir(parents=True, exist_ok=True) - run(command) - signature = destination.read_bytes()[:8] - except (OSError, subprocess.SubprocessError) as error: - raise ImageConversionError( - "Non sono riuscito a convertire l’immagine HEIC con sips." - ) from error - if signature != b"\x89PNG\r\n\x1a\n": - raise ImageConversionError( - "La conversione HEIC non ha prodotto un file PNG valido." - ) - return destination + try: + output_dir.mkdir(parents=True, exist_ok=True) + run(command) + signature = temporary.read_bytes()[:8] + except (OSError, subprocess.SubprocessError) as error: + raise ImageConversionError( + "Non sono riuscito a convertire l’immagine HEIC con sips." + ) from error + if signature != b"\x89PNG\r\n\x1a\n": + raise ImageConversionError( + "La conversione HEIC non ha prodotto un file PNG valido." + ) + temporary.replace(destination) + return destination + finally: + temporary.unlink(missing_ok=True) diff --git a/gui/tests/test_image_converter.py b/gui/tests/test_image_converter.py index 737aa851..c08cb3eb 100644 --- a/gui/tests/test_image_converter.py +++ b/gui/tests/test_image_converter.py @@ -27,19 +27,36 @@ def fake_run(command: tuple[str, ...]) -> None: self.assertEqual(converted.suffix, ".png") self.assertEqual(converted.read_bytes()[:8], b"\x89PNG\r\n\x1a\n") self.assertEqual( - commands, - [ - ( - "/usr/bin/sips", - "-s", - "format", - "png", - str(source.resolve()), - "--out", - str(converted), - ) - ], + commands[0][:5], + ("/usr/bin/sips", "-s", "format", "png", str(source.resolve())), ) + self.assertEqual(commands[0][5], "--out") + + 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() + first.write_bytes(b"first") + second.write_bytes(b"second") + + def fake_run(command: tuple[str, ...]) -> None: + Path(command[-1]).write_bytes(b"\x89PNG\r\n\x1a\nconverted") + + first_png = convert_reference_image(first, root / "converted", run=fake_run) + second_png = convert_reference_image( + second, root / "converted", run=fake_run + ) + + self.assertNotEqual(first_png, second_png) + self.assertTrue(first_png.is_file()) + self.assertTrue(second_png.is_file()) if __name__ == "__main__": diff --git a/gui/tests/test_window.py b/gui/tests/test_window.py index a9d04030..823abeae 100644 --- a/gui/tests/test_window.py +++ b/gui/tests/test_window.py @@ -121,6 +121,8 @@ def fake_converter(image: Path, output_dir: Path) -> Path: 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) @@ -128,7 +130,7 @@ def fake_converter(image: Path, output_dir: Path) -> Path: self.assertEqual(window.reference_edit.text(), str(converted)) self.assertEqual( conversions, - [(source, repo.resolve() / "outputs" / "reference-images")], + [(source, chosen_output.resolve().parent / "reference-images")], ) self.assertIn("HEIC convertito", window.reference_status.text()) window.close() diff --git a/gui/window.py b/gui/window.py index 064aa18b..3852e4eb 100644 --- a/gui/window.py +++ b/gui/window.py @@ -33,7 +33,12 @@ ) from .hardware import MacInfo -from .image_converter import ImageConversionError, convert_reference_image +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, @@ -388,7 +393,7 @@ def choose() -> None: self, label, edit.text(), - "Immagini (*.png *.jpg *.jpeg *.heic *.webp)", + REFERENCE_IMAGE_FILE_FILTER, ) else: selected, _ = QFileDialog.getOpenFileName(self, label, edit.text()) @@ -431,10 +436,16 @@ def set_prompt(self, prompt: str) -> None: self.prompt_edit.setPlainText(prompt) def set_reference_image(self, source: Path) -> Path: - is_heic = source.suffix.lower() in (".heic", ".heif") + is_heic = requires_png_conversion(source) + output_text = self.output_edit.text().strip() + output_dir = ( + Path(output_text).expanduser().resolve().parent + if output_text + else self.default_output_dir + ) converted = self.reference_converter( source, - self.default_output_dir / "reference-images", + output_dir / "reference-images", ) self.reference_edit.setText(str(converted)) self.reference_edit.setCursorPosition(0) @@ -450,7 +461,7 @@ def set_reference_image(self, source: Path) -> Path: def _convert_reference_field(self) -> bool: text = self.reference_edit.text().strip() - if not text or Path(text).suffix.lower() not in (".heic", ".heif"): + if not text or not requires_png_conversion(Path(text)): return True try: self.set_reference_image(Path(text).expanduser()) From dfb6caa2b683a942c9ce1bfd6182855f9d1aca40 Mon Sep 17 00:00:00 2001 From: Pierpaolo Vendittelli Date: Thu, 20 Aug 2026 20:36:04 +0200 Subject: [PATCH 05/13] Keep HEIC references beside selected output --- gui/image_converter.py | 40 ++++++++++++++++++++-------- gui/tests/test_window.py | 56 +++++++++++++++++++++++++++++++++++++++ gui/window.py | 57 +++++++++++++++++++++++++++++++++------- 3 files changed, 133 insertions(+), 20 deletions(-) diff --git a/gui/image_converter.py b/gui/image_converter.py index 3435eb1d..92ace709 100644 --- a/gui/image_converter.py +++ b/gui/image_converter.py @@ -2,6 +2,7 @@ import hashlib import subprocess +import sys import uuid from collections.abc import Callable from pathlib import Path @@ -12,8 +13,17 @@ class ImageConversionError(RuntimeError): CommandRunner = Callable[[tuple[str, ...]], None] -CONVERTIBLE_REFERENCE_SUFFIXES = frozenset((".heic", ".heif")) -REFERENCE_IMAGE_FILE_FILTER = "Immagini (*.png *.jpg *.jpeg *.heic *.heif *.webp)" +CONVERTIBLE_REFERENCE_SUFFIXES = (".heic", ".heif") +REFERENCE_IMAGE_SUFFIXES = ( + ".png", + ".jpg", + ".jpeg", + *CONVERTIBLE_REFERENCE_SUFFIXES, + ".webp", +) +REFERENCE_IMAGE_FILE_FILTER = "Immagini (" + " ".join( + f"*{suffix}" for suffix in REFERENCE_IMAGE_SUFFIXES +) + ")" def requires_png_conversion(source: Path) -> bool: @@ -51,19 +61,27 @@ def convert_reference_image( str(temporary), ) try: - try: - output_dir.mkdir(parents=True, exist_ok=True) - run(command) - signature = temporary.read_bytes()[:8] - except (OSError, subprocess.SubprocessError) as error: - raise ImageConversionError( - "Non sono riuscito a convertire l’immagine HEIC con sips." - ) from error + output_dir.mkdir(parents=True, exist_ok=True) + run(command) + signature = temporary.read_bytes()[:8] if signature != b"\x89PNG\r\n\x1a\n": raise ImageConversionError( "La conversione HEIC non ha prodotto un file PNG valido." ) temporary.replace(destination) return destination + except ImageConversionError: + raise + except (OSError, subprocess.SubprocessError) as error: + raise ImageConversionError( + "Non sono riuscito a convertire o salvare l’immagine HEIC." + ) from error finally: - temporary.unlink(missing_ok=True) + active_error = sys.exc_info()[0] is not None + try: + temporary.unlink(missing_ok=True) + except OSError as cleanup_error: + if not active_error: + raise ImageConversionError( + "Non sono riuscito a rimuovere il file temporaneo HEIC." + ) from cleanup_error diff --git a/gui/tests/test_window.py b/gui/tests/test_window.py index 823abeae..bf539cde 100644 --- a/gui/tests/test_window.py +++ b/gui/tests/test_window.py @@ -135,6 +135,62 @@ def fake_converter(image: Path, output_dir: Path) -> Path: self.assertIn("HEIC convertito", 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 supportato"), + 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_persists_complete_generation_configuration(self) -> None: from gui.window import MainWindow diff --git a/gui/window.py b/gui/window.py index 3852e4eb..eeedb228 100644 --- a/gui/window.py +++ b/gui/window.py @@ -78,6 +78,8 @@ def __init__( 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._reference_source: Path | None = None + self._converted_reference: Path | None = None self._active_preset = recommended_preset_name(mac_info) self._preset_buttons: dict[str, QPushButton] = {} self._bridge = RunnerBridge(self) @@ -425,6 +427,8 @@ def set_paths( reference_image: Path | None, output_path: Path, ) -> None: + self._reference_source = None + self._converted_reference = None 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)) @@ -437,16 +441,12 @@ def set_prompt(self, prompt: str) -> None: def set_reference_image(self, source: Path) -> Path: is_heic = requires_png_conversion(source) - output_text = self.output_edit.text().strip() - output_dir = ( - Path(output_text).expanduser().resolve().parent - if output_text - else self.default_output_dir - ) converted = self.reference_converter( source, - output_dir / "reference-images", + 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: @@ -459,12 +459,40 @@ def set_reference_image(self, source: Path) -> Path: ) return converted + 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 or not requires_png_conversion(Path(text)): + if not text: + self._reference_source = None + self._converted_reference = None + return True + 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 + return True + if current.resolve().parent == self._reference_output_dir().resolve(): + return True + source = ( + current if requires_png_conversion(current) else self._reference_source + ) + if source is None: return True try: - self.set_reference_image(Path(text).expanduser()) + self.set_reference_image(source) except ImageConversionError as error: QMessageBox.critical(self, "Conversione HEIC non riuscita", str(error)) return False @@ -695,6 +723,7 @@ def _load_preferences(self) -> None: reference = self._setting_text( "reference_image", self.reference_edit.text() ) + reference_source = self._setting_text("reference_source", "") output = self._setting_text("output_path", self.output_edit.text()) prompt = self._setting_text("prompt", "") preset = self._setting_text("preset", self._active_preset) @@ -741,6 +770,12 @@ def _load_preferences(self) -> None: self.reference_edit.setCursorPosition(0) self.output_edit.setCursorPosition(0) self.prompt_edit.setPlainText(prompt) + if reference_source and requires_png_conversion(Path(reference_source)): + self._reference_source = Path(reference_source).expanduser() + self._converted_reference = Path(reference).expanduser() + else: + self._reference_source = None + self._converted_reference = None def _setting_text(self, key: str, default: str) -> str: value = self._settings.value(key, default) @@ -768,6 +803,10 @@ def _save_preferences(self) -> None: return self._settings.setValue("model_dir", self.model_edit.text()) self._settings.setValue("reference_image", self.reference_edit.text()) + self._settings.setValue( + "reference_source", + str(self._reference_source) if self._reference_source else "", + ) self._settings.setValue("output_path", self.output_edit.text()) self._settings.setValue("prompt", self.prompt_edit.toPlainText()) self._settings.setValue("preset", self._active_preset) From 14d4074238caf95c0907151ac4f21a44f79e4d7c Mon Sep 17 00:00:00 2001 From: Pierpaolo Vendittelli Date: Thu, 20 Aug 2026 21:38:13 +0200 Subject: [PATCH 06/13] Polish H3 Studio progress and responsive UI --- README.md | 6 + gui/hardware.py | 6 +- gui/image_converter.py | 10 +- gui/main.py | 4 +- gui/presets.py | 16 +-- gui/runner.py | 64 +++++++--- gui/tests/test_presets.py | 4 +- gui/tests/test_runner.py | 29 ++++- gui/tests/test_window.py | 105 ++++++++++++++-- gui/widgets.py | 59 +++++++++ gui/window.py | 244 ++++++++++++++++++++++++-------------- 11 files changed, 414 insertions(+), 133 deletions(-) create mode 100644 gui/widgets.py diff --git a/README.md b/README.md index 277f6f46..2f685610 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,12 @@ 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. Progress is shown as three global stages (Preparation, Generation, and +Decode & export), while the optional preview follows the available panel size. + Build `h3`, create the isolated GUI environment, and open the window with: ```sh diff --git a/gui/hardware.py b/gui/hardware.py index 9c7ed80e..98b5f5ae 100644 --- a/gui/hardware.py +++ b/gui/hardware.py @@ -18,7 +18,7 @@ class MacInfo: @property def summary(self) -> str: - return f"{self.chip} · {self.memory_gib:.0f} GB memoria unificata" + return f"{self.chip} · {self.memory_gib:.0f} GB unified memory" def _run_command(command: Sequence[str]) -> str: @@ -41,9 +41,9 @@ def detect_mac_info(run: CommandRunner = _run_command) -> MacInfo: r"^\s*Metal(?: Support)?:\s*(.+?)\s*$", display_info, re.MULTILINE ) if not metal_match: - metal_support = "Non rilevato" + metal_support = "Not detected" elif metal_match.group(1).strip().lower() == "supported": - metal_support = "Metal supportato" + metal_support = "Metal supported" else: metal_support = metal_match.group(1).strip() return MacInfo( diff --git a/gui/image_converter.py b/gui/image_converter.py index 92ace709..5d2d8e1f 100644 --- a/gui/image_converter.py +++ b/gui/image_converter.py @@ -21,7 +21,7 @@ class ImageConversionError(RuntimeError): *CONVERTIBLE_REFERENCE_SUFFIXES, ".webp", ) -REFERENCE_IMAGE_FILE_FILTER = "Immagini (" + " ".join( +REFERENCE_IMAGE_FILE_FILTER = "Images (" + " ".join( f"*{suffix}" for suffix in REFERENCE_IMAGE_SUFFIXES ) + ")" @@ -45,7 +45,7 @@ def convert_reference_image( if not requires_png_conversion(source): return source if not source.is_file(): - raise ImageConversionError(f"Immagine HEIC non trovata: {source}") + raise ImageConversionError(f"HEIC image not found: {source}") output_dir = output_dir.expanduser().resolve() source_id = hashlib.sha256(str(source).encode()).hexdigest()[:8] @@ -66,7 +66,7 @@ def convert_reference_image( signature = temporary.read_bytes()[:8] if signature != b"\x89PNG\r\n\x1a\n": raise ImageConversionError( - "La conversione HEIC non ha prodotto un file PNG valido." + "HEIC conversion did not produce a valid PNG file." ) temporary.replace(destination) return destination @@ -74,7 +74,7 @@ def convert_reference_image( raise except (OSError, subprocess.SubprocessError) as error: raise ImageConversionError( - "Non sono riuscito a convertire o salvare l’immagine HEIC." + "Could not convert or save the HEIC image." ) from error finally: active_error = sys.exc_info()[0] is not None @@ -83,5 +83,5 @@ def convert_reference_image( except OSError as cleanup_error: if not active_error: raise ImageConversionError( - "Non sono riuscito a rimuovere il file temporaneo HEIC." + "Could not remove the temporary HEIC file." ) from cleanup_error diff --git a/gui/main.py b/gui/main.py index 7334458a..bc57947e 100644 --- a/gui/main.py +++ b/gui/main.py @@ -21,10 +21,10 @@ def _mac_info() -> MacInfo: return detect_mac_info() except (OSError, ValueError, SubprocessError): return MacInfo( - chip="Mac non rilevato", + chip="Mac not detected", memory_gib=0.0, architecture="—", - metal_support="Non rilevato", + metal_support="Not detected", ) diff --git a/gui/presets.py b/gui/presets.py index 9a8d922c..75c310ad 100644 --- a/gui/presets.py +++ b/gui/presets.py @@ -28,10 +28,10 @@ class GenerationPreset: _PRESETS = { "fast": GenerationPreset( name="fast", - label="Veloce", + label="Fast", description=( - "Controlla rapidamente prompt, identità e inquadratura con " - "un canvas interno ridotto." + "Quickly check the prompt, identity, and framing with a smaller " + "internal canvas." ), width=512, height=512, @@ -46,8 +46,8 @@ class GenerationPreset: ), "balanced": GenerationPreset( name="balanced", - label="Bilanciato", - description="Buon compromesso per verificare movimento e somiglianza.", + label="Balanced", + description="A good compromise for checking motion and likeness.", width=512, height=512, render_width=None, @@ -61,8 +61,8 @@ class GenerationPreset: ), "quality": GenerationPreset( name="quality", - label="Qualità", - description="Render finale con tutti i layer e denoising completo.", + label="Quality", + description="Final render with every layer and full denoising.", width=512, height=512, render_width=None, @@ -89,7 +89,7 @@ def preset_for(name: str, info: MacInfo) -> GenerationPreset: try: preset = _PRESETS[name] except KeyError as error: - raise ValueError(f"preset sconosciuto: {name}") from error + raise ValueError(f"unknown preset: {name}") from error if name == "fast" and info.memory_gib < 32: return replace( preset, diff --git a/gui/runner.py b/gui/runner.py index 736919e2..626e9e0b 100644 --- a/gui/runner.py +++ b/gui/runner.py @@ -34,6 +34,8 @@ class GenerationSettings: @dataclass(frozen=True, slots=True) class ProgressUpdate: phase: str + stage: str + stage_index: int completed: int total: int percent: float @@ -42,11 +44,29 @@ class ProgressUpdate: 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._phase: str | None = None - self._phase_started = 0.0 - self._phase_started_at = 0 + self._started: float | None = None + self._overall_percent = 0.0 + self._stage_index = 0 def consume(self, text: str, *, now: float) -> ProgressUpdate | None: match = self._pattern.fullmatch(text.strip("\r\n")) @@ -57,23 +77,39 @@ def consume(self, text: str, *, now: float) -> ProgressUpdate | None: total = int(match.group(3)) if total <= 0 or completed > total: return None - if phase != self._phase or completed < self._phase_started_at: - self._phase = phase - self._phase_started = now - self._phase_started_at = completed - delta = completed - self._phase_started_at - eta_seconds = None - if delta > 0: - seconds_per_unit = (now - self._phase_started) / delta - eta_seconds = max(0.0, seconds_per_unit * (total - completed)) + if self._started is None: + self._started = now + stage_index, band_start, band_end = self._band_for(phase) + self._stage_index = max(self._stage_index, stage_index) + phase_fraction = completed / total + mapped_percent = band_start + (band_end - band_start) * phase_fraction + self._overall_percent = max(self._overall_percent, mapped_percent) + eta_seconds: float | None = None + elapsed = now - self._started + if self._overall_percent >= 100.0: + eta_seconds = 0.0 + elif elapsed > 0.0 and self._overall_percent > 0.0: + eta_seconds = elapsed * (100.0 - self._overall_percent) / self._overall_percent return ProgressUpdate( phase=phase, + stage=self._stage_names[self._stage_index], + stage_index=self._stage_index, completed=completed, total=total, - percent=(completed / total) * 100.0, + percent=self._overall_percent, eta_seconds=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: @@ -129,7 +165,7 @@ def start( ) -> None: with self._lock: if self._process is not None: - raise RuntimeError("una generazione è già in corso") + 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) diff --git a/gui/tests/test_presets.py b/gui/tests/test_presets.py index da66b935..63da239c 100644 --- a/gui/tests/test_presets.py +++ b/gui/tests/test_presets.py @@ -10,7 +10,7 @@ def setUp(self) -> None: chip="Apple M4 Pro", memory_gib=48.0, architecture="arm64", - metal_support="Metal supportato", + metal_support="Metal supported", ) def test_m4_pro_48_gib_defaults_to_fast_identity_preview(self) -> None: @@ -45,7 +45,7 @@ def test_low_memory_mac_gets_a_smaller_fast_canvas(self) -> None: chip="Apple M3", memory_gib=24.0, architecture="arm64", - metal_support="Metal supportato", + metal_support="Metal supported", ) preset = preset_for("fast", small_mac) diff --git a/gui/tests/test_runner.py b/gui/tests/test_runner.py index e62c20d0..ff942f1d 100644 --- a/gui/tests/test_runner.py +++ b/gui/tests/test_runner.py @@ -135,9 +135,10 @@ def test_estimates_remaining_time_from_completed_steps(self) -> None: 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, 100 / 6) - self.assertAlmostEqual(second.eta_seconds or 0.0, 50.0) + 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() @@ -146,6 +147,30 @@ def test_ignores_regular_diagnostic_output(self) -> None: 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) + class RunnerTests(unittest.TestCase): def test_reports_progress_preview_and_completion(self) -> None: diff --git a/gui/tests/test_window.py b/gui/tests/test_window.py index bf539cde..6a71682c 100644 --- a/gui/tests/test_window.py +++ b/gui/tests/test_window.py @@ -7,9 +7,12 @@ 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 @@ -61,7 +64,7 @@ def test_fast_portrait_render_preserves_output_aspect_ratio(self) -> None: repo = Path(directory) window = MainWindow( repo_root=repo, - mac_info=MacInfo("Apple M4 Pro", 48.0, "arm64", "Metal supportato"), + mac_info=MacInfo("Apple M4 Pro", 48.0, "arm64", "Metal supported"), runner=FakeRunner(), load_preferences=False, ) @@ -88,7 +91,7 @@ def test_blank_output_is_rejected_before_settings_are_built(self) -> None: repo = Path(directory) window = MainWindow( repo_root=repo, - mac_info=MacInfo("Apple M4 Pro", 48.0, "arm64", "Metal supportato"), + mac_info=MacInfo("Apple M4 Pro", 48.0, "arm64", "Metal supported"), runner=FakeRunner(), load_preferences=False, ) @@ -116,7 +119,7 @@ def fake_converter(image: Path, output_dir: Path) -> Path: window = MainWindow( repo_root=repo, - mac_info=MacInfo("Apple M4 Pro", 48.0, "arm64", "Metal supportato"), + mac_info=MacInfo("Apple M4 Pro", 48.0, "arm64", "Metal supported"), runner=FakeRunner(), load_preferences=False, reference_converter=fake_converter, @@ -132,7 +135,7 @@ def fake_converter(image: Path, output_dir: Path) -> Path: conversions, [(source, chosen_output.resolve().parent / "reference-images")], ) - self.assertIn("HEIC convertito", window.reference_status.text()) + self.assertIn("HEIC converted", window.reference_status.text()) window.close() def test_changing_video_output_reconverts_heic_beside_new_output(self) -> None: @@ -157,7 +160,7 @@ def fake_converter(image: Path, output_dir: Path) -> Path: runner = FakeRunner() window = MainWindow( repo_root=repo, - mac_info=MacInfo("Apple M4 Pro", 48.0, "arm64", "Metal supportato"), + mac_info=MacInfo("Apple M4 Pro", 48.0, "arm64", "Metal supported"), runner=runner, load_preferences=False, reference_converter=fake_converter, @@ -203,7 +206,7 @@ def test_persists_complete_generation_configuration(self) -> None: ) first = MainWindow( repo_root=repo, - mac_info=MacInfo("Apple M4 Pro", 48.0, "arm64", "Metal supportato"), + mac_info=MacInfo("Apple M4 Pro", 48.0, "arm64", "Metal supported"), runner=FakeRunner(), settings_store=preferences, ) @@ -219,7 +222,7 @@ def test_persists_complete_generation_configuration(self) -> None: restored = MainWindow( repo_root=repo, - mac_info=MacInfo("Apple M4 Pro", 48.0, "arm64", "Metal supportato"), + mac_info=MacInfo("Apple M4 Pro", 48.0, "arm64", "Metal supported"), runner=FakeRunner(), settings_store=QSettings( str(preferences_path), QSettings.Format.IniFormat @@ -235,6 +238,94 @@ def test_persists_complete_generation_configuration(self) -> None: self.assertFalse(settings.ssd_streaming) restored.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() + class FakeRunner: running = False diff --git a/gui/widgets.py b/gui/widgets.py new file mode 100644 index 00000000..9f336101 --- /dev/null +++ b/gui/widgets.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtGui import QPixmap, QResizeEvent +from PySide6.QtWidgets import QHBoxLayout, QLabel, QProgressBar, 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 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 index eeedb228..d0395cd7 100644 --- a/gui/window.py +++ b/gui/window.py @@ -6,7 +6,7 @@ from typing import Any from PySide6.QtCore import QObject, QSettings, Qt, QUrl, Signal -from PySide6.QtGui import QDesktopServices, QPixmap +from PySide6.QtGui import QDesktopServices, QPixmap, QResizeEvent from PySide6.QtWidgets import ( QButtonGroup, QCheckBox, @@ -22,10 +22,11 @@ QMainWindow, QMessageBox, QPlainTextEdit, - QProgressBar, QPushButton, + QScrollArea, QSizePolicy, QSpinBox, + QSplitter, QStackedWidget, QTextEdit, QVBoxLayout, @@ -47,6 +48,7 @@ RunResult, RunnerCallbacks, ) +from .widgets import ScalablePreviewLabel, SegmentedProgressBar class RunnerBridge(QObject): @@ -78,6 +80,7 @@ def __init__( 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._active_preset = recommended_preset_name(mac_info) @@ -88,10 +91,11 @@ def __init__( self._bridge.preview.connect(self._on_preview) self._bridge.finished.connect(self._on_finished) - self.setWindowTitle("H3 Studio") - self.setMinimumSize(820, 650) - self.resize(980, 720) + 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() @@ -99,11 +103,17 @@ def __init__( self._load_preferences() def _build_ui(self) -> None: - central = QWidget(self) - root = QVBoxLayout(central) + content = QWidget(self) + content.setObjectName("contentRoot") + root = QVBoxLayout(content) root.setContentsMargins(18, 16, 18, 16) root.setSpacing(12) - self.setCentralWidget(central) + 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") @@ -113,7 +123,7 @@ def _build_ui(self) -> None: title = QLabel(self.mac_info.chip) title.setObjectName("hardwareTitle") details = QLabel( - f"{self.mac_info.memory_gib:.0f} GB memoria unificata · " + f"{self.mac_info.memory_gib:.0f} GB unified memory · " f"{self.mac_info.architecture} · {self.mac_info.metal_support}" ) details.setObjectName("secondaryText") @@ -126,15 +136,18 @@ def _build_ui(self) -> None: hardware_layout.addWidget(self.recommendation_label) root.addWidget(hardware) - columns = QHBoxLayout() - columns.setSpacing(14) - root.addLayout(columns, 1) + 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) - columns.addWidget(controls, 11) + controls.setMinimumWidth(390) + self.content_splitter.addWidget(controls) controls_layout.addWidget(self._section_label("Preset")) preset_row = QHBoxLayout() @@ -149,6 +162,13 @@ def _build_ui(self) -> None: 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) @@ -157,18 +177,18 @@ def _build_ui(self) -> None: self.model_edit = self._path_field( controls_layout, - "Cartella modello", + "Model folder", directory=True, ) self.reference_edit = self._path_field( controls_layout, - "Immagine di riferimento", + "Reference image", image=True, optional=True, ) self.reference_edit.editingFinished.connect(self._convert_reference_field) self.reference_status = QLabel( - "Le foto HEIC/HEIF dell’iPhone vengono convertite automaticamente in PNG." + "iPhone HEIC/HEIF photos are converted to PNG automatically." ) self.reference_status.setWordWrap(True) self.reference_status.setObjectName("secondaryText") @@ -177,31 +197,34 @@ def _build_ui(self) -> None: controls_layout.addWidget(self._field_label("Prompt")) self.prompt_edit = QTextEdit() self.prompt_edit.setAcceptRichText(False) - self.prompt_edit.setPlaceholderText("Descrivi scena, azione, camera e audio…") + 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("Compatto · 256 × 256", (256, 256)) - self.format_combo.addItem("Quadrato · 512 × 512", (512, 512)) - self.format_combo.addItem("Verticale · 480 × 864", (480, 864)) - self.format_combo.addItem("Orizzontale · 864 × 480", (864, 480)) + 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("Formato", self.format_combo) + 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} secondi", seconds) - duration_form.addRow("Durata", self.duration_combo) + 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( - "Preview durante il denoising (circa +10 GB di memoria)" + "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() @@ -209,10 +232,10 @@ def _build_ui(self) -> None: self.preview_warning.setObjectName("warningText") controls_layout.addWidget(self.preview_warning) - self.advanced_check = QCheckBox("Mostra parametri avanzati") + 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("Parametri avanzati") + 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) @@ -243,54 +266,56 @@ def _build_ui(self) -> None: controls_layout.addStretch() right = QWidget() + right.setObjectName("previewColumn") right_layout = QVBoxLayout(right) right_layout.setContentsMargins(0, 0, 0, 0) right_layout.setSpacing(10) - columns.addWidget(right, 9) - right_layout.addWidget(self._section_label("Anteprima")) + 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_stack = QStackedWidget() self.preview_stack.setObjectName("previewPanel") - placeholder = QLabel( - "Nessuna generazione avviata\n\n" - "Qui apparirà un frame intermedio quando la preview è attiva." - ) - placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter) - placeholder.setWordWrap(True) - placeholder.setObjectName("previewPlaceholder") - self.preview_label = QLabel() + 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(placeholder) + self.preview_stack.addWidget(self.preview_placeholder) self.preview_stack.addWidget(self.preview_label) right_layout.addWidget(self.preview_stack, 1) status_row = QHBoxLayout() - self.phase_label = QLabel("Pronto") + 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 = QProgressBar() - self.progress_bar.setRange(0, 1000) - self.progress_bar.setValue(0) - self.progress_bar.setTextVisible(False) + self.progress_bar = SegmentedProgressBar() right_layout.addWidget(self.progress_bar) self.output_edit = self._path_field( right_layout, - "Video di output", + "Output video", save_file=True, ) log_header = QHBoxLayout() log_header.addWidget(self._section_label("Log")) log_header.addStretch() - self.log_toggle = QPushButton("Mostra") + self.log_toggle = QPushButton("Show") self.log_toggle.setCheckable(True) self.log_toggle.toggled.connect(self._toggle_log) log_header.addWidget(self.log_toggle) @@ -303,13 +328,13 @@ def _build_ui(self) -> None: right_layout.addWidget(self.log_edit) action_row = QHBoxLayout() - self.generate_button = QPushButton("Genera video") + 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("Apri video") + 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) @@ -321,6 +346,10 @@ def _apply_style(self) -> None: self.setStyleSheet( """ QMainWindow { background: #171816; } + QWidget#contentRoot, QWidget#controlsPanel, QWidget#previewColumn, + QScrollArea#contentScroll, QScrollArea#contentScroll > QWidget > QWidget { + background: #171816; border: 0; + } QWidget { color: #f1f3ed; font-size: 13px; } QFrame#hardwarePanel, QGroupBox, QStackedWidget#previewPanel { background: #252723; border: 1px solid #41463e; border-radius: 9px; @@ -342,6 +371,7 @@ def _apply_style(self) -> None: } 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; } @@ -377,18 +407,18 @@ def _path_field( save_file: bool = False, optional: bool = False, ) -> QLineEdit: - suffix = " (opzionale)" if optional else "" + suffix = " (optional)" if optional else "" layout.addWidget(self._field_label(label + suffix)) row = QHBoxLayout() edit = QLineEdit() - browse = QPushButton("Scegli…") + 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(), "Video MP4 (*.mp4)" + self, label, edit.text(), "MP4 video (*.mp4)" ) elif image: selected, _ = QFileDialog.getOpenFileName( @@ -451,11 +481,11 @@ def set_reference_image(self, source: Path) -> Path: self.reference_edit.setCursorPosition(0) if is_heic: self.reference_status.setText( - f"HEIC convertito automaticamente: {converted}" + f"HEIC converted automatically: {converted}" ) else: self.reference_status.setText( - "Le foto HEIC/HEIF dell’iPhone vengono convertite automaticamente in PNG." + "iPhone HEIC/HEIF photos are converted to PNG automatically." ) return converted @@ -494,7 +524,7 @@ def _convert_reference_field(self) -> bool: try: self.set_reference_image(source) except ImageConversionError as error: - QMessageBox.critical(self, "Conversione HEIC non riuscita", str(error)) + QMessageBox.critical(self, "HEIC conversion failed", str(error)) return False return True @@ -505,7 +535,7 @@ def apply_preset(self, name: str) -> None: self.select_format(preset.width, preset.height) self.preset_description.setText(preset.description) self.recommendation_label.setText( - f"Preset consigliato: {preset_for(recommended_preset_name(self.mac_info), self.mac_info).label}" + 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) @@ -521,17 +551,26 @@ def apply_preset(self, name: str) -> None: 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"formato non disponibile: {width}x{height}") + 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"durata non disponibile: {seconds}") + raise ValueError(f"unsupported duration: {seconds}") self.duration_combo.setCurrentIndex(index) def _on_format_changed(self) -> None: @@ -549,7 +588,7 @@ def _on_format_changed(self) -> None: def _update_preview_warning(self, enabled: bool) -> None: self.preview_warning.setText( - "Sul Mac da 48 GB può aumentare la pressione sulla memoria e rallentare." + "On a 48 GB Mac this can increase memory pressure and slow generation." if enabled and self.mac_info.memory_gib <= 48 else "" ) @@ -559,7 +598,7 @@ def _set_advanced_visible(self, visible: bool) -> None: def _toggle_log(self, visible: bool) -> None: self.log_edit.setVisible(visible) - self.log_toggle.setText("Nascondi" if visible else "Mostra") + self.log_toggle.setText("Hide" if visible else "Show") def generation_settings( self, preview_dir: Path | None = None @@ -571,9 +610,9 @@ def generation_settings( model_text = self.model_edit.text().strip() output_text = self.output_edit.text().strip() if not model_text: - raise ValueError("Scegli la cartella del modello.") + raise ValueError("Choose the model folder.") if not output_text: - raise ValueError("Scegli il file video di output.") + raise ValueError("Choose the output video file.") return GenerationSettings( model_dir=Path(model_text).expanduser(), prompt=self.prompt_edit.toPlainText().strip(), @@ -597,15 +636,15 @@ def generation_settings( def _validate(self, settings: GenerationSettings) -> str | None: executable = self.repo_root / "h3" if not executable.is_file(): - return "Eseguibile h3 non trovato. Esegui prima ‘make h3’." + return "The h3 executable was not found. Run ‘make h3’ first." if not settings.model_dir.is_dir(): - return "La cartella del modello non esiste." + return "The model folder does not exist." if not settings.prompt: - return "Inserisci un prompt." + return "Enter a prompt." if settings.reference_image is not None and not settings.reference_image.is_file(): - return "L’immagine di riferimento non esiste." + return "The reference image does not exist." if settings.reuse > 1 and settings.core_reuse > 1: - return "Reuse e core reuse non possono essere entrambi maggiori di 1." + return "Reuse and core reuse cannot both be greater than 1." return None def _start_generation(self) -> None: @@ -621,20 +660,29 @@ def _start_generation(self) -> None: settings = self.generation_settings(preview_dir) except ValueError as settings_error: self._cleanup_preview_temp() - QMessageBox.warning(self, "Impossibile generare", str(settings_error)) + QMessageBox.warning(self, "Cannot generate", str(settings_error)) return validation_error = self._validate(settings) if validation_error: self._cleanup_preview_temp() - QMessageBox.warning(self, "Impossibile generare", validation_error) + QMessageBox.warning(self, "Cannot generate", validation_error) return self.log_edit.clear() - self.progress_bar.setValue(0) - self.phase_label.setText("Avvio…") + 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." + ) 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, @@ -649,22 +697,24 @@ def _start_generation(self) -> None: def _on_start_error(self, message: str) -> None: self.generate_button.setEnabled(True) self.stop_button.setEnabled(False) - self.phase_label.setText("Errore di avvio") + self.live_preview_check.setEnabled(True) + self.phase_label.setText("Start error") self._cleanup_preview_temp() - QMessageBox.critical(self, "Errore", message) + QMessageBox.critical(self, "Error", message) def _stop_generation(self) -> None: - self.phase_label.setText("Interruzione…") + 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.phase} · {update.completed}/{update.total}" + f"{update.stage_index + 1}/3 {update.stage} · " + f"{update.phase} {update.completed}/{update.total}" ) - self.progress_bar.setValue(round(update.percent * 10)) + self.progress_bar.set_overall_percent(update.percent) if update.eta_seconds is None: - self.eta_label.setText("ETA in calcolo…") + self.eta_label.setText("Calculating ETA…") else: self.eta_label.setText(f"ETA {self._format_duration(update.eta_seconds)}") @@ -675,29 +725,24 @@ def _on_preview(self, path_text: str) -> None: pixmap = QPixmap(path_text) if pixmap.isNull(): return - available = self.preview_stack.size() - self.preview_label.setPixmap( - pixmap.scaled( - available, - Qt.AspectRatioMode.KeepAspectRatio, - Qt.TransformationMode.SmoothTransformation, - ) - ) + self.preview_label.set_source_pixmap(pixmap) self.preview_stack.setCurrentWidget(self.preview_label) + self._preview_received = True def _on_finished(self, result: RunResult) -> None: self.generate_button.setEnabled(True) self.stop_button.setEnabled(False) - self.progress_bar.setValue(1000 if result.exit_code == 0 else self.progress_bar.value()) + self.live_preview_check.setEnabled(True) if result.cancelled: - self.phase_label.setText("Generazione interrotta") + self.phase_label.setText("Generation stopped") elif result.exit_code == 0: - self.phase_label.setText("Video completato") + 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"Errore · codice {result.exit_code}") + self.phase_label.setText(f"Error · exit code {result.exit_code}") self.log_edit.setVisible(True) self.log_toggle.setChecked(True) self._cleanup_preview_temp() @@ -718,6 +763,23 @@ def _format_duration(self, seconds: float) -> str: 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: model = self._setting_text("model_dir", self.model_edit.text()) reference = self._setting_text( @@ -727,7 +789,9 @@ def _load_preferences(self) -> None: output = self._setting_text("output_path", self.output_edit.text()) prompt = self._setting_text("prompt", "") preset = self._setting_text("preset", self._active_preset) - if preset in self._preset_buttons: + 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]) @@ -832,8 +896,8 @@ def closeEvent(self, event) -> None: if self.runner.running: answer = QMessageBox.question( self, - "Generazione in corso", - "Interrompere la generazione e chiudere H3 Studio?", + "Generation in progress", + "Stop generation and close H3 Studio?", ) if answer != QMessageBox.StandardButton.Yes: event.ignore() From d5ee9a9c7aaf97f1fabedaff7f8c88398b71442a Mon Sep 17 00:00:00 2001 From: Pierpaolo Vendittelli Date: Thu, 20 Aug 2026 21:43:54 +0200 Subject: [PATCH 07/13] Handle repeated phases and preview failures --- gui/runner.py | 34 ++++++++++++++++++++++++++++++---- gui/tests/test_runner.py | 20 ++++++++++++++++++++ gui/tests/test_window.py | 39 +++++++++++++++++++++++++++++++++++++++ gui/window.py | 13 +++++++++++++ 4 files changed, 102 insertions(+), 4 deletions(-) diff --git a/gui/runner.py b/gui/runner.py index 626e9e0b..be23046f 100644 --- a/gui/runner.py +++ b/gui/runner.py @@ -67,6 +67,12 @@ 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._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")) @@ -80,16 +86,36 @@ def consume(self, text: str, *, now: float) -> ProgressUpdate | None: if self._started is None: self._started = now stage_index, band_start, band_end = self._band_for(phase) + if phase != self._active_phase: + 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._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) - eta_seconds: float | None = None elapsed = now - self._started if self._overall_percent >= 100.0: - eta_seconds = 0.0 + self._eta_seconds = 0.0 elif elapsed > 0.0 and self._overall_percent > 0.0: - eta_seconds = elapsed * (100.0 - self._overall_percent) / self._overall_percent + 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(0.0, self._eta_seconds - (now - self._eta_updated_at)) + self._eta_seconds = max(1.0, min(countdown, estimate)) + self._eta_updated_at = now return ProgressUpdate( phase=phase, stage=self._stage_names[self._stage_index], @@ -97,7 +123,7 @@ def consume(self, text: str, *, now: float) -> ProgressUpdate | None: completed=completed, total=total, percent=self._overall_percent, - eta_seconds=eta_seconds, + eta_seconds=self._eta_seconds, ) def _band_for(self, phase: str) -> tuple[int, float, float]: diff --git a/gui/tests/test_runner.py b/gui/tests/test_runner.py index ff942f1d..2539aa20 100644 --- a/gui/tests/test_runner.py +++ b/gui/tests/test_runner.py @@ -171,6 +171,26 @@ def test_maps_log_phases_to_three_global_progress_segments(self) -> None: self.assertEqual(completed.percent, 100.0) self.assertEqual(completed.eta_seconds, 0.0) + def test_repeated_reference_phases_advance_and_eta_never_increases(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("Qwen vision 80/80", 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[4].percent, progress[2].percent) + self.assertGreater(progress[5].percent, progress[4].percent) + etas = [item.eta_seconds for item in progress if item.eta_seconds is not None] + self.assertTrue( + all(later <= earlier for earlier, later in zip(etas, etas[1:])) + ) + class RunnerTests(unittest.TestCase): def test_reports_progress_preview_and_completion(self) -> None: diff --git a/gui/tests/test_window.py b/gui/tests/test_window.py index 6a71682c..aeac186e 100644 --- a/gui/tests/test_window.py +++ b/gui/tests/test_window.py @@ -326,6 +326,45 @@ def test_custom_opens_advanced_controls_and_layout_is_responsive(self) -> None: ) 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 diff --git a/gui/window.py b/gui/window.py index d0395cd7..60a025a1 100644 --- a/gui/window.py +++ b/gui/window.py @@ -699,6 +699,10 @@ def _on_start_error(self, message: str) -> None: 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) @@ -745,6 +749,15 @@ def _on_finished(self, result: RunResult) -> None: 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: From d21596e543128a787c945877258cecd71b69ca4d Mon Sep 17 00:00:00 2001 From: Pierpaolo Vendittelli Date: Thu, 20 Aug 2026 21:47:25 +0200 Subject: [PATCH 08/13] Improve repeated phase and ETA tracking --- gui/runner.py | 17 ++++++++++++++--- gui/tests/test_runner.py | 23 ++++++++++++++++------- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/gui/runner.py b/gui/runner.py index be23046f..210dd950 100644 --- a/gui/runner.py +++ b/gui/runner.py @@ -68,6 +68,7 @@ def __init__(self) -> 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 @@ -86,11 +87,15 @@ def consume(self, text: str, *, now: float) -> ProgressUpdate | None: if self._started is None: self._started = now stage_index, band_start, band_end = self._band_for(phase) - if phase != self._active_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 @@ -113,8 +118,14 @@ def consume(self, text: str, *, now: float) -> ProgressUpdate | None: if self._eta_seconds is None or self._eta_updated_at is None: self._eta_seconds = max(1.0, estimate) else: - countdown = max(0.0, self._eta_seconds - (now - self._eta_updated_at)) - self._eta_seconds = max(1.0, min(countdown, estimate)) + 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, diff --git a/gui/tests/test_runner.py b/gui/tests/test_runner.py index 2539aa20..64b9b8f6 100644 --- a/gui/tests/test_runner.py +++ b/gui/tests/test_runner.py @@ -171,25 +171,34 @@ def test_maps_log_phases_to_three_global_progress_segments(self) -> None: self.assertEqual(completed.percent, 100.0) self.assertEqual(completed.eta_seconds, 0.0) - def test_repeated_reference_phases_advance_and_eta_never_increases(self) -> None: + 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("Qwen vision 80/80", now=20.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[4].percent, progress[2].percent) + self.assertGreater(progress[3].percent, progress[1].percent) + self.assertGreater(progress[4].percent, progress[3].percent) self.assertGreater(progress[5].percent, progress[4].percent) - etas = [item.eta_seconds for item in progress if item.eta_seconds is not None] - self.assertTrue( - all(later <= earlier for earlier, later in zip(etas, etas[1:])) - ) + + 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): From 539661a76eb3b8fa92d407b9b31337f51c0be449 Mon Sep 17 00:00:00 2001 From: Pierpaolo Vendittelli Date: Thu, 20 Aug 2026 22:06:23 +0200 Subject: [PATCH 09/13] Add ordered reference inputs to H3 Studio --- README.md | 24 ++-- gui/runner.py | 26 ++-- gui/tests/test_runner.py | 15 ++- gui/tests/test_window.py | 152 +++++++++++++++++++++- gui/widgets.py | 85 +++++++++++- gui/window.py | 271 ++++++++++++++++++++++++++++++++++----- 6 files changed, 523 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 2f685610..4eb78409 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ reference. #### Optional macOS desktop interface -H3 Studio is a PySide6 interface for choosing the model, reference image, +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 @@ -37,8 +37,14 @@ 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. Progress is shown as three global stages (Preparation, Generation, and -Decode & export), while the optional preview follows the available panel size. +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: @@ -55,11 +61,13 @@ open gui/H3Studio.app ``` The model weights remain external. Select `MiniMax-H3` on first launch; H3 -Studio remembers the model, reference, prompt, output, and preset paths through -macOS preferences. iPhone `.heic` and `.heif` references are converted -automatically with the macOS `sips` utility; the original photo is left intact -and the generated PNG is stored in the `reference-images` folder beside the -output selected in the GUI. Run the GUI contract tests with `make gui-test`. +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 converted automatically with the macOS `sips` utility; originals +are left intact and the generated PNG 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: diff --git a/gui/runner.py b/gui/runner.py index 210dd950..6c710c91 100644 --- a/gui/runner.py +++ b/gui/runner.py @@ -29,6 +29,15 @@ class GenerationSettings: 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) @@ -285,15 +294,10 @@ def _consume_line( def build_h3_command(executable: Path, settings: GenerationSettings) -> list[str]: command = [str(executable), "--profile", "-d", str(settings.model_dir)] - if settings.reference_image is not None: - command.extend( - ( - "--ref-image", - str(settings.reference_image), - "--ref-image-size", - "max", - ) - ) + 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: @@ -322,6 +326,10 @@ def build_h3_command(executable: Path, settings: GenerationSettings) -> list[str 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))) diff --git a/gui/tests/test_runner.py b/gui/tests/test_runner.py index 64b9b8f6..655d51b3 100644 --- a/gui/tests/test_runner.py +++ b/gui/tests/test_runner.py @@ -82,6 +82,13 @@ def test_builds_reference_video_command_without_a_shell(self) -> None: 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) @@ -95,8 +102,12 @@ def test_builds_reference_video_command_without_a_shell(self) -> None: "/models/MiniMax-H3", "--ref-image", "/images/michela.png", + "--ref-image", + "/images/michela-profile.png", + "--ref-image", + "/images/michela-full-body.png", "--ref-image-size", - "max", + "match", "-p", "Michela cammina sulla spiaggia e sorride.", "--width", @@ -118,6 +129,8 @@ def test_builds_reference_video_command_without_a_shell(self) -> None: "--seed", "42", "--ssd-streaming", + "--token-reduction", + "--use-int8-row-fc2", "-o", "/videos/michela.mp4", ], diff --git a/gui/tests/test_window.py b/gui/tests/test_window.py index aeac186e..078bb8cb 100644 --- a/gui/tests/test_window.py +++ b/gui/tests/test_window.py @@ -84,6 +84,82 @@ def test_fast_portrait_render_preserves_output_aspect_ratio(self) -> None: ) 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_blank_output_is_rejected_before_settings_are_built(self) -> None: from gui.window import MainWindow @@ -194,7 +270,67 @@ def fake_converter(image: Path, output_dir: Path) -> Path: runner.running = False window.close() - def test_persists_complete_generation_configuration(self) -> None: + 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 @@ -217,6 +353,13 @@ def test_persists_complete_generation_configuration(self) -> None: 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() @@ -236,6 +379,13 @@ def test_persists_complete_generation_configuration(self) -> None: 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_live_preview_appears_and_resizes_with_the_window(self) -> None: diff --git a/gui/widgets.py b/gui/widgets.py index 9f336101..0b34db0e 100644 --- a/gui/widgets.py +++ b/gui/widgets.py @@ -1,8 +1,19 @@ from __future__ import annotations +from pathlib import Path + from PySide6.QtCore import Qt from PySide6.QtGui import QPixmap, QResizeEvent -from PySide6.QtWidgets import QHBoxLayout, QLabel, QProgressBar, QWidget +from PySide6.QtWidgets import ( + QFrame, + QGridLayout, + QHBoxLayout, + QLabel, + QProgressBar, + QScrollArea, + QVBoxLayout, + QWidget, +) class ScalablePreviewLabel(QLabel): @@ -30,6 +41,78 @@ def _fit_source(self) -> None: ) +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) + pixmap = QPixmap(str(path)) + 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__() diff --git a/gui/window.py b/gui/window.py index 60a025a1..1e3fdf32 100644 --- a/gui/window.py +++ b/gui/window.py @@ -19,6 +19,7 @@ QHBoxLayout, QLabel, QLineEdit, + QListWidget, QMainWindow, QMessageBox, QPlainTextEdit, @@ -28,6 +29,7 @@ QSpinBox, QSplitter, QStackedWidget, + QTabWidget, QTextEdit, QVBoxLayout, QWidget, @@ -48,7 +50,7 @@ RunResult, RunnerCallbacks, ) -from .widgets import ScalablePreviewLabel, SegmentedProgressBar +from .widgets import ReferenceInputPreview, ScalablePreviewLabel, SegmentedProgressBar class RunnerBridge(QObject): @@ -83,6 +85,8 @@ def __init__( self._preview_received = False self._reference_source: Path | None = None self._converted_reference: Path | None = None + self._additional_reference_sources: list[Path] = [] + self._additional_reference_images: list[Path] = [] self._active_preset = recommended_preset_name(mac_info) self._preset_buttons: dict[str, QPushButton] = {} self._bridge = RunnerBridge(self) @@ -245,6 +249,11 @@ def _build_ui(self) -> None: 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)") fields = ( ("Step", self.steps_spin), ("Layer", self.layers_spin), @@ -260,7 +269,45 @@ def _build_ui(self) -> None: box.addWidget(self._field_label(label)) box.addWidget(widget) advanced.addLayout(box, row, column) - advanced.addWidget(self.ssd_streaming_check, 3, 0, 1, 3) + 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.advanced_group.setVisible(False) controls_layout.addWidget(self.advanced_group) controls_layout.addStretch() @@ -277,6 +324,11 @@ def _build_ui(self) -> None: 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( @@ -293,7 +345,8 @@ def _build_ui(self) -> None: ) self.preview_stack.addWidget(self.preview_placeholder) self.preview_stack.addWidget(self.preview_label) - right_layout.addWidget(self.preview_stack, 1) + self.preview_tabs.addTab(self.preview_stack, "Generation") + right_layout.addWidget(self.preview_tabs, 1) status_row = QHBoxLayout() self.phase_label = QLabel("Ready") @@ -347,11 +400,15 @@ def _apply_style(self) -> None: """ QMainWindow { background: #171816; } QWidget#contentRoot, QWidget#controlsPanel, QWidget#previewColumn, - QScrollArea#contentScroll, QScrollArea#contentScroll > QWidget > QWidget { + 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, QGroupBox, QStackedWidget#previewPanel { + 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; } @@ -369,6 +426,10 @@ def _apply_style(self) -> None: 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; } @@ -442,11 +503,10 @@ def choose() -> None: def _set_default_paths(self) -> None: model = self.repo_root / "MiniMax-H3" - reference = self.repo_root.parent / "reference-images" / "michela-cutout-transparent.png" output = self.default_output_dir / "h3-studio.mp4" self.set_paths( model_dir=model, - reference_image=reference if reference.exists() else None, + reference_image=None, output_path=output, ) @@ -459,12 +519,16 @@ def set_paths( ) -> None: self._reference_source = None self._converted_reference = None + self._additional_reference_sources.clear() + self._additional_reference_images.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) @@ -487,8 +551,118 @@ def set_reference_image(self, source: Path) -> Path: 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_reference_images) + len(sources) > 9: + raise ValueError("H3 supports at most 9 reference images.") + expanded_sources = tuple(source.expanduser().resolve() for source in sources) + converted_images: list[Path] = [] + for source in expanded_sources: + converted = self.reference_converter( + source, + self._reference_output_dir(), + ).expanduser() + converted_images.append(converted) + self._additional_reference_sources.extend(expanded_sources) + self._additional_reference_images.extend(converted_images) + self._refresh_additional_reference_list() + return tuple(converted_images) + + 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, image in enumerate( + self._additional_reference_images, + start=first_picture, + ): + self.additional_references_list.addItem( + f"Picture {index} · {image.name}" + ) + if self._additional_reference_images: + self.additional_references_list.setCurrentRow( + min(max(selected_row, 0), len(self._additional_reference_images) - 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(self._additional_reference_images) + 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_reference_sources[row] + del self._additional_reference_images[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_reference_images + ): + return + for values in ( + self._additional_reference_sources, + self._additional_reference_images, + ): + values[row], values[destination] = values[destination], values[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 index, (source, converted) in enumerate( + zip( + self._additional_reference_sources, + self._additional_reference_images, + ) + ): + if not requires_png_conversion(source): + continue + if converted.resolve().parent == output_dir and converted.is_file(): + continue + try: + self._additional_reference_images[index] = self.reference_converter( + 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 = ( @@ -503,6 +677,7 @@ def _convert_reference_field(self) -> bool: if not text: self._reference_source = None self._converted_reference = None + self._refresh_additional_reference_list() return True current = Path(text).expanduser() if not requires_png_conversion(current): @@ -513,8 +688,10 @@ def _convert_reference_field(self) -> bool: ): 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 @@ -545,6 +722,9 @@ def apply_preset(self, name: str) -> None: 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) @@ -631,6 +811,10 @@ def generation_settings( ssd_streaming=self.ssd_streaming_check.isChecked(), live_preview=self.live_preview_check.isChecked(), preview_dir=preview_dir, + additional_reference_images=tuple(self._additional_reference_images), + 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: @@ -641,8 +825,11 @@ def _validate(self, settings: GenerationSettings) -> str | None: return "The model folder does not exist." if not settings.prompt: return "Enter a prompt." - if settings.reference_image is not None and not settings.reference_image.is_file(): - return "The reference image does not exist." + 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.reuse > 1 and settings.core_reuse > 1: return "Reuse and core reuse cannot both be greater than 1." return None @@ -650,7 +837,10 @@ def _validate(self, settings: GenerationSettings) -> str | None: def _start_generation(self) -> None: if self.runner.running: return - if not self._convert_reference_field(): + if ( + not self._convert_reference_field() + or not self._convert_additional_references() + ): return preview_dir = None if self.live_preview_check.isChecked(): @@ -679,6 +869,8 @@ def _start_generation(self) -> None: 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) @@ -731,6 +923,7 @@ def _on_preview(self, path_text: str) -> None: 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: @@ -795,12 +988,7 @@ def _update_responsive_layout(self, width: int) -> None: def _load_preferences(self) -> None: model = self._setting_text("model_dir", self.model_edit.text()) - reference = self._setting_text( - "reference_image", self.reference_edit.text() - ) - reference_source = self._setting_text("reference_source", "") output = self._setting_text("output_path", self.output_edit.text()) - prompt = self._setting_text("prompt", "") preset = self._setting_text("preset", self._active_preset) if preset == "custom": self.activate_custom() @@ -834,6 +1022,25 @@ def _load_preferences(self) -> None: 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._setting_bool( + "use_int8_row_fc2", + self.int8_row_fc2_check.isChecked(), + ) + ) self.live_preview_check.setChecked( self._setting_bool("live_preview", self.live_preview_check.isChecked()) ) @@ -841,18 +1048,9 @@ def _load_preferences(self) -> None: self._setting_bool("advanced_visible", self.advanced_check.isChecked()) ) self.model_edit.setText(model) - self.reference_edit.setText(reference) self.output_edit.setText(output) self.model_edit.setCursorPosition(0) - self.reference_edit.setCursorPosition(0) self.output_edit.setCursorPosition(0) - self.prompt_edit.setPlainText(prompt) - if reference_source and requires_png_conversion(Path(reference_source)): - self._reference_source = Path(reference_source).expanduser() - self._converted_reference = Path(reference).expanduser() - else: - self._reference_source = None - self._converted_reference = None def _setting_text(self, key: str, default: str) -> str: value = self._settings.value(key, default) @@ -879,13 +1077,14 @@ def _save_preferences(self) -> None: if not self._persistence_enabled: return self._settings.setValue("model_dir", self.model_edit.text()) - self._settings.setValue("reference_image", self.reference_edit.text()) - self._settings.setValue( - "reference_source", - str(self._reference_source) if self._reference_source else "", - ) self._settings.setValue("output_path", self.output_edit.text()) - self._settings.setValue("prompt", self.prompt_edit.toPlainText()) + for transient_key in ( + "prompt", + "reference_image", + "reference_source", + "reference_images", + ): + self._settings.remove(transient_key) self._settings.setValue("preset", self._active_preset) width, height = self.format_combo.currentData() self._settings.setValue("format_width", width) @@ -901,6 +1100,18 @@ def _save_preferences(self) -> None: 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() From c786a60bfbdccd09f02cf396873ea37b898c5098 Mon Sep 17 00:00:00 2001 From: Pierpaolo Vendittelli Date: Thu, 20 Aug 2026 22:12:23 +0200 Subject: [PATCH 10/13] Validate advanced options and clear legacy inputs --- gui/tests/test_window.py | 63 +++++++++++++++++++- gui/window.py | 125 +++++++++++++++++++++++++-------------- 2 files changed, 141 insertions(+), 47 deletions(-) diff --git a/gui/tests/test_window.py b/gui/tests/test_window.py index 078bb8cb..8bdbf968 100644 --- a/gui/tests/test_window.py +++ b/gui/tests/test_window.py @@ -342,7 +342,7 @@ def test_persists_technical_settings_but_starts_with_fresh_content(self) -> None ) first = MainWindow( repo_root=repo, - mac_info=MacInfo("Apple M4 Pro", 48.0, "arm64", "Metal supported"), + mac_info=MacInfo("Apple M5 Max", 48.0, "arm64", "Metal 4"), runner=FakeRunner(), settings_store=preferences, ) @@ -365,7 +365,7 @@ def test_persists_technical_settings_but_starts_with_fresh_content(self) -> None restored = MainWindow( repo_root=repo, - mac_info=MacInfo("Apple M4 Pro", 48.0, "arm64", "Metal supported"), + mac_info=MacInfo("Apple M5 Max", 48.0, "arm64", "Metal 4"), runner=FakeRunner(), settings_store=QSettings( str(preferences_path), QSettings.Format.IniFormat @@ -388,6 +388,65 @@ def test_persists_technical_settings_but_starts_with_fresh_content(self) -> None 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 4"), + 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 diff --git a/gui/window.py b/gui/window.py index 1e3fdf32..a8ea6dc3 100644 --- a/gui/window.py +++ b/gui/window.py @@ -2,6 +2,7 @@ import tempfile from collections.abc import Callable +from dataclasses import dataclass from pathlib import Path from typing import Any @@ -60,7 +61,20 @@ class RunnerBridge(QObject): 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, *, @@ -85,8 +99,7 @@ def __init__( self._preview_received = False self._reference_source: Path | None = None self._converted_reference: Path | None = None - self._additional_reference_sources: list[Path] = [] - self._additional_reference_images: list[Path] = [] + self._additional_references: list[ReferenceInput] = [] self._active_preset = recommended_preset_name(mac_info) self._preset_buttons: dict[str, QPushButton] = {} self._bridge = RunnerBridge(self) @@ -254,6 +267,9 @@ def _build_ui(self) -> None: 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), @@ -308,6 +324,7 @@ def _build_ui(self) -> None: ) ) 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() @@ -519,8 +536,7 @@ def set_paths( ) -> None: self._reference_source = None self._converted_reference = None - self._additional_reference_sources.clear() - self._additional_reference_images.clear() + 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 "") @@ -556,20 +572,19 @@ def set_reference_image(self, source: Path) -> Path: 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_reference_images) + len(sources) > 9: + 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) - converted_images: list[Path] = [] + new_references: list[ReferenceInput] = [] for source in expanded_sources: converted = self.reference_converter( source, self._reference_output_dir(), ).expanduser() - converted_images.append(converted) - self._additional_reference_sources.extend(expanded_sources) - self._additional_reference_images.extend(converted_images) + new_references.append(ReferenceInput(source, converted)) + self._additional_references.extend(new_references) self._refresh_additional_reference_list() - return tuple(converted_images) + return tuple(reference.converted for reference in new_references) def _choose_additional_references(self) -> None: selected, _ = QFileDialog.getOpenFileNames( @@ -591,16 +606,16 @@ def _refresh_additional_reference_list(self) -> None: 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, image in enumerate( - self._additional_reference_images, + for index, reference in enumerate( + self._additional_references, start=first_picture, ): self.additional_references_list.addItem( - f"Picture {index} · {image.name}" + f"Picture {index} · {reference.converted.name}" ) - if self._additional_reference_images: + if self._additional_references: self.additional_references_list.setCurrentRow( - min(max(selected_row, 0), len(self._additional_reference_images) - 1) + min(max(selected_row, 0), len(self._additional_references) - 1) ) self._refresh_input_preview() @@ -610,7 +625,7 @@ def _refresh_input_preview(self) -> None: primary_text = self.reference_edit.text().strip() paths = ( (Path(primary_text).expanduser(),) if primary_text else () - ) + tuple(self._additional_reference_images) + ) + tuple(reference.converted for reference in self._additional_references) self.input_preview.set_references(paths) self.preview_tabs.setCurrentWidget(self.input_preview) @@ -618,41 +633,38 @@ def _remove_additional_reference(self) -> None: row = self.additional_references_list.currentRow() if row < 0: return - del self._additional_reference_sources[row] - del self._additional_reference_images[row] + 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_reference_images + self._additional_references ): return - for values in ( - self._additional_reference_sources, - self._additional_reference_images, - ): - values[row], values[destination] = values[destination], values[row] + 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 index, (source, converted) in enumerate( - zip( - self._additional_reference_sources, - self._additional_reference_images, - ) - ): - if not requires_png_conversion(source): + for reference in self._additional_references: + if not requires_png_conversion(reference.source): continue - if converted.resolve().parent == output_dir and converted.is_file(): + if ( + reference.converted.resolve().parent == output_dir + and reference.converted.is_file() + ): continue try: - self._additional_reference_images[index] = self.reference_converter( - source, + reference.converted = self.reference_converter( + reference.source, output_dir, ).expanduser() except ImageConversionError as error: @@ -773,6 +785,27 @@ def _update_preview_warning(self, enabled: bool) -> None: else "" ) + def _supports_int8_row_fc2(self) -> bool: + return ( + "m5" in self.mac_info.chip.lower() + and "metal 4" in self.mac_info.metal_support.lower() + ) + + 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) @@ -811,7 +844,9 @@ def generation_settings( ssd_streaming=self.ssd_streaming_check.isChecked(), live_preview=self.live_preview_check.isChecked(), preview_dir=preview_dir, - additional_reference_images=tuple(self._additional_reference_images), + 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(), @@ -830,6 +865,10 @@ def _validate(self, settings: GenerationSettings) -> str | None: 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 @@ -987,6 +1026,9 @@ def _update_responsive_layout(self, width: int) -> None: ) 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) @@ -1036,10 +1078,8 @@ def _load_preferences(self) -> None: ) ) self.int8_row_fc2_check.setChecked( - self._setting_bool( - "use_int8_row_fc2", - self.int8_row_fc2_check.isChecked(), - ) + 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()) @@ -1078,12 +1118,7 @@ def _save_preferences(self) -> None: return self._settings.setValue("model_dir", self.model_edit.text()) self._settings.setValue("output_path", self.output_edit.text()) - for transient_key in ( - "prompt", - "reference_image", - "reference_source", - "reference_images", - ): + 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() From 0ecbeb3e9a2bd12f726fa2cbc375736450de4a47 Mon Sep 17 00:00:00 2001 From: Pierpaolo Vendittelli Date: Thu, 20 Aug 2026 22:15:41 +0200 Subject: [PATCH 11/13] Enforce reference limits and detect M5 support --- gui/tests/test_window.py | 34 +++++++++++++++++++++++++++++++++- gui/window.py | 31 ++++++++++++++++++++++++++----- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/gui/tests/test_window.py b/gui/tests/test_window.py index 8bdbf968..d927152b 100644 --- a/gui/tests/test_window.py +++ b/gui/tests/test_window.py @@ -160,6 +160,33 @@ def test_input_preview_shows_all_selected_reference_images(self) -> None: 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 @@ -434,7 +461,12 @@ def test_int8_row_fc2_requires_m5_metal4_and_ssd_streaming_off(self) -> None: m5_window = MainWindow( repo_root=repo, - mac_info=MacInfo("Apple M5 Max", 64.0, "arm64", "Metal 4"), + mac_info=MacInfo( + "Apple M5 Max", + 64.0, + "arm64", + "Metal supported", + ), runner=FakeRunner(), load_preferences=False, ) diff --git a/gui/window.py b/gui/window.py index a8ea6dc3..aa5c8dbf 100644 --- a/gui/window.py +++ b/gui/window.py @@ -508,9 +508,17 @@ def choose() -> None: else: selected, _ = QFileDialog.getOpenFileName(self, label, edit.text()) if selected: - edit.setText(selected) if image: - self._convert_reference_field() + 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) @@ -550,6 +558,8 @@ 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, @@ -691,6 +701,15 @@ def _convert_reference_field(self) -> bool: 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 ( @@ -786,10 +805,12 @@ def _update_preview_warning(self, enabled: bool) -> None: ) def _supports_int8_row_fc2(self) -> bool: - return ( - "m5" in self.mac_info.chip.lower() - and "metal 4" in self.mac_info.metal_support.lower() + 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 From 5e3cbba4130549c15c8832386544bcd1d1fae623 Mon Sep 17 00:00:00 2001 From: Pierpaolo Vendittelli Date: Fri, 21 Aug 2026 14:16:21 +0200 Subject: [PATCH 12/13] Normalize reference image orientation --- README.md | 8 ++--- gui/image_converter.py | 58 +++++++++++++++++++++++++++++-- gui/tests/test_image_converter.py | 55 ++++++++++++++++++----------- gui/tests/test_widgets.py | 57 ++++++++++++++++++++++++++++++ gui/widgets.py | 7 ++-- 5 files changed, 156 insertions(+), 29 deletions(-) create mode 100644 gui/tests/test_widgets.py diff --git a/README.md b/README.md index 4eb78409..0a84602a 100644 --- a/README.md +++ b/README.md @@ -64,10 +64,10 @@ 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 converted automatically with the macOS `sips` utility; originals -are left intact and the generated PNG files are stored in the `reference-images` -folder beside the output selected in the GUI. Run the GUI contract tests with -`make gui-test`. +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: diff --git a/gui/image_converter.py b/gui/image_converter.py index 5d2d8e1f..f0227949 100644 --- a/gui/image_converter.py +++ b/gui/image_converter.py @@ -7,6 +7,14 @@ 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.""" @@ -34,13 +42,43 @@ 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: - """Convert an iPhone HEIC/HEIF reference to PNG, preserving the original.""" + """Decode an iPhone photo upright and save a metadata-neutral PNG copy.""" source = source.expanduser().resolve() if not requires_png_conversion(source): return source @@ -51,6 +89,7 @@ def convert_reference_image( 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", @@ -58,11 +97,25 @@ def convert_reference_image( "png", str(source), "--out", - str(temporary), + 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( @@ -80,6 +133,7 @@ def convert_reference_image( 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( diff --git a/gui/tests/test_image_converter.py b/gui/tests/test_image_converter.py index c08cb3eb..d850d789 100644 --- a/gui/tests/test_image_converter.py +++ b/gui/tests/test_image_converter.py @@ -1,36 +1,54 @@ +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_png_without_modifying_the_source(self) -> None: + def test_converts_heic_to_physically_oriented_png_without_modifying_source( + self, + ) -> None: from gui.image_converter import convert_reference_image - commands: list[tuple[str, ...]] = [] with tempfile.TemporaryDirectory() as directory: root = Path(directory) source = root / "IMG_4621.HEIC" - source.write_bytes(b"original-heic") - - def fake_run(command: tuple[str, ...]) -> None: - commands.append(command) - Path(command[-1]).write_bytes(b"\x89PNG\r\n\x1a\nconverted") + write_rotated_jpeg(source) + original = source.read_bytes() converted = convert_reference_image( source, root / "converted", - run=fake_run, ) - self.assertEqual(source.read_bytes(), b"original-heic") + 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( - commands[0][:5], - ("/usr/bin/sips", "-s", "format", "png", str(source.resolve())), + reader.transformation(), + QImageIOHandler.Transformation.TransformationNone, ) - self.assertEqual(commands[0][5], "--out") def test_same_iphone_filename_from_different_folders_does_not_collide( self, @@ -43,16 +61,11 @@ def test_same_iphone_filename_from_different_folders_does_not_collide( second = root / "trip-two" / "IMG_4621.HEIC" first.parent.mkdir() second.parent.mkdir() - first.write_bytes(b"first") - second.write_bytes(b"second") + write_rotated_jpeg(first) + write_rotated_jpeg(second) - def fake_run(command: tuple[str, ...]) -> None: - Path(command[-1]).write_bytes(b"\x89PNG\r\n\x1a\nconverted") - - first_png = convert_reference_image(first, root / "converted", run=fake_run) - second_png = convert_reference_image( - second, root / "converted", run=fake_run - ) + 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()) 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/widgets.py b/gui/widgets.py index 0b34db0e..f40d5803 100644 --- a/gui/widgets.py +++ b/gui/widgets.py @@ -3,7 +3,7 @@ from pathlib import Path from PySide6.QtCore import Qt -from PySide6.QtGui import QPixmap, QResizeEvent +from PySide6.QtGui import QImageReader, QPixmap, QResizeEvent from PySide6.QtWidgets import ( QFrame, QGridLayout, @@ -88,7 +88,10 @@ def set_references(self, paths: tuple[Path, ...]) -> None: card_layout = QVBoxLayout(card) image_label = QLabel() image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - pixmap = QPixmap(str(path)) + reader = QImageReader(str(path)) + reader.setAutoTransform(True) + image = reader.read() + pixmap = QPixmap.fromImage(image) if pixmap.isNull(): image_label.setText("Preview unavailable") else: From 2d6efbb8ee24c3eb242149680de9c86ad8451fd6 Mon Sep 17 00:00:00 2001 From: Pierpaolo Vendittelli Date: Fri, 21 Aug 2026 14:27:21 +0200 Subject: [PATCH 13/13] Document H3 Studio interface --- README.md | 2 ++ gui/assets/h3-studio-ui.png | Bin 0 -> 83717 bytes 2 files changed, 2 insertions(+) create mode 100644 gui/assets/h3-studio-ui.png diff --git a/README.md b/README.md index 0a84602a..9372b7dc 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ 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, diff --git a/gui/assets/h3-studio-ui.png b/gui/assets/h3-studio-ui.png new file mode 100644 index 0000000000000000000000000000000000000000..5ecb69d62ec6de2c5dacc83f399125f5176d9fcb GIT binary patch literal 83717 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 literal 0 HcmV?d00001