diff --git a/.env.example b/.env.example
new file mode 100644
index 00000000..7ce9f837
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,39 @@
+# Copy to .env and adjust. Only H3_MODEL_DIR is required.
+
+# The MiniMax-H3 checkpoint directory, mounted read-only (about 465 GB).
+H3_MODEL_DIR=./MiniMax-H3
+
+# Where jobs, uploads and generated videos are written. Keep this separate
+# from the local development directory: the container writes as root.
+H3_DATA_DIR=./data
+
+# CUDA architecture the h3 binary is compiled for. sm_121 is the NVIDIA GB10.
+NVCC_ARCH=sm_121
+
+# Interface the UI is published on. 127.0.0.1 keeps it on this machine only.
+# To reach it from another machine, use a private-network address rather than
+# 0.0.0.0: accounts are required, but the service serves plain HTTP. With
+# Tailscale, `tailscale ip -4` prints the address to use, and only your
+# tailnet can then connect.
+H3_BIND=127.0.0.1
+
+# The administrator account. It is created once, on the first start of an
+# empty database; afterwards these values are ignored and the password is
+# managed from the People tab in the UI. Every other account is made with a
+# single-use invite from that tab. Change both before the first start.
+H3_ADMIN_USERNAME=admin
+H3_ADMIN_PASSWORD=change-me-before-first-start
+
+# Largest accepted upload, in bytes (512 MB).
+H3_MAX_UPLOAD_BYTES=536870912
+
+# Optional post-processing runtime. Left empty, the faceswap plugin stays
+# unavailable and nothing is downloaded. See docs/POSTPROCESSING.md.
+H3_FACESWAP_CMD=
+
+# Read only by scripts/faceswap-facefusion.sh, the adapter that install.sh
+# points H3_FACESWAP_CMD at: where FaceFusion is, and the image of the face to
+# put in. The second one is yours to choose, and the stage refuses to run
+# without it.
+H3_FACEFUSION_DIR=
+H3_FACESWAP_SOURCE=
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000..b874b543
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,73 @@
+# CI without a GPU: everything that does not need CUDA or the 465 GB
+# checkpoint. The end-to-end render stays a local gate: no runner has the
+# hardware or the model.
+name: CI
+
+on:
+ push:
+ branches: ["**"]
+ pull_request:
+
+jobs:
+ backend:
+ name: Backend (lint + tests)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - name: Install FFmpeg
+ run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg
+ - name: Install dependencies
+ run: |
+ python -m venv webui/backend/.venv
+ webui/backend/.venv/bin/pip install --upgrade pip
+ webui/backend/.venv/bin/pip install \
+ "fastapi>=0.115" "uvicorn[standard]>=0.34" "pydantic-settings>=2.6" \
+ "argon2-cffi>=23.1" python-multipart pytest httpx ruff
+ - name: Lint
+ run: webui/backend/.venv/bin/ruff check webui
+ - name: Tests
+ run: webui/backend/.venv/bin/pytest webui/backend/tests -q
+
+ frontend:
+ name: Frontend (build + lint)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ - name: Install repo tooling
+ run: npm install --no-audit --no-fund
+ - name: Install frontend dependencies
+ run: npm install --no-audit --no-fund
+ working-directory: webui/frontend
+ - name: Regenerate the options module and check it is committed
+ run: |
+ node scripts/generate-options.mjs
+ git diff --exit-code src/generated/options.ts
+ working-directory: webui/frontend
+ - name: Build
+ run: npm run build
+ working-directory: webui/frontend
+ - name: Lint
+ run: npx eslint .
+
+ compose:
+ name: Docker compose files
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Validate compose configuration
+ env:
+ H3_MODEL_DIR: ./MiniMax-H3
+ # Compose requires it (`:?`); config validation only needs it to
+ # exist, so this placeholder never reaches a running container.
+ H3_ADMIN_PASSWORD: ci-placeholder-not-a-secret
+ run: |
+ docker compose config >/dev/null
+ H3_FACESWAP_CMD=/opt/faceswap/run H3_FACESWAP_DIR=/opt/faceswap \
+ docker compose -f docker-compose.yml -f docker-compose.faceswap.yml \
+ config >/dev/null
diff --git a/.gitignore b/.gitignore
index 6e4c2c90..45663568 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@
misc/
MiniMax-H3/
outputs/
+logs/
.ruff_cache/
# Compiler and test outputs.
@@ -12,7 +13,7 @@ outputs/
h3
h3_*test
h3_*tests
-h3_*bench
+h3_*bench*
h3_dit_bench_864
h3_tests
h3_metal_tests
@@ -28,3 +29,32 @@ h3_real_video_vae_test
h3_semantic_vae_test
libh3.a
.DS_Store
+._*
+
+# Node dev tooling and web UI build output.
+node_modules/
+webui/frontend/dist/
+webui/backend/.venv/
+webui/backend/data/
+data/
+__pycache__/
+.pytest_cache/
+
+# Local docker env
+.env
+
+# Agent method files: they guide work on this machine (R35, 2026-08-30) but
+# are not part of the public repository.
+PLAN.md
+PLAN.template.md
+PLAN_ARCHIVE.md
+AGENTS.md
+CLAUDE.md
+docs/AGENT_SOP.md
+scripts/agent_logging.py
+scripts/health_report.sh
+scripts/install_git_hook.sh
+
+# Python packaging artefacts from a local `pip install` of the backend.
+webui/backend/build/
+*.egg-info/
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 00000000..92a367ae
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,70 @@
+# Contributing
+
+Thanks for looking. This is a small project with a strict habit: **nothing is
+marked done until a command says so.**
+
+## Before you start
+
+If you are adding something substantial, open an issue first and say what you
+intend to change; a plan beats a surprise pull request.
+
+## The gate
+
+One command decides whether a change is acceptable:
+
+```sh
+./scripts/verify.sh all
+```
+
+Its last line is `VERIFY: PASS` or `VERIFY: FAIL`. It runs the C test suite,
+the CUDA primitive tests when `nvcc` is present, `ruff` and `pytest` over
+`webui/`, and ESLint over the repository. A pull request whose gate fails is
+not ready, and "it works on my machine" is not an exit code.
+
+The GPU end-to-end render is not in CI — no runner has a GB10 and the
+checkpoint is 465 GB. If your change can affect generation, run one yourself
+and say so in the pull request:
+
+```sh
+./h3 -d ./MiniMax-H3 -p "A bright red cube on a white background." \
+ --width 256 --height 256 --frames 22 --steps 2 -o outputs/smoke.mp4
+```
+
+## Working on the C engine
+
+- Keep the CLI and the public API in `h3.h` backward compatible. New behaviour
+ is an additive flag that changes nothing when it is absent.
+- Prove non-invasiveness where it matters. `--preview-dir` was accepted because
+ the SHA-256 of the generated mp4 is identical with and without the flag.
+- Both backends stay alive: Metal on Apple Silicon, CUDA on Linux. Selection
+ happens at build time in the `Makefile`, never with a runtime branch.
+
+## Working on the web UI
+
+- `webui/shared/options.schema.json` is the single source of truth for the
+ generation options. Add the flag to `main.c`, then to the schema; a test
+ reads `main.c` and fails if the two drift.
+- The frontend's `src/generated/options.ts` is generated by
+ `node scripts/generate-options.mjs`. Never edit it by hand, and commit the
+ regenerated file: CI checks that it is current.
+- Validation messages are copied verbatim from `h3.c`, so the browser and the
+ engine say the same thing.
+- Tests use a stand-in for the `h3` binary: a shell script that prints progress
+ lines and writes a file. No GPU, no checkpoint, no waiting.
+
+## Style
+
+Match the surrounding code. C follows the existing project conventions; Python
+is formatted for `ruff` with the settings in `webui/backend/pyproject.toml`;
+TypeScript follows the ESLint configuration at the repository root. Comments
+explain why, not what.
+
+Keep diffs surgical: a change that also reformats a neighbouring function is
+two changes, and reviewers can only review one of them.
+
+## Licensing
+
+By contributing you agree that your work is released under the MIT license in
+[`LICENSE`](LICENSE). Do not add model weights, checkpoints or download URLs to
+this repository, and do not add dependencies whose license is more restrictive
+than MIT without saying so explicitly in the pull request.
diff --git a/Makefile b/Makefile
index bb202379..d7bdd3fb 100644
--- a/Makefile
+++ b/Makefile
@@ -1,23 +1,94 @@
-CC := clang
-AR := ar
+PLATFORM ?= $(shell uname -s)
+.DEFAULT_GOAL := all
+AR ?= ar
CFLAGS := -std=c11 -O3 -MMD -MP -Wall -Wextra -Wpedantic -Wshadow \
- -Wconversion -Wno-sign-conversion -D_DARWIN_C_SOURCE
-OBJCFLAGS := $(CFLAGS) -fobjc-arc
+ -Wconversion -Wno-sign-conversion
+
+ifeq ($(PLATFORM),Darwin)
+CC ?= clang
+OBJCFLAGS := $(CFLAGS) -D_DARWIN_C_SOURCE -fobjc-arc
FRAMEWORKS := -framework Foundation -framework Metal \
-framework MetalPerformanceShaders -framework MetalPerformanceShadersGraph \
-framework Accelerate
LDLIBS := $(FRAMEWORKS) -licucore -lm
+DEVICE_LDLIBS := $(FRAMEWORKS)
+DEVICE_SRC := h3_metal.m
+GPU_SRC := h3_gpu.m
+TOKENIZER_SRC := h3_tokenizer.m
+TOKENIZER_OBJ := h3_tokenizer_metal.o
+BACKEND := metal
+PLATFORM_LD := $(CC)
+else ifeq ($(PLATFORM),Linux)
+CC ?= cc
+NVCC ?= nvcc
+CUDA_HOME ?= /usr/local/cuda
+NVCC_ARCH ?= native
+NVCCFLAGS ?= -O3 -std=c++17 -arch=$(NVCC_ARCH) -Xcompiler=-Wall,-Wextra,-Wshadow
+CPPFLAGS += -D_POSIX_C_SOURCE=200809L -I$(CUDA_HOME)/include
+CUDA_LDLIBS := -L$(CUDA_HOME)/lib64 -lcudart -lcublasLt
+CUDNN_ROOT ?=
+CUDNN_FRONTEND_ROOT ?=
+ifneq ($(strip $(CUDNN_ROOT)$(CUDNN_FRONTEND_ROOT)),)
+ifeq ($(strip $(CUDNN_ROOT)),)
+$(error CUDNN_ROOT is required when enabling cuDNN attention)
+endif
+ifeq ($(strip $(CUDNN_FRONTEND_ROOT)),)
+$(error CUDNN_FRONTEND_ROOT is required when enabling cuDNN attention)
+endif
+CPPFLAGS += -DH3_USE_CUDNN -isystem $(CUDNN_ROOT)/include \
+ -isystem $(CUDNN_FRONTEND_ROOT)/include
+CUDNN_LDLIBS := -L$(CUDNN_ROOT)/lib \
+ -Xlinker -rpath -Xlinker $(CUDNN_ROOT)/lib -l:libcudnn.so.9 \
+ -lnvrtc -lcuda
+CUDA_LDLIBS += $(CUDNN_LDLIBS)
+endif
+LDLIBS := $(CUDA_LDLIBS) -lstdc++ -licui18n -licuuc -lm
+DEVICE_LDLIBS := -L$(CUDA_HOME)/lib64 -lcudart
+DEVICE_SRC := h3_device_cuda.cu
+GPU_SRC := h3_gpu_cuda.cu
+TOKENIZER_SRC := h3_tokenizer.c
+TOKENIZER_OBJ := h3_tokenizer.o
+BACKEND := cuda
+PLATFORM_LD := $(NVCC)
+else
+$(error unsupported PLATFORM '$(PLATFORM)'; expected Darwin or Linux)
+endif
LIB_C := h3.c h3_host.c h3_safetensors.c h3_weights.c h3_text_encoder.c \
h3_dit_schedule.c h3_dit.c
LIB_C += h3_video_vae.c h3_video_encoder.c h3_audio_vae.c h3_ffmpeg.c \
h3_terminal.c h3_vision_encoder.c h3_multimodal.c
-LIB_M := h3_metal.m h3_gpu.m h3_tokenizer.m
-LIB_OBJ := $(LIB_C:.c=.o) $(LIB_M:.m=.o)
+LIB_PLATFORM := $(DEVICE_SRC) $(GPU_SRC) $(TOKENIZER_SRC)
+DEVICE_OBJ := $(DEVICE_SRC:.m=.o)
+DEVICE_OBJ := $(DEVICE_OBJ:.cu=.o)
+GPU_OBJ := $(GPU_SRC:.m=.o)
+GPU_OBJ := $(GPU_OBJ:.cu=.o)
+LIB_OBJ := $(LIB_C:.c=.o) $(DEVICE_OBJ) $(GPU_OBJ) $(TOKENIZER_OBJ)
CLI_OBJ := main.o h3_cli.o linenoise.o
-.PHONY: all test parity real-parity clean
+.PHONY: all test host-portable-test tokenizer-portable-test checkpoint-schema-test cuda-runtime-test cuda-primitives-test cuda-rope-tokens-test cuda-linear-test cuda-attention-test cuda-ops-test parity real-parity print-build-config clean
+
+print-build-config:
+ @echo "platform=$(PLATFORM) backend=$(BACKEND) cc=$(CC) sources=$(LIB_PLATFORM)"
+
+h3_host_portable_test: tests/test_host_portable.o h3_host.o
+ $(CC) -o $@ $^ -lm
+
+host-portable-test: h3_host_portable_test
+ ./h3_host_portable_test
+
+h3_tokenizer_portable_test: tests/test_tokenizer_portable.o h3_tokenizer.o
+ $(CC) -o $@ $^ -licui18n -licuuc
+
+tokenizer-portable-test: h3_tokenizer_portable_test
+ ./h3_tokenizer_portable_test
+
+h3_checkpoint_schema_test: tests/test_checkpoint_schema.o h3_safetensors.o h3_weights.o h3_gpu_cuda.o
+ $(NVCC) -o $@ $^ $(CUDA_LDLIBS)
+
+checkpoint-schema-test: h3_checkpoint_schema_test
+ ./h3_checkpoint_schema_test MiniMax-H3
all: h3 libh3.a
@@ -30,14 +101,53 @@ libh3.a: $(LIB_OBJ)
h3_tests: tests/test_h3.o $(LIB_OBJ)
$(CC) -o $@ $^ $(LDLIBS)
+h3_device_test: tests/test_device.o $(DEVICE_OBJ)
+ $(PLATFORM_LD) -o $@ $^ $(DEVICE_LDLIBS)
+
+h3_cuda_runtime_test: tests/test_cuda_runtime.o h3_gpu_cuda.o
+ $(NVCC) -o $@ $^ $(CUDA_LDLIBS)
+
+cuda-runtime-test: h3_cuda_runtime_test
+ ./h3_cuda_runtime_test
+
+h3_cuda_primitives_test: tests/test_cuda_primitives.o h3_gpu_cuda.o
+ $(NVCC) -o $@ $^ $(CUDA_LDLIBS)
+
+cuda-primitives-test: h3_cuda_primitives_test
+ ./h3_cuda_primitives_test
+
+h3_cuda_rope_tokens_test: tests/test_cuda_rope_tokens.o h3_gpu_cuda.o
+ $(NVCC) -o $@ $^ $(CUDA_LDLIBS)
+
+cuda-rope-tokens-test: h3_cuda_rope_tokens_test
+ ./h3_cuda_rope_tokens_test
+
+h3_cuda_linear_test: tests/test_cuda_linear.o h3_gpu_cuda.o
+ $(NVCC) -o $@ $^ $(CUDA_LDLIBS)
+
+cuda-linear-test: h3_cuda_linear_test
+ ./h3_cuda_linear_test
+
+h3_cuda_attention_test: tests/test_cuda_attention.o h3_gpu_cuda.o
+ $(NVCC) -o $@ $^ $(CUDA_LDLIBS)
+
+cuda-attention-test: h3_cuda_attention_test
+ ./h3_cuda_attention_test
+
+h3_cuda_ops_test: tests/test_cuda_ops.o h3_gpu_cuda.o
+ $(NVCC) -o $@ $^ $(CUDA_LDLIBS)
+
+cuda-ops-test: h3_cuda_ops_test
+ ./h3_cuda_ops_test
+
h3_metal_tests: tests/test_metal.o $(LIB_OBJ)
$(CC) -o $@ $^ $(LDLIBS)
h3_bf16_tests: tests/test_bf16.o $(LIB_OBJ)
$(CC) -o $@ $^ $(LDLIBS)
-h3_tokenizer_tests: tests/test_tokenizer.o $(LIB_OBJ)
- $(CC) -o $@ $^ $(LDLIBS)
+h3_tokenizer_tests: tests/test_tokenizer.o $(TOKENIZER_OBJ)
+ $(CC) -o $@ $^ $(if $(filter Darwin,$(PLATFORM)),-licucore,-licui18n -licuuc)
h3_text_tests: tests/test_text_metal.o $(LIB_OBJ)
$(CC) -o $@ $^ $(LDLIBS)
@@ -91,9 +201,27 @@ tests/bench_dit_864.o: tests/bench_dit.c
$(CC) $(CFLAGS) -I. -DH3_BENCH_LATENT_H=30 \
-DH3_BENCH_LATENT_W=54 -c $< -o $@
+h3_dit_bench_quality: tests/bench_dit_quality.o $(LIB_OBJ)
+ $(CC) -o $@ $^ $(LDLIBS)
+
+tests/bench_dit_quality.o: tests/bench_dit.c
+ $(CC) $(CPPFLAGS) $(CFLAGS) -I. -DH3_BENCH_LATENT_H=36 \
+ -DH3_BENCH_LATENT_W=64 -DH3_BENCH_LATENT_T=32 \
+ -DH3_BENCH_AUDIO_T=178 -c $< -o $@
+
h3_real_video_vae_test: tests/test_real_video_vae.o $(LIB_OBJ)
$(CC) -o $@ $^ $(LDLIBS)
+h3_vae_bench_quality: tests/bench_video_vae.o $(LIB_OBJ)
+ $(CC) -o $@ $^ $(LDLIBS)
+
+h3_vae_bench_smoke: tests/bench_video_vae_smoke.o $(LIB_OBJ)
+ $(CC) -o $@ $^ $(LDLIBS)
+
+tests/bench_video_vae_smoke.o: tests/bench_video_vae.c
+ $(CC) $(CPPFLAGS) $(CFLAGS) -I. -DH3_BENCH_VAE_LATENT_T=7 \
+ -DH3_BENCH_VAE_LATENT_H=4 -DH3_BENCH_VAE_LATENT_W=4 -c $< -o $@
+
h3_semantic_vae_test: tests/test_semantic_vae.o $(LIB_OBJ)
$(CC) -o $@ $^ $(LDLIBS)
@@ -188,13 +316,19 @@ real-parity: h3_real_prompt_test h3_real_dit_block_test
./h3_real_dit_block_test MiniMax-H3 misc/fixtures/h3_real_dit_block0_bf16.safetensors
%.o: %.c
- $(CC) $(CFLAGS) -I. -c $< -o $@
+ $(CC) $(CPPFLAGS) $(CFLAGS) -I. -c $< -o $@
%.o: %.m
- $(CC) $(OBJCFLAGS) -I. -c $< -o $@
+ $(CC) $(CPPFLAGS) $(OBJCFLAGS) -I. -c $< -o $@
+
+h3_tokenizer_metal.o: h3_tokenizer.m
+ $(CC) $(CPPFLAGS) $(OBJCFLAGS) -I. -c $< -o $@
+
+%.o: %.cu
+ $(NVCC) $(CPPFLAGS) $(NVCCFLAGS) -I. -c $< -o $@
tests/%.o: tests/%.c
- $(CC) $(CFLAGS) -I. -c $< -o $@
+ $(CC) $(CPPFLAGS) $(CFLAGS) -I. -c $< -o $@
# Vendored from Iris. Keep the main project strict without rewriting this small
# terminal editor for conversion diagnostics unrelated to H3.
@@ -203,7 +337,7 @@ linenoise.o: CFLAGS += -Wno-conversion -Wno-variadic-macro-arguments-omitted
-include $(wildcard *.d tests/*.d)
clean:
- rm -f h3 h3_tests h3_metal_tests h3_bf16_tests h3_tokenizer_tests \
+ rm -f h3 h3_tests h3_device_test h3_host_portable_test h3_tokenizer_portable_test h3_checkpoint_schema_test h3_cuda_runtime_test h3_cuda_primitives_test h3_cuda_rope_tokens_test h3_cuda_linear_test h3_cuda_attention_test h3_cuda_ops_test h3_metal_tests h3_bf16_tests h3_tokenizer_tests \
h3_text_tests h3_real_prompt_test h3_real_dit_block_test \
h3_audio_gpu_tests h3_real_audio_vae_test h3_real_audio_encoder_test \
h3_av_mux_test \
@@ -211,5 +345,6 @@ clean:
h3_real_multimodal_text_test h3_real_ref_video_text_test \
h3_real_dit_schedule_test h3_real_dit_test h3_semantic_dit_test \
h3_real_video_vae_test h3_semantic_vae_test \
- h3_dit_bench h3_dit_bench_864 \
+ h3_dit_bench h3_dit_bench_864 h3_dit_bench_quality h3_vae_bench_quality \
+ h3_vae_bench_smoke \
libh3.a *.o *.d tests/*.o tests/*.d
diff --git a/README.md b/README.md
index 4750ac49..683d3777 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,127 @@
-# h3-metal
+
-Native MiniMax-H3 inference for Apple Silicon. The project is being built as a
-sequence of working vertical slices: deterministic host/model metadata first,
-then portable Metal block parity, prompt encoding, prompt-to-video/audio, and
-first/last-frame conditioning and then ordered references.
+# h3.c
-Prompt-to-video/audio, first/last-frame conditioning, and ordered Ref2VA
-image/video/audio references work end to end. The current work is incremental
-H3-specific Metal performance and memory optimization on M3 Max and M5 Max.
+Native MiniMax-H3 inference in C for NVIDIA CUDA on Linux and Metal on Apple
+Silicon. Prompt-to-video/audio, first/last-frame conditioning, and ordered
+Ref2VA image/video/audio references work end to end on both backends.
+
+**h3c studio**, the included browser front end, exposes every generation
+option of the CLI with a live preview of the denoising — if you would rather
+click than type, start below. The rest of this document is the CLI.
+
+In a hurry: `install.sh` does the setup below for you, and asks before every
+download. See [Installing with the script](#installing-with-the-script).
+
+## h3c studio (Web UI)
+
+`h3c studio` is a small FastAPI backend and a React front end that drive the
+same `h3` binary this document describes. Every CLI option is reachable from
+the browser, generation progress streams live with a weighted progress bar,
+and uploaded photos and clips stay in a reusable library.
+
+```sh
+cp .env.example .env # set H3_MODEL_DIR to your checkpoint
+docker compose up --build # then open http://127.0.0.1:8080
+```
+
+Docker needs the NVIDIA Container Toolkit; the checkpoint is bind-mounted
+read-only and never enters an image. A local, non-Docker setup and the design
+notes are in [`docs/WEBUI.md`](docs/WEBUI.md).
+
+Sign in with the administrator account you define in `.env`
+(`H3_ADMIN_USERNAME`, `H3_ADMIN_PASSWORD`): it is created once, on the first
+start of an empty database. Every other account is made with a single-use
+invite from the People page, and videos and uploads stay private to the
+person who made them. The service serves plain HTTP and binds to
+`127.0.0.1`; to reach it from another machine, set `H3_BIND` to a private
+address — a Tailscale address, or an SSH tunnel — and put a TLS-terminating
+reverse proxy in front of it if it is anything but a network you trust. See
+[`docs/WEBUI.md`](docs/WEBUI.md#security).
+
+An optional post-processing stage can hand the finished video to an external
+program. This repository ships no such program and no models: see
+[`docs/POSTPROCESSING.md`](docs/POSTPROCESSING.md).
+
+### Gallery
+
+All five views of the studio (synthetic demo data):
+
+
+
+ Sign in — invites create the other accounts
+ The prompt page, with the live denoising strip
+
+
+ Expert page — every engine control, validated live
+ People — accounts, single-use invites, quotas
+
+
+ Takes — the finished library, download and reuse
+
+
+
+## Platforms and prerequisites
+
+The validated Linux configuration is Ubuntu ARM64, NVIDIA GB10, driver 595.84,
+and CUDA Toolkit 13.0. The supported Linux baseline is a C11 compiler, GNU
+Make, CUDA Toolkit 13.0 or newer, ICU 72 or newer, and FFmpeg/FFprobe 6.1 or
+newer. The build links `cudart`, `cublasLt`, ICU, pthreads, and libm. NVIDIA
+drivers must support the installed toolkit and GPU. macOS continues to use the
+Metal backend and the Objective-C/Foundation tokenizer; its existing Apple
+Silicon optimization switches remain available there.
+
+Install the ordinary Ubuntu build dependencies with your package manager and
+install CUDA from NVIDIA's repository for the target architecture. Verify the
+toolchain before building:
+
+```sh
+cc --version
+nvcc --version
+ffmpeg -version
+pkg-config --modversion icu-uc
+```
+
+The released checkpoint is about 465 GB. If `./MiniMax-H3` already contains
+the complete snapshot, reuse it: no second download is needed. Otherwise the
+current Hugging Face CLI resumes and reuses cached blobs:
+
+```sh
+hf auth login
+hf download MiniMaxAI/MiniMax-H3 --local-dir ./MiniMax-H3
+hf cache verify MiniMaxAI/MiniMax-H3 --local-dir ./MiniMax-H3 \
+ --fail-on-missing-files
+```
+
+Build and run the complete local gate:
+
+```sh
+make -j"$(nproc)"
+./scripts/verify.sh all
+./h3 --info -d ./MiniMax-H3
+```
+
+The GB10 end-to-end command validated with the official checkpoint is:
+
+```sh
+mkdir -p outputs
+./h3 --profile -d ./MiniMax-H3 \
+ -p "A bright red cube rotates smoothly on a white background." \
+ --width 512 --height 512 --frames 22 --steps 20 --layers 50 \
+ --token-reduction -o outputs/gb10-cube.mp4
+```
+
+Linux/CUDA limitations: `--use-int8-row-fc2` is currently a Metal/M5
+specialization and a measured CUDA no-op; there is no verified FP8 execution
+path. `--show` depends on terminal graphics support. `--ssd-streaming` is exact
+and cuts the measured GB10 DiT peak from 27.06 GB to 1.63 GB, but increased
+load-plus-denoise time by 37.6% in the three-run benchmark. See
+[`docs/GB10_PROFILE.md`](docs/GB10_PROFILE.md) for the reproducible measurements.
## Tutorial
@@ -22,7 +136,7 @@ mkdir -p outputs
./h3 --info -d ./MiniMax-H3
```
-`--info` checks the model layout and prints the selected Metal device without
+`--info` checks the model layout and prints the selected GPU device without
mapping all weights or generating media. Run `./h3 --help` for the complete CLI
reference.
@@ -403,6 +517,45 @@ Standalone audio must accompany an image or video reference. Audio references
must be 2–15 seconds; at most three audio inputs are accepted and their total
decoded duration is capped at 15 seconds.
+## Installing with the script
+
+`install.sh` checks the prerequisites, puts the repository in place and writes
+a `.env`. It downloads **nothing** unless asked: the checkpoint and the
+optional face-swapping runtime are separate questions, and both can be
+declined. Read it before running it: it is deliberately not written to be
+piped from a URL into a shell.
+
+```sh
+git clone https://github.com/matrixfede/h3.c.git ~/h3
+cd ~/h3
+less install.sh
+./install.sh
+```
+
+Run from outside a checkout, it clones the repository itself:
+
+```sh
+bash install.sh --dir ~/h3
+```
+
+Both accept `--branch NAME` if you want a branch other than the default
+one. Either way the options are the same:
+
+```sh
+./install.sh # repository and .env only
+./install.sh --with-model # and the 465 GB checkpoint
+./install.sh --yes # no questions; nothing optional is done
+./install.sh --help
+```
+
+`--with-model` runs exactly the `hf download` and `hf cache verify` commands
+documented above, into `/MiniMax-H3`, and refuses to start if the
+filesystem cannot hold the checkpoint. A snapshot that is already complete is
+left alone.
+
+`--with-faceswap` is off by default and explained in
+[`docs/POSTPROCESSING.md`](docs/POSTPROCESSING.md).
+
## Tests and runtime requirements
```sh
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 460e0a0f..73b90442 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -30,3 +30,37 @@ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+---
+
+## Upstream project
+
+`h3.c` began as [antirez/h3.c](https://github.com/antirez/h3.c) by Salvatore
+Sanfilippo, released under the MIT license reproduced in `LICENSE`. This fork
+keeps that license and adds the CUDA backend and the web UI under the same
+terms.
+
+## Web UI dependencies
+
+The web UI does not vendor any third-party source. Its dependencies are
+installed from their own registries at build time and each keeps its own
+license:
+
+| Component | Where | License |
+| --- | --- | --- |
+| FastAPI, Starlette | backend | MIT |
+| Uvicorn | backend | BSD-3-Clause |
+| Pydantic, pydantic-settings | backend | MIT |
+| python-multipart | backend | Apache-2.0 |
+| argon2-cffi (Argon2 password hashing) | backend | MIT |
+| React, React DOM | frontend | MIT |
+| Vite, @vitejs/plugin-react | frontend | MIT |
+| TypeScript | frontend | Apache-2.0 |
+| ESLint, typescript-eslint | dev tooling | MIT |
+| Playwright | dev tooling | Apache-2.0 |
+| nginx | container image | BSD-2-Clause |
+| FFmpeg | runtime dependency | LGPL-2.1-or-later or GPL-2.0-or-later, depending on the build |
+
+The MiniMax-H3 checkpoint is **not** part of this repository and is covered by
+its own license from MiniMax. No model weights of any kind are distributed
+here, including for the optional post-processing stage.
diff --git a/docker-compose.faceswap.yml b/docker-compose.faceswap.yml
new file mode 100644
index 00000000..f9c9882c
--- /dev/null
+++ b/docker-compose.faceswap.yml
@@ -0,0 +1,19 @@
+# Optional override that wires an already installed face-swapping runtime into
+# the post-processing stage. It is NOT active by default:
+#
+# docker compose -f docker-compose.yml -f docker-compose.faceswap.yml up
+#
+# This repository ships no models, no weights and no download URLs. You install
+# the runtime yourself, you check its licence, and you are responsible for the
+# consent of anyone whose face you process. See docs/POSTPROCESSING.md for the
+# contract the executable must satisfy.
+
+services:
+ api:
+ environment:
+ # Path *inside the container* of the executable implementing the
+ # --input/--output contract.
+ H3_FACESWAP_CMD: ${H3_FACESWAP_CMD:?set H3_FACESWAP_CMD to your runtime}
+ volumes:
+ # Mount your own runtime and its models read-only.
+ - ${H3_FACESWAP_DIR:?set H3_FACESWAP_DIR to the runtime directory}:/opt/faceswap:ro
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 00000000..f53eb142
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,58 @@
+# h3.c Studio: the CUDA backend and the static UI.
+#
+# Prerequisites: an NVIDIA driver matching CUDA 13, the NVIDIA Container
+# Toolkit, and the MiniMax-H3 checkpoint on disk (about 465 GB). The checkpoint
+# is never copied into an image: it is bind-mounted read-only.
+#
+# cp .env.example .env # then set H3_MODEL_DIR
+# docker compose up --build
+#
+# The UI listens on 127.0.0.1 only. Every call needs an account: the
+# administrator is bootstrapped from H3_ADMIN_USERNAME/H3_ADMIN_PASSWORD, the
+# others come from single-use invites. But the service speaks plain HTTP, so
+# outside a network you trust put a TLS-terminating reverse proxy in front, or
+# publish on a private overlay: a Tailscale address in H3_BIND keeps only your
+# own devices reachable. Binding to 0.0.0.0 hands the login screen — and the
+# GPU behind it — to anyone on the LAN.
+
+services:
+ api:
+ build:
+ context: .
+ dockerfile: docker/Dockerfile.backend
+ args:
+ NVCC_ARCH: ${NVCC_ARCH:-sm_121}
+ environment:
+ H3_MODEL_DIR: /models
+ H3_DATA_DIR: /data
+ # The administrator account, created once on an empty database.
+ H3_ADMIN_USERNAME: ${H3_ADMIN_USERNAME:-admin}
+ H3_ADMIN_PASSWORD: ${H3_ADMIN_PASSWORD:?set H3_ADMIN_PASSWORD in .env}
+ H3_MAX_UPLOAD_BYTES: ${H3_MAX_UPLOAD_BYTES:-536870912}
+ # Post-processing stays off unless you install a runtime yourself.
+ H3_FACESWAP_CMD: ${H3_FACESWAP_CMD:-}
+ volumes:
+ - ${H3_MODEL_DIR:?set H3_MODEL_DIR in .env}:/models:ro
+ - ${H3_DATA_DIR:-./data}:/data
+ # The API is only published for local debugging: the web container reaches
+ # it over the compose network, and nginx proxies /api for the browser.
+ ports:
+ - "127.0.0.1:8000:8000"
+ deploy:
+ resources:
+ reservations:
+ devices:
+ - driver: nvidia
+ count: all
+ capabilities: [gpu]
+ restart: unless-stopped
+
+ web:
+ build:
+ context: .
+ dockerfile: docker/Dockerfile.frontend
+ depends_on:
+ - api
+ ports:
+ - "${H3_BIND:-127.0.0.1}:8080:80"
+ restart: unless-stopped
diff --git a/docker/Dockerfile.backend b/docker/Dockerfile.backend
new file mode 100644
index 00000000..1b781012
--- /dev/null
+++ b/docker/Dockerfile.backend
@@ -0,0 +1,62 @@
+# Builds h3 for CUDA, then ships only the binary and the Python service.
+# The 465 GB checkpoint is never copied in: it is bind-mounted at runtime.
+ARG CUDA_VERSION=13.0.0
+ARG UBUNTU_VERSION=24.04
+
+FROM nvidia/cuda:${CUDA_VERSION}-devel-ubuntu${UBUNTU_VERSION} AS build
+# nvcc is already in the image: this stage only needs Ubuntu packages. NVIDIA's
+# apt repository is dropped because it is a frequent source of hash-sum
+# mismatches behind caching proxies, and apt is told to retry without
+# pipelining, which is what makes those mismatches transient.
+RUN set -eux; \
+ rm -f /etc/apt/sources.list.d/cuda*.list \
+ /etc/apt/sources.list.d/nvidia*.list \
+ /etc/apt/sources.list.d/*cuda*.sources; \
+ printf 'Acquire::Retries "5";\nAcquire::http::Pipeline-Depth "0";\nAcquire::http::No-Cache "true";\nAcquire::BrokenProxy "true";\n' \
+ > /etc/apt/apt.conf.d/99-h3-resilient; \
+ apt-get clean; rm -rf /var/lib/apt/lists/*; \
+ apt-get update; \
+ apt-get install -y --no-install-recommends build-essential libicu-dev pkg-config; \
+ rm -rf /var/lib/apt/lists/*
+WORKDIR /src
+# Only what the C build needs, so editing the web UI does not rebuild h3.
+COPY Makefile *.c *.h *.cu *.metal ./
+COPY tests ./tests
+# A concrete architecture, because `native` needs a visible GPU and the build
+# stage has none. sm_121 is the GB10; override with --build-arg NVCC_ARCH=...
+ARG NVCC_ARCH=sm_121
+RUN make -j"$(nproc)" NVCC_ARCH=${NVCC_ARCH} h3
+
+FROM nvidia/cuda:${CUDA_VERSION}-runtime-ubuntu${UBUNTU_VERSION} AS runtime
+# Same treatment: only Ubuntu packages are needed here.
+RUN set -eux; \
+ rm -f /etc/apt/sources.list.d/cuda*.list \
+ /etc/apt/sources.list.d/nvidia*.list \
+ /etc/apt/sources.list.d/*cuda*.sources; \
+ printf 'Acquire::Retries "5";\nAcquire::http::Pipeline-Depth "0";\nAcquire::http::No-Cache "true";\nAcquire::BrokenProxy "true";\n' \
+ > /etc/apt/apt.conf.d/99-h3-resilient; \
+ apt-get clean; rm -rf /var/lib/apt/lists/*; \
+ apt-get update; \
+ apt-get install -y --no-install-recommends ffmpeg libicu74 python3 python3-venv; \
+ rm -rf /var/lib/apt/lists/*
+WORKDIR /app
+COPY --from=build /src/h3 /app/h3
+COPY webui/backend/pyproject.toml /app/webui/backend/pyproject.toml
+RUN python3 -m venv /opt/venv \
+ && /opt/venv/bin/pip install --no-cache-dir -q --upgrade pip \
+ && /opt/venv/bin/pip install --no-cache-dir -q \
+ "fastapi>=0.115" "uvicorn[standard]>=0.34" "pydantic-settings>=2.6" \
+ "argon2-cffi>=23.1" \
+ python-multipart
+COPY webui/backend/app /app/webui/backend/app
+COPY webui/shared /app/webui/shared
+
+ENV PYTHONUNBUFFERED=1 \
+ H3_BINARY=/app/h3 \
+ H3_MODEL_DIR=/models \
+ H3_DATA_DIR=/data \
+ H3_SCHEMA_PATH=/app/webui/shared/options.schema.json \
+ H3_PROGRESS_WEIGHTS_PATH=/app/webui/shared/progress_weights.json
+EXPOSE 8000
+WORKDIR /app/webui/backend
+CMD ["/opt/venv/bin/uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/docker/Dockerfile.frontend b/docker/Dockerfile.frontend
new file mode 100644
index 00000000..359d77ad
--- /dev/null
+++ b/docker/Dockerfile.frontend
@@ -0,0 +1,13 @@
+# Builds the static UI and serves it behind nginx, which also proxies /api.
+FROM node:22-slim AS build
+WORKDIR /src
+COPY webui/frontend/package.json webui/frontend/package-lock.json* ./
+RUN npm install --no-audit --no-fund
+COPY webui/shared /shared
+COPY webui/frontend ./
+RUN node scripts/generate-options.mjs && npm run build
+
+FROM nginx:1.27-alpine AS runtime
+COPY --from=build /src/dist /usr/share/nginx/html
+COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
+EXPOSE 80
diff --git a/docker/nginx.conf b/docker/nginx.conf
new file mode 100644
index 00000000..cfd37c4c
--- /dev/null
+++ b/docker/nginx.conf
@@ -0,0 +1,23 @@
+server {
+ listen 80;
+ server_name _;
+ root /usr/share/nginx/html;
+
+ # Generated videos are large; uploads are capped by the backend too.
+ client_max_body_size 1024m;
+
+ location / {
+ try_files $uri /index.html;
+ }
+
+ location /api/ {
+ proxy_pass http://api:8000;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ # Server-sent events: no buffering, no timeout during a long phase.
+ proxy_buffering off;
+ proxy_cache off;
+ proxy_read_timeout 24h;
+ }
+}
diff --git a/docs/CUDA_GPU_API_INVENTORY.md b/docs/CUDA_GPU_API_INVENTORY.md
new file mode 100644
index 00000000..988f16ed
--- /dev/null
+++ b/docs/CUDA_GPU_API_INVENTORY.md
@@ -0,0 +1,189 @@
+# Inventario API GPU per il port CUDA
+
+Fonte di verità: `h3_gpu.h` al commit `8974cc0`. Le shape sono espresse in
+ordine di memoria row-major; `N` indica il numero di elementi. `custom+BLAS`
+indica un wrapper che usa cuBLASLt per GEMM e kernel CUDA per layout/fusione.
+
+## Runtime, tensori e profiling
+
+| API | Backend CUDA | Dtype e shape | Verifica CUDA prevista |
+|---|---|---|---|
+| `h3_gpu_create` | host + CUDA runtime/NVRTC | contesto; sorgente shader ignorata su CUDA | create su device 0, errore leggibile senza device |
+| `h3_gpu_free` | host + CUDA runtime | contesto | teardown senza leak (Compute Sanitizer) |
+| `h3_gpu_is_m5` | host | N/A; sempre 0 su CUDA | capability test |
+| `h3_gpu_has_nax_mlp` | host | N/A; sempre 0 su CUDA | capability test |
+| `h3_gpu_has_int8_mlp` | host | N/A; 1 se cuBLASLt INT8 disponibile | capability test |
+| `h3_gpu_tensor_new_f32` | CUDA runtime | F32 `[N]` | allocazione, dtype, contatori |
+| `h3_gpu_tensor_new_bf16` | CUDA runtime | BF16 `[N]` | allocazione, dtype, contatori |
+| `h3_gpu_tensor_new_i8` | CUDA runtime | I8 `[N]` | allocazione, dtype, contatori |
+| `h3_gpu_tensor_from_f32` | host + CUDA runtime | F32 host `[N]` -> device `[N]` | round-trip esatto |
+| `h3_gpu_tensor_from_bf16` | host + CUDA runtime | BF16 host `[N]` -> device `[N]` | round-trip bit-esatto |
+| `h3_gpu_tensor_from_u32` | host + CUDA runtime | U32 host `[N]` -> device `[N]` | round-trip bit-esatto |
+| `h3_gpu_tensor_load_bf16` | host + CUDA runtime | file BF16 `[N]` -> device `[N]` | fixture safetensors, offset e short read |
+| `h3_gpu_tensor_load_f32` | host + CUDA runtime | file F32 `[N]` -> device `[N]` | fixture safetensors, offset e short read |
+| `h3_gpu_tensor_read_file_bf16` | host + CUDA runtime | file BF16 `[N]` -> tensor BF16 `[N]` | reload in-place e bounds |
+| `h3_gpu_tensor_stream_file_bf16` | host + CUDA runtime | file BF16 `[N]` -> tensor BF16 `[N]` | stesso golden del read; hint cache best-effort |
+| `h3_gpu_tensor_free` | CUDA runtime | tensor | double ownership escluso; contatori e leak |
+| `h3_gpu_tensor_elements` | host | metadata `N` | tutti i costruttori |
+| `h3_gpu_tensor_dtype` | host | metadata F32/BF16/I8/U32 | tutti i costruttori |
+| `h3_gpu_tensor_read_f32` | CUDA runtime + custom | tensor F32/BF16 `[N]` -> host F32 `[N]` | F32 esatto; BF16 conversione esatta |
+| `h3_gpu_tensor_read_f32_range` | CUDA runtime + custom | tensor F32/BF16 `[N]`, slice `[offset, n]` -> host F32 | offset/bounds e conversione |
+| `h3_gpu_tensor_read_bf16` | CUDA runtime | BF16 `[N]` -> host BF16 `[N]` | bit-esatto |
+| `h3_gpu_tensor_write_f32` | CUDA runtime + custom | host F32 `[N]` -> tensor F32/BF16 `[N]` | F32 esatto; BF16 round-to-nearest-even |
+| `h3_gpu_tensor_write_f32_range` | CUDA runtime + custom | host F32 `[n]` -> slice tensor `[offset,n]` | offset/bounds, sentinelle adiacenti |
+| `h3_gpu_tensor_write_bf16` | CUDA runtime | host BF16 `[N]` -> tensor BF16 `[N]` | bit-esatto |
+| `h3_gpu_tensor_write_bf16_range` | CUDA runtime | host BF16 `[n]` -> slice `[offset,n]` | bit-esatto e bounds |
+| `h3_gpu_begin` | CUDA runtime | stream/graph corrente | state-machine test |
+| `h3_gpu_continue` | CUDA runtime | event + stream ordinato | due tranche, risultato e ordine |
+| `h3_gpu_submit` | CUDA runtime | sincronizzazione stream | error propagation e contatori |
+| `h3_gpu_error` | host | stringa | errore sintetico non vuoto |
+| `h3_gpu_get_stats` | host + CUDA events | `h3_gpu_stats` | allocazioni, dispatch, submission, tempi non negativi |
+| `h3_gpu_profile_set_label` | host | stringa | smoke con `H3_PROFILE` |
+| `h3_gpu_profile_mark` | host + CUDA events | fase | smoke con `H3_PROFILE`, stream ordinato |
+
+## Algebra lineare, quantizzazione e fusioni
+
+| API | Backend CUDA | Dtype e shape | Verifica CUDA prevista |
+|---|---|---|---|
+| `h3_gpu_linear_f32` | cuBLASLt | X F32 `[R,K]`, W F32 `[O,K]`, bias `[O]?` -> Y `[R,O]` | fixture `test_metal`; CPU piccoli |
+| `h3_gpu_patch_linear_bf16` | cuBLASLt | X/W/B/Y BF16, `[R,K]·[O,K] -> [R,O]` | BF16 fixture + CPU piccoli |
+| `h3_gpu_patch_linear_bf16_offset` | cuBLASLt | come sopra con offset elemento input/output | sentinelle e parità patch-linear |
+| `h3_gpu_patch_linear_bf16_map` | custom+BLAS | X `[R,K]`, map U32 `[R]`, Y `[output_rows,O]` | map sparsa, duplicati, bounds |
+| `h3_gpu_linear_bf16` | cuBLASLt | X/W/B/Y BF16, `[R,K]·[O,K] -> [R,O]`, accumulo F32 | `test_bf16`, `test_text_metal`, fixture reali |
+| `h3_gpu_mlp_bf16` | custom+BLAS | X `[R,K]`, W1 `[2H,K]`, W2 `[O,H]` -> Y `[R,O]` | `test_bf16`, confronto pipeline non fusa |
+| `h3_gpu_mlp_nax_bf16` | host fallback | stesso contratto MLP; alias della pipeline portabile su CUDA | risultato identico a `mlp_bf16`, capability false |
+| `h3_gpu_quantize_weight_int8` | CUDA custom | BF16/F32 `[R,C]` -> I8 `[R,C]` + scale F32 `[R]` | dequant CPU, errore per-canale |
+| `h3_gpu_linear_int8_bf16` | custom+cuBLASLt | X BF16 `[R,K]`, W I8 `[O,K]`, scale X `[R]`, W `[O]` -> BF16 `[R,O]` | CPU INT8 e confronto BF16 |
+| `h3_gpu_linear_int8_head_major_bf16` | custom+cuBLASLt | X BF16 `[heads,R,D]`, W I8 `[O,heads*D]` -> BF16 `[R,O]` | confronto transpose+linear |
+| `h3_gpu_mlp_int8_bf16` | custom+cuBLASLt | X `[R,K]`, W1 I8 `[2H,K]`, W2 I8 `[O,H]` -> BF16 `[R,O]` | confronto MLP BF16 e flag-paths |
+| `h3_gpu_adaln_linear_bf16` | custom+cuBLASLt | AdaLN `[R,W]` + Wgt `[O,W]` -> BF16 `[R,O]`, inverse `[R]` | `test_bf16`, confronto due chiamate |
+| `h3_gpu_grouped_qkv_linear_rope_bf16` | custom+cuBLASLt | X `[R,K]`, W `[3*H*D,K]` -> Q/K/V `[H,R,D]` | `test_bf16`, confronto linear+grouped QKV |
+| `h3_gpu_grouped_qkv_linear_rope_int8` | custom+cuBLASLt | X BF16/I8 `[R,K]`, W I8 `[3HD,K]` -> Q/K/V BF16 `[H,R,D]` | confronto variante BF16 e flag-paths |
+
+## Elementwise, norm, embedding, RoPE e token transforms
+
+| API | Backend CUDA | Dtype e shape | Verifica CUDA prevista |
+|---|---|---|---|
+| `h3_gpu_silu_f32` | CUDA custom | F32 `[N] -> [N]` | CPU edge values |
+| `h3_gpu_cast_f32_to_bf16` | CUDA custom | F32 `[N] -> BF16 [N]` | bit oracle inclusi NaN/Inf |
+| `h3_gpu_cast_bf16_to_f32` | CUDA custom | BF16 `[N] -> F32 [N]` | bit oracle |
+| `h3_gpu_copy_bf16` | CUDA runtime | BF16 slice `[src,n] -> [dst,n]` | sentinelle, overlap policy |
+| `h3_gpu_copy_f32` | CUDA runtime | F32 slice `[src,n] -> [dst,n]` | sentinelle, overlap policy |
+| `h3_gpu_rms_norm_f32` | CUDA custom | X F32 `[R,W]`, weight `[W]` -> F32 `[R,W]` | `test_metal`/CPU |
+| `h3_gpu_adaln_f32` | CUDA custom | X `[R,W]`, norm `[W]`, mod `[M,S,W]`, map U32 `[R]` -> `[R,W]` | `test_metal`/CPU |
+| `h3_gpu_gate_f32` | CUDA custom | residual/branch `[R,W]`, mod `[M,S,W]`, map `[R]` -> `[R,W]` | `test_metal`/CPU |
+| `h3_gpu_swiglu_f32` | CUDA custom | fused F32 `[R,2W] -> [R,W]` | `test_metal`/CPU |
+| `h3_gpu_scale_add_f32` | CUDA custom | residual/branch `[R,W]`, scale `[R]` o `[1]` -> `[R,W]` | CPU broadcast cases |
+| `h3_gpu_layer_norm_f32` | CUDA custom | X `[R,W]`, weight/bias `[W]` -> `[R,W]` | CPU, constant row |
+| `h3_gpu_weight_norm_f32` | CUDA custom | vector `[outer,inner]`, magnitude `[outer]` -> `[outer,inner]` | `test_audio_gpu`, abs `2e-6` |
+| `h3_gpu_add_scaled_f32` | CUDA custom | left/right F32 `[N] -> [N]` | `test_audio_gpu`, abs `1e-7` |
+| `h3_gpu_alias_free_snake_f32` | CUDA custom | X F32 `[B,L,C]`, params `[C]`, filters `[12]` -> `[B,L,C]` | `test_audio_gpu`, abs `2e-5` |
+| `h3_gpu_snake1d_f32` | CUDA custom | X F32 `[B,L,C]`, alpha `[C]` -> `[B,L,C]` | CPU edge values |
+| `h3_gpu_geglu_f32` | CUDA custom | gate/linear F32 `[N] -> [N]` | CPU GELU oracle |
+| `h3_gpu_clip_f32` | CUDA custom | F32 `[N] -> [N]` | `test_audio_gpu`, abs `1e-7` |
+| `h3_gpu_silu_bf16` | CUDA custom | BF16 `[N] -> [N]`, accumulo F32 | `test_bf16`, BF16 boundary |
+| `h3_gpu_rms_norm_bf16` | CUDA custom | X BF16 `[R,W]`, weight `[W]` -> BF16 `[R,W]` | `test_bf16`, fixture reali |
+| `h3_gpu_layer_norm_bf16` | CUDA custom | X BF16 `[R,W]`, weight/bias `[W]` -> BF16 `[R,W]` | vision fixture + CPU |
+| `h3_gpu_gelu_bf16` | CUDA custom | BF16 `[N] -> [N]`, exact/approx flag | entrambe le modalità contro CPU |
+| `h3_gpu_vision_qkv_rope_bf16` | CUDA custom | QKV `[S,3,H,D]` -> Q/K/V `[H,S,D]` BF16 | vision fixture |
+| `h3_gpu_adaln_bf16` | CUDA custom | BF16 equivalente AdaLN F32 `[R,W]` | `test_bf16` |
+| `h3_gpu_adaln_bf16_offset` | CUDA custom | come AdaLN, input slice da offset | `test_bf16`, sentinelle |
+| `h3_gpu_gate_bf16` | CUDA custom | BF16 equivalente gate `[R,W]` | `test_bf16` |
+| `h3_gpu_gate_adaln_bf16` | CUDA custom | gate residual + AdaLN, due output `[R,W]` | `test_bf16`, confronto non fuso |
+| `h3_gpu_gate_adaln_quantize_int8` | CUDA custom | gate/AdaLN BF16 `[R,W]` -> residual BF16 + I8 `[padded_R,W]`, scale `[padded_R]` | confronto non fuso + dequant |
+| `h3_gpu_qkv_rope_bf16` | CUDA custom | QKV BF16 `[S,3,H,D]` -> Q/K/V `[H,S,D]` | `test_bf16` |
+| `h3_gpu_grouped_qkv_rope_bf16` | CUDA custom | QKV BF16 `[S,H,3,D]` -> Q/K/V `[H,S,D]` | `test_bf16`, bit-identico alla permutazione |
+| `h3_gpu_swiglu_bf16` | CUDA custom | fused BF16 `[R,2W] -> [R,W]` | `test_bf16`, fixture MLX |
+| `h3_gpu_embedding_bf16` | CUDA custom | weight BF16 `[V,W]`, ids U32 `[T]` -> BF16 `[T,W]` | `test_text_metal`, ids limite/invalidi |
+| `h3_gpu_text_qk_rope_bf16` | CUDA custom | Q `[S,QH,D]`, K `[S,KH,D]` -> head-major BF16 | confronto head-norm + rope separati |
+| `h3_gpu_head_rms_norm_bf16` | CUDA custom | tensor BF16 `[S,H,D]` in-place, weight `[D]` | `test_text_metal` |
+| `h3_gpu_rope_text_bf16` | CUDA custom | Q `[S,QH,D]`, K `[S,KH,D]`, cos/sin F32 `[S,D/2]` | `test_text_metal` |
+| `h3_gpu_add_bf16` | CUDA custom | BF16 `[N] + [N] -> [N]` | `test_bf16`, `test_text_metal` |
+| `h3_gpu_sub_bf16` | CUDA custom | BF16 `[N] - [N] -> [N]` | CPU e round boundary |
+| `h3_gpu_token_pool_bf16` | CUDA custom | BF16 input `[input_R,W]` -> pooled `[R,W]`; indices U32 | `test_bf16` synthetic exact |
+| `h3_gpu_token_pool_adaln_bf16` | CUDA custom | pool + AdaLN, residual/output `[R,W]` | `test_bf16`, confronto non fuso |
+| `h3_gpu_token_expand_delta_bf16` | CUDA custom | reduced `[reduced_R,W]` + maps U32 -> output `[R,W]` | `test_bf16` synthetic exact |
+| `h3_gpu_token_expand_adaln_bf16` | CUDA custom | expand + AdaLN, residual/output `[R,W]` | `test_bf16`, confronto non fuso |
+| `h3_gpu_euler_bf16` | CUDA custom | sample F32 slice `[N]`, last/previous BF16 `[N]` -> F32 | `test_bf16`, CPU formula |
+| `h3_gpu_silu_mul_bf16` | CUDA custom | gate/up BF16 `[N] -> [N]` | `test_text_metal`, CPU |
+
+## Attention
+
+| API | Backend CUDA | Dtype e shape | Verifica CUDA prevista |
+|---|---|---|---|
+| `h3_gpu_qkv_rope_f32` | CUDA custom | QKV F32 `[S,3,H,D]` -> Q/K/V `[H,S,D]`, norm/RoPE | `test_metal` fixture |
+| `h3_gpu_sdpa_f32` | CUDA custom + cuBLASLt | Q/K/V F32 `[H,S,D]` -> `[S,H,D]`, non causale | `test_metal`, CPU piccoli |
+| `h3_gpu_video_qkv_rope_f32` | CUDA custom | QKV F32 `[S,3,H,D]` -> `[H,S,D]`, video RoPE | video encoder fixture |
+| `h3_gpu_audio_qkv_split_f32` | CUDA custom | QKV F32 `[B,L,3,H,D]` + bias -> Q/K/V `[B,H,L,D]` | audio encoder fixture + CPU |
+| `h3_gpu_sdpa_causal_f32` | CUDA custom + cuBLASLt | Q/K/V F32 `[B,H,S,D]` -> `[B,S,H,D]`, causal | CPU mask test |
+| `h3_gpu_audio_attention_pool_f32` | CUDA custom | attended `[B,L,H,D]` -> `[B,output_dim]` | audio encoder fixture |
+| `h3_gpu_sdpa_bf16` | CUDA custom + cuBLASLt | Q/K/V BF16 `[H,S,D]` -> `[S,H,D]` | `test_bf16`, real DiT fixture |
+| `h3_gpu_sdpa_bf16_head_major_output` | CUDA custom + cuBLASLt | Q/K/V BF16 `[H,S,D]` -> `[H,S,D]` | confronto `sdpa_bf16` + transpose |
+| `h3_gpu_gqa_causal_bf16` | CUDA custom + cuBLASLt | Q `[QH,S,D]`, K/V `[KH,S,D]` -> `[S,QH,D]`, causal | `test_text_metal`, CPU mask/GQA |
+
+## Convoluzioni e VAE
+
+| API | Backend CUDA | Dtype e shape | Verifica CUDA prevista |
+|---|---|---|---|
+| `h3_gpu_conv1d_f32` | CUDA custom + cuBLASLt | X `[B,L,Cin]`, W `[Cout,Cin,K]` -> `[B,Lout,Cout]` | `test_audio_gpu`, abs `2e-5` |
+| `h3_gpu_conv1d_stride_f32` | CUDA custom + cuBLASLt | come Conv1d con stride, `Lout=floor((L+2P-D(K-1)-1)/S)+1` | CPU stride/dilation |
+| `h3_gpu_conv_transpose1d_f32` | CUDA custom + cuBLASLt | X `[B,L,Cin]`, W `[Cin,Cout,K]` -> `[(L-1)S+K-2P]` | `test_audio_gpu`, abs `2e-5` |
+| `h3_gpu_vae_encoder_pad_f32` | CUDA custom | X `[B,T,H,W,C]` -> padded channels-last tensor | semantic VAE encoder, border oracle |
+| `h3_gpu_conv3d_f32` | CUDA custom + cuBLASLt | X `[B,T,H,W,Cin]`, W `[Cout,Cin,Kt,Kh,Kw]` -> channels-last output | semantic/real video encoder fixtures |
+| `h3_gpu_vae_encoder_group_norm_silu_f32` | CUDA custom | X F32 `[B,T,H,W,C]`, weight/bias `[C]` -> stessa shape | semantic VAE encoder + CPU |
+
+## Soglie di parità
+
+- Copie, indici, metadata, layout puri e round-trip BF16/U32/I8: confronto
+ bit-esatto. Le fusioni devono coincidere bit-per-bit con la pipeline CUDA non
+ fusa quando condividono gli stessi confini di arrotondamento BF16.
+- Primitive F32 con oracle CPU: `max_abs <= 2e-5`, salvo weight norm
+ `2e-6` e add/clip `1e-7`, mantenendo le soglie già usate da
+ `tests/test_audio_gpu.c`.
+- Blocchi F32 contro fixture MLX: `max_rel < 5e-3`, come
+ `tests/test_metal.c`.
+- Primitive e blocchi BF16 contro fixture MLX: `max_rel < 1e-2`, come
+ `tests/test_bf16.c` e `tests/test_text_metal.c`. Per una singola primitiva si
+ registra anche `max_abs`; NaN/Inf o mismatch di shape sono sempre FAIL.
+- INT8: oltre alla soglia finale BF16 `max_rel < 2e-2`, la quantizzazione deve
+ rispettare `max_abs(dequant-input) <= scale/2 + 1e-6` per riga/canale.
+
+## Equivalenza della suite Metal
+
+Ogni eseguibile GPU esistente viene compilato una seconda volta contro il
+backend CUDA, senza cambiare fixture né assertions:
+
+| Suite esistente | Equivalente CUDA | Copertura principale |
+|---|---|---|
+| `tests/test_metal.c` | `h3_cuda_tests` | blocco DiT F32, statistiche |
+| `tests/test_bf16.c` | `h3_cuda_bf16_tests` | DiT BF16, fusioni, token reduction, Euler, INT8 fallback |
+| `tests/test_text_metal.c` | `h3_cuda_text_tests` | embedding, Qwen norm/RoPE/GQA/MLP |
+| `tests/test_audio_gpu.c` | `h3_cuda_audio_gpu_tests` | Conv1d, transpose, weight norm, Snake, elementwise |
+| `tests/test_real_dit_block.c` | stesso target con backend CUDA | primitive reali e confini BF16 |
+| `tests/test_real_dit.c`, `test_real_dit_schedule.c`, `test_semantic_dit.c` | stessi target con backend CUDA | integrazione DiT e scheduling |
+| test real/semantic audio e video già elencati nel `Makefile` | stessi target con backend CUDA | operatori encoder/VAE e fixture checkpoint |
+
+Le API di lifecycle/I/O non coperte direttamente dalle suite Metal ricevono un
+test CUDA dedicato. Il test genera un file temporaneo controllato per coprire
+load, reload, streaming, range, errori e contatori.
+
+## Rischi specifici GB10
+
+1. La memoria unificata CPU/GPU non rende automaticamente conveniente
+ `cudaMallocManaged`: page migration e fault su stream di pesi possono
+ serializzare I/O e compute. Il default resta device memory con staging
+ pinned; managed+prefetch richiede benchmark del Task 11.
+2. cuBLASLt su ARM64/Blackwell può scegliere workspace e algoritmi diversi tra
+ shape; ogni matmul deve avere fallback deterministico e workspace limitato.
+3. BF16 Tensor Core cambia l’ordine delle riduzioni rispetto a MPSGraph. Le
+ soglie sopra verificano il risultato, mentre i test bit-esatti sono limitati
+ a layout, copie e fusioni con uguali confini BF16.
+4. Gli operatori conv/attention senza cuDNN devono evitare materializzazioni
+ `im2col` o score `[S,S]` non limitate: sul modello reale possono consumare
+ decine di GiB nonostante i 121 GiB disponibili.
+5. Lo streaming SSD deve mantenere vivi staging buffer e CUDA event fino alla
+ copia completata; riuso anticipato produce corruzioni intermittenti che i
+ soli test piccoli non rilevano.
+6. Le opzioni Metal 4/NAX non hanno equivalente diretto. Su CUDA usano la
+ pipeline portabile; INT8 è dichiarato disponibile solo dopo un probe reale
+ cuBLASLt, mai in base al solo compute capability.
diff --git a/docs/GB10_PROFILE.md b/docs/GB10_PROFILE.md
new file mode 100644
index 00000000..8397b115
--- /dev/null
+++ b/docs/GB10_PROFILE.md
@@ -0,0 +1,67 @@
+# NVIDIA GB10 profile
+
+Measured on NVIDIA GB10 with CUDA 13.0 and driver 595.84. Each result is the
+median of three complete runs using the released `MiniMax-H3` checkpoint:
+
+```sh
+./h3 -d ./MiniMax-H3 \
+ -p "A bright red cube rotates on a white background." \
+ --width 256 --height 256 --frames 22 --steps 2 --layers 35 \
+ --token-reduction --seed 42 --profile -o OUTPUT.mp4
+```
+
+| Mode | DiT load | Denoise | Load + denoise | DiT peak |
+| --- | ---: | ---: | ---: | ---: |
+| Resident BF16 baseline | 96.563 s | 2.715 s | 99.278 s | 27.063 GB |
+| `--ssd-streaming` | 51.829 s | 84.798 s | 136.627 s | 1.630 GB |
+| `--use-int8-row-fc2` | 96.491 s | 2.714 s | 99.205 s | 27.063 GB |
+
+All three repetitions within each mode produced the same SHA-256, and all
+nine files share SHA-256
+`dbae1b441cface55bfe86aaabe78d44c0c05746909f7874908dd2cb298d8c5c8`.
+Baseline versus SSD also measures SSIM 1.0.
+
+`--ssd-streaming` reduces the DiT peak by 94.0%, at a 37.6% increase in
+load-plus-denoise time for this two-step workload. Its prefetch read 50.962
+GiB per run at a median 0.592 GiB/s, leaving a median 79.488 s of unhidden I/O.
+It is therefore useful as an exact low-memory mode, not as the GB10 speed
+default.
+
+`--use-int8-row-fc2` is a Metal/M5 specialization and is intentionally a no-op
+in the CUDA backend. The measured 0.001 s denoise difference is noise. No
+FP8 path is exposed by the current backend, and adding one without an
+independent numeric oracle would violate the correctness gate. CUDA device
+allocation already uses the GB10 unified physical memory; managed-memory
+prefetch would add migration policy without reducing the measured resident
+footprint.
+
+No new performance switch is enabled by default: none demonstrated a speedup
+while preserving the verified output. Use resident BF16 for speed and
+`--ssd-streaming` only when the 27 GB resident DiT peak is unacceptable.
+
+## Video VAE tiled F32 attention (T29-T32)
+
+Nsight profiling of the max-quality decode (1024x576, 107 frames) attributed
+90.5% of the video VAE phase to the scalar F32 attention kernel (1007.06 s of
+1112.96 s median; 1728 calls x 582.8 ms at sequence 2805, head_dim 64). A
+tiled F32 kernel (`h3_attention_tiled_f32_kernel<64>`, 8 query rows per block,
+online softmax, identical F32 recurrence) now serves non-causal batch-1 F32
+attention with head_dim 64; the scalar kernel remains the fallback and is
+selectable again with `H3_DISABLE_TILED_ATTENTION=1`.
+
+Median of three `h3_vae_bench_quality` runs (latent 32x36x64x24, 107 frames):
+
+| Metric | Scalar F32 | Tiled F32 |
+| --- | ---: | ---: |
+| Video VAE decode | 1112.960 s | 247.737 s (4.49x) |
+| Max-quality render wall | 33:36.80 | 18:56.36 (1.78x) |
+| DiT denoise (unchanged) | 830.999 s | 814.958 s |
+| Video VAE peak | 10.26 GB | 10.26 GB |
+
+Quality: CPU oracle parity within max_abs 2e-5 on shapes 13x2x64 and
+2805x4x64, Compute Sanitizer clean, `VERIFY: PASS all`. Matched short render
+(512x288/22 frames, same binary, only the VAE path toggled) measures SSIM
+0.999303 / PSNR 55.45 dB. The max-quality MP4 measures SSIM 0.984985 /
+PSNR 43.94 dB against the pre-optimization T28 artifact; two identical
+rerenders are bit-identical (SSIM 1.0), so the delta is the VAE reduction
+order amplified by the high-frequency content and H.264, not run noise.
diff --git a/docs/POSTPROCESSING.md b/docs/POSTPROCESSING.md
new file mode 100644
index 00000000..b7466441
--- /dev/null
+++ b/docs/POSTPROCESSING.md
@@ -0,0 +1,110 @@
+# Post-processing plugins
+
+After a video is generated, h3.c Studio can hand it to an external program
+before publishing it. That is the whole extension point: a contract, not an
+integration.
+
+**This repository contains no models and no weights**, and nothing is ever
+fetched at build time or at run time. Every plugin is unavailable until a
+runtime is installed and an environment variable points at it.
+
+One URL does appear here, in `install.sh`: `--with-faceswap` fetches the
+**FaceFusion runtime** — the program, not the models — and only when you ask
+for it on the command line or answer yes to the question. FaceFusion then
+downloads its own models, from its own sources, under its own licence. The
+default installs nothing.
+
+## The contract
+
+A plugin is an executable. The backend calls it with an argument list — never
+through a shell — and waits:
+
+```
+$H3__CMD --input /path/to/in.mp4 --output /path/to/out.mp4
+```
+
+| Outcome | What the backend does |
+| --- | --- |
+| exit code `0` and the output file exists | the job's video is replaced by the output |
+| exit code `0` but no output file | the job fails; the generated video is kept |
+| any non-zero exit code | the job fails with the last line of stderr; the generated video is kept |
+| the process does not finish within an hour | the job fails with a timeout |
+
+The generated video is never deleted: a failed post-processing step costs you
+the stage, not the render.
+
+Anything the plugin writes to stderr ends up in the job log, so make errors
+readable in one line.
+
+## Registered plugins
+
+| Name | Environment variable | Status in this repository |
+| --- | --- | --- |
+| `faceswap` | `H3_FACESWAP_CMD` | unavailable until you install a runtime |
+
+`GET /api/capabilities` reports the same thing at run time, with the reason,
+and the UI shows it disabled. Requesting an unavailable plugin fails the job
+instead of silently ignoring it.
+
+## Enabling one, with the installer
+
+`./install.sh --with-faceswap` fetches FaceFusion into `vendor/facefusion`,
+runs its own installer, and writes into `.env`:
+
+- `H3_FACESWAP_CMD`, pointing at `scripts/faceswap-facefusion.sh`, the adapter
+ that turns the `--input`/`--output` contract into FaceFusion's command line;
+- `H3_FACEFUSION_DIR`, where the runtime went.
+
+One value stays yours: `H3_FACESWAP_SOURCE`, the image of the face to put in.
+The contract carries only the video, so the adapter reads the face from the
+environment, and refuses to run until it is set.
+
+This wires the **local** path, where FaceFusion runs in its own Python
+environment on the host. It does not wire the container: the API image carries
+neither FaceFusion nor its dependencies, and
+[`docker-compose.faceswap.yml`](../docker-compose.faceswap.yml) expects a
+runtime you have already made reachable inside the container.
+
+FaceFusion's command line is its own and changes between releases; the adapter
+is written against the 3.x `headless-run` and is the one file to adjust if
+yours differs.
+
+## Enabling one by hand
+
+Installing a runtime is one configuration step, not a code change:
+
+```sh
+# 1. Install the runtime yourself, in its own environment.
+# 2. Point the variable at the executable and restart the backend.
+export H3_FACESWAP_CMD=/opt/faceswap/run
+```
+
+With Docker, use the override file, which mounts your runtime read-only:
+
+```sh
+export H3_FACESWAP_DIR=/opt/faceswap
+export H3_FACESWAP_CMD=/opt/faceswap/run
+docker compose -f docker-compose.yml -f docker-compose.faceswap.yml up
+```
+
+## Adding another plugin
+
+Add an entry to `registry()` in `webui/backend/app/postprocess.py` with a name,
+a label, a description and an environment variable. Nothing else changes: the
+API lists it, and the UI renders it from the API — there is no plugin name
+hard-coded in the frontend.
+
+The stage is not specific to faces. Upscaling, frame interpolation or
+watermarking fit the same `--input`/`--output` contract.
+
+## Licences and consent
+
+The face-swapping models that were evaluated for this project are licensed for
+**non-commercial or research use only**. That is why they are not shipped here
+and why no URL is given: checking the licence of what you install is your
+responsibility, not this repository's.
+
+**Do not use face replacement on images or videos of real people without their
+informed consent.** Depicting someone saying or doing something they did not is
+harmful regardless of how good the result looks, and in many jurisdictions it
+is illegal. If you cannot obtain consent, do not run the stage.
diff --git a/docs/WEBUI.md b/docs/WEBUI.md
new file mode 100644
index 00000000..210d7d6e
--- /dev/null
+++ b/docs/WEBUI.md
@@ -0,0 +1,175 @@
+# h3c studio — the web UI
+
+A browser front end for `h3`. Everything the CLI accepts is here: duration,
+canvas, sampler, first/last frame anchors, ordered Ref2VA references, the
+memory and parity switches, and a live preview of the denoising.
+
+The design direction is documented in the mockups under [`docs/mockup/`](mockup/); `v4.html` is the current one, and `logo.html` shows the mark.
+
+## What you need
+
+- The same prerequisites as `h3` itself: an NVIDIA driver matching CUDA 13, the
+ CUDA toolkit, ICU, and FFmpeg/FFprobe 6.1 or newer on `PATH`.
+- The MiniMax-H3 checkpoint on disk (about 465 GB). It is never copied into a
+ container image; it is mounted read-only.
+- Python 3.12 and Node 22 for the local (non-Docker) path.
+- For Docker: the NVIDIA Container Toolkit, so the container can see the GPU.
+
+## Docker
+
+`./install.sh` writes the `.env` for you and can fetch the checkpoint; see
+[Installing with the script](../README.md#installing-with-the-script). By hand:
+
+```sh
+cp .env.example .env
+# Set H3_MODEL_DIR to your checkpoint directory, then:
+docker compose up --build
+```
+
+To check that the container sees the GPU and the checkpoint before waiting on
+a first render — it prints the device and a tensor inventory, and nothing else:
+
+```sh
+docker compose run --rm --no-deps api /app/h3 --info -d /models
+```
+
+The UI is then on and the API on
+. Both bind to the loopback address on purpose — see
+*Security* below.
+
+`NVCC_ARCH` in `.env` selects the CUDA architecture `h3` is compiled for;
+`sm_121` is the NVIDIA GB10. The image is built for the architecture of the
+machine that builds it.
+
+## Running it without Docker
+
+```sh
+# 1. Build h3 as usual.
+make -j"$(nproc)"
+
+# 2. Backend.
+python3 -m venv webui/backend/.venv
+webui/backend/.venv/bin/pip install \
+ "fastapi>=0.115" "uvicorn[standard]>=0.34" "pydantic-settings>=2.6" \
+ python-multipart
+H3_MODEL_DIR=./MiniMax-H3 \
+ webui/backend/.venv/bin/uvicorn app.main:app \
+ --app-dir webui/backend --host 127.0.0.1 --port 8000
+
+# 3. Frontend, in another terminal.
+cd webui/frontend
+npm install
+node scripts/generate-options.mjs
+npm run dev
+```
+
+Open . The dev server proxies `/api` to the backend.
+
+## How it works
+
+```
+browser ── /api ──▶ FastAPI ──▶ serial queue ──▶ ./h3 (one process per job)
+ ▲ │ │
+ └── SSE progress ────┘ └── mp4, log, previews
+```
+
+- **One job at a time.** A single GPU with a 27 GB DiT peak: the queue is
+ serial by design, and a queued job can be cancelled before it starts.
+- **One `./h3` process per job.** Every CLI flag is therefore reachable, and
+ cancelling is a signal to the process group. The cost is about 96 seconds of
+ DiT load per job on a GB10.
+- **Validation mirrors the engine.** The messages the browser shows before you
+ submit are copied verbatim from `h3.c`, so a job that the UI accepts is a job
+ the engine accepts.
+- **Progress is weighted.** Phases are not equal: on the calibration run the
+ transformer load took 40.9 s and the denoising 5.1 s. Weights live in
+ `webui/shared/progress_weights.json`; regenerate them for your hardware with
+ `webui/backend/tools/calibrate_progress.py`.
+- **The option inventory has one source.** `webui/shared/options.schema.json`
+ feeds both the backend validator and the generated TypeScript module, and a
+ test fails if it drifts from `main.c`.
+
+## The interface
+
+Composition reads as a sentence: the prompt, then one line of choices —
+length, shape, quality, variation — where each word opens where it stands
+and carries its own time estimate. Photos to start from, end on, or keep as
+references hang off the prompt as chips; files dropped anywhere on the page
+land in the library.
+
+Everything else lives in one panel with three tabs — Picture, Reference
+material and Expert — and Expert exposes every CLI option, including the ten
+`--use-slower-*` parity flags, with the exact command-line name. Two CLI
+options are deliberately absent: `--show` and `--zoom` are terminal graphics
+protocols with no meaning in a browser — the live preview uses
+`--preview-dir` instead.
+
+Finished videos form a gallery that plays on hover. While a video is being
+made, the page shows the frame developing pass by pass, with a weighted
+progress bar calibrated on this machine, and generation keeps running while
+you compose the next one.
+
+The administrator has a People page of its own: accounts, single-use
+invites, password resets and deletions. Uploaded images, clips and
+soundtracks stay in a library and can be reused in later jobs, as anchors or
+as ordered references.
+
+## Security
+
+**Every call needs an account.** The whole API sits behind a session cookie.
+The administrator account is defined on the server — `H3_ADMIN_USERNAME` and
+`H3_ADMIN_PASSWORD` in `.env` — and is created once, on the first start of an
+empty database; afterwards those values are ignored and the password is
+managed from the People page. Every other account is made with a single-use
+invite from that tab. Passwords are hashed with argon2id, sessions live in
+the database (a logout or a password reset ends them at once), and five wrong
+passwords in fifteen minutes pause that username. Videos and uploads belong
+to the person who made them; an id that is not yours answers 404, the same
+as one that does not exist.
+
+What this does **not** do: there is no TLS. The service serves plain HTTP, so
+passwords and cookies travel in the clear on whatever network carries them.
+Outside a network you trust, put a TLS-terminating reverse proxy (Caddy,
+nginx, Traefik) in front of it; binding to a private address is not a
+substitute.
+
+A fresh installation with no `H3_ADMIN_PASSWORD` has no administrator and
+no way in: the backend says so in its log at startup. Set the two variables
+before the first start.
+
+Everything binds to `127.0.0.1` by default.
+
+**A private overlay network.** With [Tailscale](https://tailscale.com), publish
+the UI on the tailnet address instead of the loopback: only your own devices
+can then connect, and the tailnet does the authenticating.
+
+```sh
+tailscale ip -4 # e.g. 100.117.213.82
+# in .env:
+H3_BIND=100.117.213.82
+docker compose up -d
+```
+
+Bear in mind that the container can only bind that address while Tailscale is
+up; `restart: unless-stopped` retries if it is not.
+
+**An SSH tunnel.** No configuration at all, from the other machine:
+
+```sh
+ssh -N -L 8080:127.0.0.1:8080 you@the-machine
+```
+
+Then open there.
+
+**Publishing on the LAN** (`H3_BIND=0.0.0.0`) gives everyone on the network
+the login screen — including attempts at the administrator password. If you
+do it anyway, two things to know: only port 8080 needs publishing, because
+nginx proxies `/api`; and a host firewall will not save you — ports published
+by Docker are DNAT-ed in the `DOCKER-USER` chain, which `ufw` does not filter,
+so a `ufw deny` rule has no effect on them.
+
+## Post-processing
+
+An optional stage can hand the finished video to an external program. No such
+program is included, and no model is downloaded: see
+[`docs/POSTPROCESSING.md`](POSTPROCESSING.md).
diff --git a/docs/assets/banner.svg b/docs/assets/banner.svg
new file mode 100644
index 00000000..983d6981
--- /dev/null
+++ b/docs/assets/banner.svg
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+ h3c studio
+ PROMPT-TO-VIDEO ON YOUR OWN GPU · CUDA AND METAL
+
diff --git a/docs/assets/h3c-studio.svg b/docs/assets/h3c-studio.svg
new file mode 100644
index 00000000..1d6b472b
--- /dev/null
+++ b/docs/assets/h3c-studio.svg
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
diff --git a/docs/assets/screenshots/create.png b/docs/assets/screenshots/create.png
new file mode 100644
index 00000000..9d1d974e
Binary files /dev/null and b/docs/assets/screenshots/create.png differ
diff --git a/docs/assets/screenshots/login.png b/docs/assets/screenshots/login.png
new file mode 100644
index 00000000..e8583266
Binary files /dev/null and b/docs/assets/screenshots/login.png differ
diff --git a/docs/assets/screenshots/people.png b/docs/assets/screenshots/people.png
new file mode 100644
index 00000000..60bf658d
Binary files /dev/null and b/docs/assets/screenshots/people.png differ
diff --git a/docs/assets/screenshots/studio.png b/docs/assets/screenshots/studio.png
new file mode 100644
index 00000000..6b8464cd
Binary files /dev/null and b/docs/assets/screenshots/studio.png differ
diff --git a/docs/assets/screenshots/takes.png b/docs/assets/screenshots/takes.png
new file mode 100644
index 00000000..5663f094
Binary files /dev/null and b/docs/assets/screenshots/takes.png differ
diff --git a/docs/assets/social-preview.png b/docs/assets/social-preview.png
new file mode 100644
index 00000000..d8955e7e
Binary files /dev/null and b/docs/assets/social-preview.png differ
diff --git a/docs/mockup/UX_RESEARCH.md b/docs/mockup/UX_RESEARCH.md
new file mode 100644
index 00000000..67b6429b
--- /dev/null
+++ b/docs/mockup/UX_RESEARCH.md
@@ -0,0 +1,50 @@
+# UX research — AI video generation platforms (R29 / T116)
+
+**Fonte e data.** 2026-08-27. Analisi prodotta dall'agente senza accesso web
+in tempo reale (`web_search`/`web_fetch` richiedono `ollama signin`, non
+eseguito): si basa sulla conoscenza delle piattaforme al training. Ogni voce
+è etichettata `[osservato]` (pattern stabile e ripetuto in più versioni della
+piattaforma) o `[incerto]` (dettaglio che potrebbe essere cambiato). Da
+riverificare con fonti live prima di trattarla come verità definitiva.
+
+Le sette piattaforme analizzate: **Runway** (Gen-3/Gen-4), **Sora**
+(inclusi storyboard/remix), **Luma Dream Machine**, **Kling**, **Pika**,
+**Google Flow** (Veo), **Hailuo** (MiniMax — stesso produttore del modello
+H3, quindi il confronto più diretto).
+
+---
+
+## Pattern osservati → proposta per h3.c Studio
+
+Ogni riga è 1:1: pattern osservato, dove, e la decisione concreta per il
+mockup v4 (T117). Rubrica M6, criterio 1: nessun miglioramento nel mockup
+senza una riga qui sotto.
+
+| # | Pattern | Piattaforme | h3.c Studio oggi | Proposta v4 |
+|---|---|---|---|---|
+| P1 | Il prompt è una command bar con **chip di aggancio visivo**: le immagini di riferimento si allegano come tessere direttamente sotto/al bordo del campo di testo, non in una sezione separata | Runway `[osservato]`, Sora `[osservato]`, Luma `[osservato]`, Kling `[incerto]`, Hailuo `[osservato]` | Ancore e riferimenti vivono in schede/pannelli (`PhotoSlot`, `References`) lontani dal prompt | **Adotta**: riga di chip sotto il prompt per first/last frame e fino a N riferimenti; ogni chip apre il picker della libreria sul posto. La frase di R28 resta; i chip sono agganci, non campi. |
+| P2 | Scelte rapide come **chip segmentati inline** (aspect, durata, modello) accanto al prompt | Sora `[osservato]`, Luma `[osservato]`, Hailuo `[osservato]` | Le scelte sono parole cliccabili in una riga di testo (R28) | **Non adottare la sostituzione**: la riga-frase è la firma approvata in R28 e misurata (305 px). I chip aprirebbero una griglia proprio dove R28 l'ha tolta. Tenere la frase. |
+| P3 | **Galleria a griglia con hover-play**: i video partono in muto al passaggio del mouse; azioni (riusa, scarica, cancella) compaiono in overlay | Runway `[osservato]`, Sora `[osservato]`, Luma `[osservato]`, Pika `[osservato]` | Card statiche con poster; il video parte solo aprendo la presa | **Adotta**: hover-play muto con `preload="metadata"`; le tre azioni già esistenti in overlay sulla card. |
+| P4 | Stato di generazione con **preview quasi full-bleed** e stato minimo sovrapposto | Runway `[osservato]`, Sora `[incerto]` | Preview in un riquadro con guida perforata | **Adotta con misura**: nel render la preview diventa l'elemento dominante (già lo è concettualmente); guida perforata e tempo restano, più piccoli. Non full-bleed totale: h3.c è uno strumento che scrive, non un feed. |
+| P5 | **"Enhance prompt"**: un pulsante riscrive/espande il prompt con un LLM | Luma `[osservato]`, Hailuo `[osservato]`, Kling `[osservato]` | Assente | **Non in v4**: richiede un LLM nel backend, fuori dall'architettura D20.2 (un job = `./h3` one-shot). Candidato futuro. |
+| P6 | **Storyboard/scene multiple** con transizioni fra shot | Flow `[osservato]`, Sora `[incerto]` | Un job = un video | **Non in v4**: h3.c genera una ripresa; multi-shot è prodotto, non UI. |
+| P7 | Tema **scuro** dominante, vetro/gradienti | Runway `[osservato]`, Kling `[osservato]`, Hailuo `[osservato]` | Chiaro come progetto (R23), scuro già pronto | **Tenere**: la scelta chiara è deliberata (D23.7) e il dark esiste. Nessun cambio. |
+| P8 | **Coda/history in sidebar** persistente visibile durante la composizione | Runway `[incerto]`, Kling `[incerto]` | Striscia di monitoraggio compatta (T96) | **Tenere la striscia**: la pagina a una colonna di R28 non ha spazio per una sidebar senza tradirsi; la striscia dà già composizione-durante-render (R24). |
+| P9 | Contatore di **costo/crediti** accanto al pulsante genera | Runway `[osservato]`, Sora `[osservato]`, Luma `[osservato]`, Pika `[osservato]` | Stima in minuti per ogni scelta (T92) | **Già nostro, più forte**: il tempo è l'analogo onesto dei crediti su una GPU locale. Tenere; nel mockup la stima totale sta vicino al pulsante, come i crediti dei concorrenti. |
+| P10 | **Drag & drop** di file ovunque nella pagina | Runway `[incerto]`, Luma `[incerto]` | Upload via picker file | **Adotta**: drop sulla pagina = upload nella libreria; costo zero di architettura (l'endpoint T70 esiste). |
+
+**Sintesi delle adozioni per il mockup v4**: P1 (chip di aggancio sotto il
+prompt), P3 (hover-play in galleria), P4 (preview dominante nel render),
+P9 (stima accanto al genera), P10 (drag & drop). Scartati con motivo: P2,
+P5, P6, P7, P8.
+
+## Vincoli che il mockup v4 non deve rompere
+
+- M5: nessun flag CLI e nessun gergo del motore in Create; 100% dei flag in
+ Expert; primo video senza aprire pannelli; contrasto AA; stime su ogni
+ scelta che cambia il tempo.
+- R20/R28: le 42 opzioni restano tutte raggiungibili; la composizione è una
+ colonna sola, la frase è la firma.
+- Backend invariato: tutto ciò che v4 mostra esiste già nelle API (asset,
+ stima, preview, coda, delete). P5 è l'unico pattern che richiederebbe
+ backend nuovo, ed è escluso da D29.2/D20.2.
diff --git a/docs/mockup/WIREFRAME.md b/docs/mockup/WIREFRAME.md
new file mode 100644
index 00000000..79ebc617
--- /dev/null
+++ b/docs/mockup/WIREFRAME.md
@@ -0,0 +1,99 @@
+# Wireframe di riferimento — web UI h3.c
+
+Materiale approvato durante la progettazione della web UI.
+
+Schermata 1 — Simple (default):
+
+```
++------------------------------------------------------------------------------+
+| h3.c Studio GPU: NVIDIA GB10 - 121 GiB - CUDA 13.0 * |
++------------------------------------------------------------------------------+
+| [ Simple ] [ Advanced ] Queue: 1 running |
++------------------------------------------+-----------------------------------+
+| PROMPT | QUEUE |
+| +--------------------------------------+ | +-------------------------------+ |
+| | A red fox walks through fresh snow. | | | > #12 "fox in snow" running | |
+| +--------------------------------------+ | | denoise 7/20 [####...] | |
+| | | 03:41 elapsed [x] | |
+| DURATION o-------*---------- 4.46 s | +-------------------------------+ |
+| 107 frames (aligned 5+17n) | | #11 "surfer" queued [x] | |
+| | +-------------------------------+ |
+| FORMAT [16:9] [9:16] [1:1] [4:3] [3:4] | | #10 "cube" done 00:41 > v | |
+| SIZE ( ) 256 (*) 512 ( ) 768 | +-------------------------------+ |
+| 512 x 512 - 0.26 / 1.03 MP | |
+| | PREVIEW |
+| QUALITY o-------*----------o | +-------------------------------+ |
+| Draft Balanced Reference | | | |
+| steps 20 - layers 45 - reuse 2 | | [ video player ] | |
+| | | | |
+| FIRST FRAME LAST FRAME | +-------------------------------+ |
+| +-------------+ +-------------+ | #10 - 512x512 - 22f - seed 42 |
+| | drop image | | drop image | | [ Reuse settings ] [ Download ] |
+| +-------------+ +-------------+ | |
+| | |
+| SEED [ 42 ] [ random ] | |
+| | |
+| [ Generate video ] | |
++------------------------------------------+-----------------------------------+
+```
+
+Schermata 2 — Advanced (stessa colonna destra, pannello sinistro a sezioni):
+
+```
++------------------------------------------+
+| [ Simple ] [ Advanced ] |
++------------------------------------------+
+| v OUTPUT |
+| width [ 512 ] height [ 512 ] (x32) |
+| internal canvas [x] custom |
+| render-width [384] render-height[384]|
+| output file [ outputs/fox.mp4 ] |
+| [ ] no mp4 (-o '') |
+| [ ] write frames frames-dir [ ... ] |
++------------------------------------------+
+| v DURATION |
+| (*) seconds [ 4.5 ] ( ) frames [107] |
+| -> aligned 107 frames = 4.458 s @24fps |
++------------------------------------------+
+| v SAMPLER |
+| steps [ 20 ] (2..1000) |
+| layers [ 45 ] (35..50) |
+| (*) reuse [ 2 ] (1..3) |
+| ( ) core-reuse [ 4 ] (1..6) |
+| ^ mutually exclusive |
+| [ ] token-reduction |
++------------------------------------------+
+| v MEMORY / BACKEND |
+| [ ] ssd-streaming 27.1 GB -> 1.6 GB, |
+| slower |
+| [ ] use-int8-row-fc2 (no-op on CUDA) |
+| [ ] use-reference-rope |
++------------------------------------------+
+| > PARITY / DEBUG FLAGS (10) |
+| collapsed: --use-slower-* |
+| [ ] profile |
++------------------------------------------+
+| > REFERENCES (3) |
++------------------------------------------+
+| [ Generate video ] |
++------------------------------------------+
+```
+
+Schermata 3 — References (sezione espansa, lista ordinata, max 12):
+
+```
++--------------------------------------------------------------+
+| REFERENCES (Ref2VA) order is significant |
+| ref-image-size: (*) match ( ) max |
++--------------------------------------------------------------+
+| 1 [img] fox.png 720x720 [^][v][x] |
+| 2 [vid] clip.mp4 audio: keep (--ref-video) [^][v][x] |
+| 3 [vid] silent.mp4 audio: drop (--ref-silent) [^][v][x] |
+| 4 [vid+a] scene.mp4 + music.wav [^][v][x] |
+| 5 [aud] music.wav 6.2 s [^][v][x] |
++--------------------------------------------------------------+
+| [ + image ] [ + video ] [ + video+audio ] [ + audio ] |
+| rules: audio 2-15 s, max 3 audio, total <= 15 s, audio only |
+| alongside an image or a video reference |
++--------------------------------------------------------------+
+```
diff --git a/docs/mockup/index.html b/docs/mockup/index.html
new file mode 100644
index 00000000..f58c0aea
--- /dev/null
+++ b/docs/mockup/index.html
@@ -0,0 +1,453 @@
+
+
+
+
+
+h3.c Studio — UI mockup
+
+
+
+
+ h3.c Studio
+ GPU NVIDIA GB10 · 121.7 GiB · CUDA 13.0
+ checkpoint ready · FL2VA + Ref2VA
+
+ queue 1 running · 1 queued
+
+
+
+
+
+ Simple
+ Advanced
+ References5/12
+
+
+
+
+
+ Prompt --prompt
+
+
+
+
+
Duration --seconds / --frames
+
+
4.5 s requested → rounded up to 124 frames = 5.167 s at 24 fps
+ (legal shapes are 5 + 17n, 22…362 frames). The two flags are mutually exclusive.
+
+
+
+
Format & size --width --height
+
+ 16:9 1:1
+ 9:16 4:3 3:4
+
+
+ 256² 512²
+ 768² 1344×768 768×1344
+
+
512 × 512 = 0.26 MP of the 1.03 MP limit (768 × 1344).
+ Both sides must be multiples of 32 and at least 32.
+
+
+
+
Quality preset
+
+ Draft Balanced
+ Reference
+
+
Balanced = --steps 20 --layers 45 --reuse 2 --token-reduction.
+ Estimated 6 min on this GPU. Change any of it in Advanced .
+
+
+
+
Live preview --preview-dir
+
+ Decode one frame after every denoising step — adds a preview VAE load
+ phase and one decode per step.
+
+
+
+
+
First frame --first-frame
+
drop an imagechoose from library
+
+
+
Last frame --last-frame
+
drop an imagechoose from library
+
+
+
Frame anchors select the FL2VA path and
+ cannot be combined with ordered references. Clear the 5 references to enable them.
+
+
+
+
Generate video
+
Command this will run
+ ./h3 -d $H3_MODEL_DIR -p "A red fox walks through fresh snow…" --width 512 --height 512 --frames 124 --steps 20 --layers 45 --reuse 2 --token-reduction --seed 42 --preview-dir data/jobs/13/preview -o data/jobs/13/out.mp4
+
+
+
+
+
Output canvas
+
+
Multiples of 32, at least 32, product ≤ 768 × 1344 = 1 032 192 px.
+
+ Lower internal canvas --render-width --render-height
+
+
Set both or neither, same aspect ratio as the output, multiples of 32,
+ never larger than the output. Model and VAE run at this size, then upscale.
+
+
Output file --output
+
+
Assigned by the server. Empty disables MP4 encoding (-o '').
+
+
+
+
+
+
Duration
+
+
Mutually exclusive. 4.5 s → 124 frames = 5.167 s. Requests round up to
+ 5 + 17n; below 22 frames generation is refused (one 22-frame decoder chunk minimum),
+ above 362 frames it is out of range.
+
+
+
Sampler
+
+
+
Mutually exclusive: core reuse and denoiser reuse cannot both exceed 1.
+
+
+
+
Memory and backend
+
+
Parity and debug flags 10
+
Force close-reference implementations. Slower by
+ design; useful only to reproduce reference numerics.
+
+
+
+
Post-processing
+
+
Not exposed by this UI 5
+
+ --model-dir and --info — server configuration, from H3_MODEL_DIR.
+ --show and --zoom — Kitty/Ghostty terminal graphics; the browser uses --preview-dir instead.
+ --help — CLI only.
+
+
+
+
Generate video
+
+
+
+
+
+
Image sizing --ref-image-size
+
match max
+
+
+
Ordered references --ref-*
+
Command-line order is preserved and matters.
+ Ordered references select the Ref2VA checkpoint.
+
+
image fox.png
+ 720×720 · --ref-image ↑ ↓ ✕
+
video clip.mp4
+ keeps audio · --ref-video ↑ ↓ ✕
+
video silent.mp4
+ drops audio · --ref-silent-video ↑ ↓ ✕
+
vid+aud scene.mp4 + music.wav
+ --ref-video-audio ↑ ↓ ✕
+
audio music.wav
+ 6.2 s · --ref-audio ↑ ↓ ✕
+
+
+ total 5 /12 images 1 /9
+ videos 3 /3 audio inputs 3 /3
+ audio duration 10.4 /15 s
+
+
+ + image + video
+ + silent video + video + audio
+ + audio choose from library
+
+
+ At most 12 references: 9 images, 3 videos, 3 audio inputs.
+ Audio needs at least one image or video reference alongside it.
+ Standalone audio must last at least 2 s at 32 kHz; total reference audio ≤ 15 s.
+ A video soundtrack is truncated to the output duration and needs ≥ 2 s, so request at least 56 output frames.
+ References cannot be combined with --first-frame / --last-frame .
+
+
Blocked: videos 3/3 and audio inputs 3/3 are full — remove one before adding another.
+
+
Generate video
+
+
+
+
+
+
+ Queue
+
+
running
+ #13 fox in snow cancel
+
live preview denoise step 7 of 20
+
+
41 % · denoise 7/20 · elapsed 03:41 · remaining ~05:20
+
+
+
queued
+ #14 surfer inside a wave remove
+
512×512 · 124f · steps 20 · position 1 of 1
+
+
+
failed
+ #12 portrait 1400×800 dismiss
+
h3: canvas exceeds the released 768*1344 pixel limit
+
Fix and resubmit Show log
+
+
+
done
+ #11 red cube on white delete
+
512×512 · 22f · 0.917 s · seed 42 · 00:41
+
Play Download Reuse settings
+
+ One job runs at a time: a single GPU, 27 GB DiT peak.
+
+
+
+ Library
+
+
+
Uploaded assets stay selectable for later jobs as
+ first/last frame or as ordered references.
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/mockup/logo.html b/docs/mockup/logo.html
new file mode 100644
index 00000000..e42a0b02
--- /dev/null
+++ b/docs/mockup/logo.html
@@ -0,0 +1,210 @@
+
+
+
+
+
+h3c studio — logo concepts (T125)
+
+
+
+
+
+
+
+Logo — three concepts dark theme
+Same palette, same voices as the app. Every mark is one SVG; the
+sizes shown are 16, 32 and 128 px, plus the lockup as it would sit in the header.
+
+
+A — The frame
+
+
The frame
+
Il fotogramma che si sviluppa è già la firma della pagina: il
+ logo lo disegna. Un fotogramma aperto, la perforazione sul bordo sinistro,
+ l'angolo in basso a destra che si sta ancora "sviluppando" nell'accento.
+
+
+
+
+
+
+
+
+
+
+
+
+
h3cstudio
+
lockup
+
+
+
+
+B — The perforation three
+
+
The perforation three
+
Il 3 del nome fatto coi suoi segni più riconoscibili: tre
+ perforazioni in colonna, quella centrale accesa. È il concetto più compatto,
+ il più adatto a favicon e mobile.
+
+
+
+
+
+
+ 16
+
+
+
+
+
+ 32
+
+
+
+
+
+ 128
+
+
+
+
+
+
+
+ h3cstudio
+
lockup
+
+
+
+
+C — The leader
+
+
The leader
+
Il countdown che apre le bobine: cerchio, croce, e il quarto
+ che si sta ancora sviluppando nell'accento. Il più cinematografico dei tre;
+ a 16 px resta un cerchio con una croce, che è comunque un segno solido.
+
+
+
+
+In the header (A shown; B and C swap in identically)
+
+
+
+
+
+
+
diff --git a/docs/mockup/v2.html b/docs/mockup/v2.html
new file mode 100644
index 00000000..e87b1d01
--- /dev/null
+++ b/docs/mockup/v2.html
@@ -0,0 +1,625 @@
+
+
+
+
+
+h3.c studio
+
+
+
+
+
+
+
+
+ h3.c studio
+ light / dark
+ show a render
+ NVIDIA GB10 · ready
+
+
+
+
+
+ What should the video show?
+
+
+ Try:
+ a fox in the snow
+ a surfer inside a wave
+ a red cube turning on white
+
+
+
+
How long --seconds
+
+ 00:04.5
+ seconds · 24 fps
+
+
+
Videos come in fixed lengths. The nearest to 4.5 s is
+ 4.5 s — 107 frames. ≈ 6 min to make
+
+
+
+
Shape --width --height
+
+
+ Widescreen
+ 864×480 ≈ 9 min
+
+
+ Square
+ 512×512 ≈ 6 min
+
+
+ Vertical
+ 480×864 ≈ 9 min
+
+
+
+ More sizes up to 768p
+ up to ≈ 40 min
+
+
+
Bigger pictures take longer — the times above are for the
+ quality picked below. The largest this model was released for is
+ 1344 × 768 .
+
+
+
+
Quality --steps --layers --reuse
+
+
+ Quick look
+ ≈ 2 min
+ Rough, for checking the idea.
+
+
+ Balanced
+ ≈ 6 min
+ Good detail, sensible wait.
+
+
+ Best quality
+ ≈ 18 min
+ Every pass, nothing skipped.
+
+
+
+
+
+
Start and end --first-frame --last-frame
+
+ +
+ Start from a photothe first frame
+
+ +
+ End on a photothe last frame
+
+
+
Optional. Add a photo and the video begins — or ends — there.
+
+
+
+
Variation --seed
+
+ 42
+ Try another
+ Same settings and same variation give
+ the same video, every time.
+
+
+
+
+
That size is larger than the model can make.
+
The largest is 1344 × 768. Pick a smaller shape, or use More sizes
+ to set one by hand.
+
+ what h3 reported
+ h3: canvas exceeds the released 768*1344 pixel limit
+
+
+
+
+ Make the video
+ about 6 min on this machine · nothing else is queued
+
+
+
+ Fine-tune quality, length and size, one control at a time
+
+
+
+
Detail passes --steps
+
How many times the picture is refined. More passes, more detail.
+
+
each pass adds ≈ 12 s
+
+
+
Model depth --layers
+
How much of the model runs. Less is faster and slightly looser.
+
+
45 instead of 50 saves ≈ 40 s
+
+
+
+
+
How often it redraws --reuse
+
Redrawing at every pass is closest to the reference. Less often is faster, and the framing can shift.
+
Every pass — closest to reference
+ Every other pass — validated fast setting
+ Rarely — preview quality
+
saves ≈ 2 min at this length
+
+
+
Work smaller, then enlarge --render-width
+
Draw at a smaller size and scale up. Much faster, less fine detail.
+
Off — draw at full size
+ 384 × 384, enlarged to 512 × 512
+ 320 × 320, enlarged to 512 × 512
+
saves ≈ 3 min
+
+
+
+
+ Watch it being made --preview-dir
+ Shows a picture after every pass. Adds about 15 s.
+
+
+
+ Pair up detail while drawing --token-reduction
+ Faster, but the composition can drift. Leave off at small sizes.
+
+
+
+
+
+ Expert every h3 flag, named as it is on the command line
+
+
+
Text and variation --prompt --seed
+
The prompt is sent as written. The same prompt, settings and seed
+ produce the same video on the same build.
+
References --ref-image --ref-video --ref-silent-video --ref-video-audio --ref-audio --ref-image-size
+
Ordered Ref2VA references: at most 12 — 9 images, 3 videos, 3 audio
+ inputs. Audio needs a picture or a clip beside it, lasts 2–15 s, and
+ cannot be combined with a start or end frame.
+
Duration and canvas --frames --seconds --width --height --render-width --render-height
+
Frames round up to 5 + 17n, 22…362. Sides are multiples of 32 and the
+ area stays under 768 × 1344.
+
+
Sampler --steps --layers --reuse --core-reuse --token-reduction
+
Steps 2…1000, layers 35…50, reuse 1…3, core reuse 1…6. Reuse and core
+ reuse cannot both exceed 1.
+
+
Parity flags 10
+
Force close-reference implementations, slower by design:
+ --use-slower-bf16-mlp --use-slower-bf16-qkv
+ --use-slower-bf16-attention-output --use-slower-row-major-attention-output
+ --use-slower-unfused-int8-inputs --use-slower-unfused-qkv-rope
+ --use-slower-scalar-qkv-rms --use-slower-uncached-int8-scales
+ --use-slower-dynamic-fc1-k --use-slower-grouped-quantizer
+
Set by the server --model-dir --output --info
+
Not exposed: --show and
+ --zoom draw in a terminal, which a browser has none of.
+
+
+
+
+
+
+
+ “A red fox walks through fresh snow in a pine forest”
+ 4.5 s · widescreen · variation 42
+ Stop
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ pass 7 of 20
+
+
+
+
+
Painting the picture — pass 7 of 20
+
+ elapsed 00:03:41
+ about 5 min left
+ 41 % done
+
+
denoise 7/20 · 864×480 · seed 42
+
+
+
+
+
+ Takes
+
+
+ fox in the snow 4.5 s
+
+ surfer 2.3 s
+
+ red cube 0.9 s
+
+ hummingbird 4.5 s
+
+
+
+
+
+
diff --git a/docs/mockup/v3.html b/docs/mockup/v3.html
new file mode 100644
index 00000000..0ed644c5
--- /dev/null
+++ b/docs/mockup/v3.html
@@ -0,0 +1,364 @@
+
+
+
+
+
+h3.c studio — v3
+
+
+
+
+
+
+
+
+ h3.c studio
+ NVIDIA GB10 · ready
+
+
+
+
+
+
+
+
+ A tall waterfall seen from below, mist rising
+ Painting the picture · 33 % · about 16 min left
+
+
Watch Stop
+
+
+
+
+
+ 2.5 s ·
+ square ·
+ balanced ·
+ variation 42
+ about 14 min
+
+
+
+ Widescreen 864×480 · 21 min
+ Square 512×512 · 14 min
+ Vertical 480×864 · 21 min
+
+
+
+ That size is larger than the model can make.
+ Choose a smaller shape, or lower the exact size under Everything else.
+ what h3 reported
+
+
+
+ Make the video
+ Everything else
+
+
+
+ or start from
+ a photo
+ a clip
+ a reference face
+
+
+
+
+
+
+
+
+
+ “A red paper boat drifting on a dark pond”
+
+ Keep making
+ Stop
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Painting the picture — pass 6 of 20
+ 07:42 · about 16 min left
+
+
+
+
+
+
+ Takes
+
+
+
+
A red paper boat drifting
+
1:47 delete
+
+
+
+
A tall waterfall seen from below
+
3:25 delete
+
+
+
+
A small red cube spinning
+
1:41 delete
+
+
+
+
+
+
diff --git a/docs/mockup/v4.html b/docs/mockup/v4.html
new file mode 100644
index 00000000..70a85c8e
--- /dev/null
+++ b/docs/mockup/v4.html
@@ -0,0 +1,451 @@
+
+
+
+
+
+h3.c studio — v4
+
+
+
+
+
+
+
+
+ h3.c studio
+ NVIDIA GB10 · ready
+ show a render
+ theme
+
+
+
+
+
+
+
+
+ A tall waterfall seen from below, mist rising
+ Painting the picture · 33 % · about 16 min left
+
+
Watch Stop
+
+
+
+
+
+
+
starts from this photo ✕
+
a face to keep ✕
+
+ a photo, clip or sound
+
+
+
+ 2.5 s ·
+ square ·
+ balanced ·
+ variation 42
+ about 14 min
+
+
+
+ Widescreen 864×480 · 21 min
+ Square 512×512 · 14 min
+ Vertical 480×864 · 21 min
+
+
+
+
+ Make the video ≈ 14 MIN
+ Everything else
+
+
+
+ or start from
+ a photo
+ a clip
+ a reference face
+
+ Drop a photo, clip or sound anywhere on this page to add it to your library.
+
+
+
+
+
+
+
+ “A red paper boat drifting on a dark pond”
+
+ Keep making
+ Stop
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Painting the picture — pass 6 of 20
+ 07:42 · about 16 min left
+
+
+
+
+
+
+
+ Takes
+
+
+
+
+
00:00 / 01:47
+
Reuse Save Delete
+
+
A red paper boat drifting
+
512×512 · 1:47
+
+
+
+
+
00:00 / 03:25
+
Reuse Save Delete
+
+
A tall waterfall seen from below
+
864×480 · 3:25
+
+
+
+
+
00:00 / 01:41
+
Reuse Save Delete
+
+
A small red cube spinning
+
512×512 · 1:41
+
+
+
+
+
+
+
diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 00000000..9108afa1
--- /dev/null
+++ b/eslint.config.js
@@ -0,0 +1,55 @@
+import js from "@eslint/js";
+import reactHooks from "eslint-plugin-react-hooks";
+import tseslint from "typescript-eslint";
+
+export default [
+ {
+ ignores: [
+ "node_modules/**",
+ "**/node_modules/**",
+ "MiniMax-H3/**",
+ "outputs/**",
+ "logs/**",
+ "misc/**",
+ "webui/frontend/dist/**",
+ "webui/frontend/src/generated/**",
+ "**/.venv/**",
+ "**/__pycache__/**",
+ "**/._*",
+ ],
+ },
+ js.configs.recommended,
+ ...tseslint.configs.recommended,
+ {
+ files: ["**/*.mjs", "**/*.js"],
+ languageOptions: {
+ ecmaVersion: 2023,
+ sourceType: "module",
+ globals: { console: "readonly", process: "readonly" },
+ },
+ },
+ {
+ files: ["webui/frontend/**/*.{ts,tsx}"],
+ plugins: { "react-hooks": reactHooks },
+ languageOptions: {
+ ecmaVersion: 2023,
+ sourceType: "module",
+ globals: {
+ window: "readonly",
+ document: "readonly",
+ fetch: "readonly",
+ FormData: "readonly",
+ File: "readonly",
+ EventSource: "readonly",
+ MessageEvent: "readonly",
+ HTMLInputElement: "readonly",
+ setTimeout: "readonly",
+ clearTimeout: "readonly",
+ console: "readonly",
+ },
+ },
+ rules: {
+ ...reactHooks.configs.recommended.rules,
+ },
+ },
+];
diff --git a/h3.c b/h3.c
index d5dca259..c6e18929 100644
--- a/h3.c
+++ b/h3.c
@@ -3,7 +3,7 @@
#include "h3_host.h"
#include "h3_dit.h"
#include "h3_ffmpeg.h"
-#include "h3_metal.h"
+#include "h3_device.h"
#include "h3_multimodal.h"
#include "h3_safetensors.h"
#include "h3_text_encoder.h"
@@ -130,10 +130,14 @@ static int h3_key_file(h3_key *key, const char *role, const char *path) {
if (stat(path, &status) != 0)
return h3_key_append(key, "|%s=%zu:%s:missing", role,
strlen(path), path);
+#if defined(__APPLE__)
+ const struct timespec modified = status.st_mtimespec;
+#else
+ const struct timespec modified = status.st_mtim;
+#endif
return h3_key_append(key, "|%s=%zu:%s:%lld:%lld:%ld", role, strlen(path),
path, (long long)status.st_size,
- (long long)status.st_mtimespec.tv_sec,
- status.st_mtimespec.tv_nsec);
+ (long long)modified.tv_sec, modified.tv_nsec);
}
static char *h3_conditioning_key(const char *prompt, const h3_params *params,
@@ -449,9 +453,9 @@ h3_ctx *h3_load_dir(const char *model_dir) {
h3_free(ctx);
return NULL;
}
- char metal_error[256];
- if (!h3_metal_probe(&ctx->device, metal_error, sizeof(metal_error))) {
- h3_set_error(ctx, "%s", metal_error);
+ char device_error[256];
+ if (!h3_device_probe(&ctx->device, device_error, sizeof(device_error))) {
+ h3_set_error(ctx, "%s", device_error);
snprintf(h3_global_error, sizeof(h3_global_error), "%s", ctx->error);
h3_free(ctx);
return NULL;
@@ -559,10 +563,12 @@ static int h3_valid_params(h3_ctx *ctx, const h3_params *params) {
h3_set_error(ctx, "int8 row FC2 cannot be combined with the BF16 MLP");
return 0;
}
+#if defined(__APPLE__)
if (params->use_int8_row_fc2 && !h3_device(ctx)->metal4) {
h3_set_error(ctx, "int8 row FC2 requires an M5-class Metal 4 GPU");
return 0;
}
+#endif
if (params->preview_denoise != 0 && params->preview_denoise != 1) {
h3_set_error(ctx, "denoising preview must be zero or one");
return 0;
diff --git a/h3.h b/h3.h
index 29640b37..c0f4f236 100644
--- a/h3.h
+++ b/h3.h
@@ -169,7 +169,7 @@ struct h3_result {
uint64_t seed;
};
-/* Load model metadata and initialize the Metal device. Weights remain unmapped. */
+/* Load model metadata and initialize the selected GPU. Weights remain unmapped. */
h3_ctx *h3_load_dir(const char *model_dir);
void h3_free(h3_ctx *ctx);
diff --git a/h3_cli.c b/h3_cli.c
index 79339c82..dd6d9538 100644
--- a/h3_cli.c
+++ b/h3_cli.c
@@ -102,9 +102,14 @@ static int set_directory(char destination[H3_CLI_PATH], const char *path) {
}
static uint64_t random_seed(void) {
- uint64_t value;
- arc4random_buf(&value, sizeof(value));
- return value;
+ uint64_t value = 0;
+ FILE *random = fopen("/dev/urandom", "rb");
+ if (random) {
+ size_t count = fread(&value, 1, sizeof(value), random);
+ fclose(random);
+ if (count == sizeof(value)) return value;
+ }
+ return (uint64_t)time(NULL) ^ ((uint64_t)(unsigned)getpid() << 32);
}
static int cli_progress(const char *phase, int completed, int total,
diff --git a/h3_device.h b/h3_device.h
new file mode 100644
index 00000000..e3be12ab
--- /dev/null
+++ b/h3_device.h
@@ -0,0 +1,16 @@
+#ifndef H3_DEVICE_H
+#define H3_DEVICE_H
+
+#include "h3.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+int h3_device_probe(h3_device_info *info, char *error, size_t error_size);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/h3_device_cuda.cu b/h3_device_cuda.cu
new file mode 100644
index 00000000..f78bce6c
--- /dev/null
+++ b/h3_device_cuda.cu
@@ -0,0 +1,50 @@
+#include "h3_device.h"
+
+#include
+
+#include
+#include
+#include
+
+static void h3_cuda_error(char *error, size_t error_size, const char *operation,
+ cudaError_t status) {
+ if (error && error_size) {
+ snprintf(error, error_size, "%s: %s", operation,
+ cudaGetErrorString(status));
+ }
+}
+
+extern "C" int h3_device_probe(h3_device_info *info, char *error,
+ size_t error_size) {
+ if (!info) {
+ if (error && error_size) snprintf(error, error_size, "device info is required");
+ return 0;
+ }
+ memset(info, 0, sizeof(*info));
+
+ int device = 0;
+ cudaError_t status = cudaGetDevice(&device);
+ if (status != cudaSuccess) {
+ h3_cuda_error(error, error_size, "cannot select CUDA device", status);
+ return 0;
+ }
+ cudaDeviceProp properties;
+ status = cudaGetDeviceProperties(&properties, device);
+ if (status != cudaSuccess) {
+ h3_cuda_error(error, error_size, "cannot inspect CUDA device", status);
+ return 0;
+ }
+
+ snprintf(info->name, sizeof(info->name), "%.127s", properties.name);
+ snprintf(info->architecture, sizeof(info->architecture), "CUDA sm_%d%d",
+ properties.major, properties.minor);
+ struct sysinfo system;
+ if (sysinfo(&system) == 0) {
+ info->physical_memory =
+ (uint64_t)system.totalram * (uint64_t)system.mem_unit;
+ }
+ info->recommended_working_set = (uint64_t)properties.totalGlobalMem;
+ info->max_buffer_length = (uint64_t)properties.totalGlobalMem;
+ info->unified_memory = properties.unifiedAddressing ? 1 : 0;
+ return 1;
+}
diff --git a/h3_ffmpeg.c b/h3_ffmpeg.c
index 66762425..171d3732 100644
--- a/h3_ffmpeg.c
+++ b/h3_ffmpeg.c
@@ -1,6 +1,8 @@
#include "h3_ffmpeg.h"
#include
+#include
+#include
#include
#include
#include
@@ -14,6 +16,8 @@
extern char **environ;
+#define H3_WRITE_MAX ((size_t)PTRDIFF_MAX)
+
static const char *ffmpeg_program(void) {
const char *override = getenv("H3_FFMPEG");
return override && *override ? override : "ffmpeg";
@@ -57,8 +61,7 @@ static int write_all(int descriptor, const uint8_t *data, size_t bytes,
char *error, size_t error_size) {
while (bytes) {
ssize_t written = write(descriptor, data,
- bytes > (size_t)SSIZE_MAX ?
- (size_t)SSIZE_MAX : bytes);
+ bytes > H3_WRITE_MAX ? H3_WRITE_MAX : bytes);
if (written < 0 && errno == EINTR) continue;
if (written <= 0) {
fail(error, error_size, "cannot stream RGB frames to FFmpeg: %s",
@@ -588,8 +591,7 @@ static void *stream_thread(void *opaque) {
const uint8_t *data = writer->data;
size_t remaining = writer->bytes;
while (remaining) {
- size_t request = remaining > (size_t)SSIZE_MAX ?
- (size_t)SSIZE_MAX : remaining;
+ size_t request = remaining > H3_WRITE_MAX ? H3_WRITE_MAX : remaining;
ssize_t written = write(writer->descriptor, data, request);
if (written < 0 && errno == EINTR) continue;
if (written <= 0) {
@@ -640,7 +642,8 @@ int h3_ffmpeg_write_av_rgb24_f32(const char *path, const uint8_t *frames,
for (int sample = 0; sample < samples; sample++)
for (int channel = 0; channel < channels; channel++)
interleaved[(size_t)sample * (size_t)channels + (size_t)channel] =
- pcm[(size_t)channel * (size_t)samples + (size_t)sample];
+ fmaxf(-0.5f, fminf(0.5f,
+ pcm[(size_t)channel * (size_t)samples + (size_t)sample]));
int video_pipe[2] = {-1, -1}, audio_pipe[2] = {-1, -1};
if (pipe(video_pipe) != 0 || pipe(audio_pipe) != 0) {
@@ -673,7 +676,9 @@ int h3_ffmpeg_write_av_rgb24_f32(const char *path, const uint8_t *frames,
"-i", audio_input,
"-map", "0:v:0", "-map", "1:a:0",
"-c:v", "libx264", "-preset", "fast", "-crf", "18",
- "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k",
+ "-pix_fmt", "yuv420p",
+ "-af", "alimiter=limit=0.5:level=disabled",
+ "-c:a", "aac", "-b:a", "192k",
"-movflags", "+faststart", (char *)path, NULL
};
posix_spawn_file_actions_t actions;
diff --git a/h3_gpu.h b/h3_gpu.h
index 3a47cc35..7fb2871f 100644
--- a/h3_gpu.h
+++ b/h3_gpu.h
@@ -4,6 +4,10 @@
#include
#include
+#ifdef __cplusplus
+extern "C" {
+#endif
+
typedef struct h3_gpu h3_gpu;
typedef struct h3_gpu_tensor h3_gpu_tensor;
@@ -610,4 +614,8 @@ int h3_gpu_silu_mul_bf16(h3_gpu *gpu, h3_gpu_tensor *output,
const h3_gpu_tensor *gate,
const h3_gpu_tensor *up, uint32_t elements);
+#ifdef __cplusplus
+}
+#endif
+
#endif
diff --git a/h3_gpu_cuda.cu b/h3_gpu_cuda.cu
new file mode 100644
index 00000000..a5a74573
--- /dev/null
+++ b/h3_gpu_cuda.cu
@@ -0,0 +1,3514 @@
+#include "h3_gpu.h"
+
+#include
+#include
+#include
+
+#ifdef H3_USE_CUDNN
+#include
+#include
+#include
+#endif
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#ifdef H3_USE_CUDNN
+namespace h3_fe = cudnn_frontend;
+
+struct h3_cudnn_sdpa {
+ uint32_t sequence;
+ uint32_t heads;
+ float scale;
+ int head_major_output;
+ int ready;
+ cudnnHandle_t handle;
+ std::shared_ptr graph;
+ void *workspace;
+ h3_cudnn_sdpa *next;
+};
+#endif
+
+struct h3_gpu {
+ cudaStream_t stream;
+ cublasLtHandle_t blas;
+ cudaEvent_t begin_event;
+ cudaEvent_t end_event;
+ cudaEvent_t continue_event;
+ h3_gpu_stats stats;
+ char error[512];
+ char profile_label[128];
+ double profile_mark_time;
+ double encode_start_time;
+ int recording;
+#ifdef H3_USE_CUDNN
+ h3_cudnn_sdpa *cudnn_sdpa;
+#endif
+};
+
+struct h3_gpu_tensor {
+ h3_gpu *gpu;
+ void *data;
+ size_t elements = 0;
+ size_t bytes;
+ h3_gpu_dtype dtype;
+};
+
+static double h3_wall_time(void) {
+ struct timespec value;
+ if (clock_gettime(CLOCK_MONOTONIC, &value) != 0) return 0.0;
+ return (double)value.tv_sec + (double)value.tv_nsec * 1e-9;
+}
+
+static int h3_set_error(h3_gpu *gpu, const char *format, ...) {
+ if (gpu) {
+ va_list arguments;
+ va_start(arguments, format);
+ vsnprintf(gpu->error, sizeof(gpu->error), format, arguments);
+ va_end(arguments);
+ }
+ return 0;
+}
+
+static int h3_cuda_ok(h3_gpu *gpu, cudaError_t status,
+ const char *operation) {
+ if (status == cudaSuccess) return 1;
+ return h3_set_error(gpu, "%s: %s", operation, cudaGetErrorString(status));
+}
+
+#ifdef H3_USE_CUDNN
+static void h3_cudnn_sdpa_free(h3_cudnn_sdpa *entry) {
+ while (entry) {
+ h3_cudnn_sdpa *next = entry->next;
+ if (entry->workspace) (void)cudaFree(entry->workspace);
+ entry->graph.reset();
+ if (entry->handle) (void)cudnnDestroy(entry->handle);
+ delete entry;
+ entry = next;
+ }
+}
+
+static h3_cudnn_sdpa *h3_cudnn_sdpa_get(
+ h3_gpu *gpu, uint32_t sequence, uint32_t heads, float scale,
+ int head_major_output, char *reason, size_t reason_size) {
+ for (h3_cudnn_sdpa *entry = gpu->cudnn_sdpa; entry; entry = entry->next)
+ if (entry->sequence == sequence && entry->heads == heads &&
+ entry->scale == scale &&
+ entry->head_major_output == head_major_output)
+ return entry;
+
+ h3_cudnn_sdpa *entry = new (std::nothrow) h3_cudnn_sdpa{};
+ if (!entry) {
+ snprintf(reason, reason_size, "out of memory creating cuDNN SDPA cache");
+ return NULL;
+ }
+ entry->sequence = sequence;
+ entry->heads = heads;
+ entry->scale = scale;
+ entry->head_major_output = head_major_output;
+ entry->next = gpu->cudnn_sdpa;
+ gpu->cudnn_sdpa = entry;
+
+ try {
+ cudnnStatus_t cudnn_status = cudnnCreate(&entry->handle);
+ if (cudnn_status != CUDNN_STATUS_SUCCESS) {
+ snprintf(reason, reason_size, "cudnnCreate: %s",
+ cudnnGetErrorString(cudnn_status));
+ return entry;
+ }
+ cudnn_status = cudnnSetStream(entry->handle, gpu->stream);
+ if (cudnn_status != CUDNN_STATUS_SUCCESS) {
+ snprintf(reason, reason_size, "cudnnSetStream: %s",
+ cudnnGetErrorString(cudnn_status));
+ return entry;
+ }
+
+ enum { Q_UID = 1, K_UID = 2, V_UID = 3, O_UID = 4 };
+ int64_t b = 1, h = heads, s = sequence, d = 128;
+ entry->graph = std::make_shared();
+ entry->graph->set_io_data_type(h3_fe::DataType_t::BFLOAT16)
+ .set_intermediate_data_type(h3_fe::DataType_t::FLOAT)
+ .set_compute_data_type(h3_fe::DataType_t::FLOAT);
+ auto q = entry->graph->tensor(
+ h3_fe::graph::Tensor_attributes()
+ .set_name("Q").set_uid(Q_UID)
+ .set_dim({b, h, s, d})
+ .set_stride({h * s * d, s * d, d, 1}));
+ auto k = entry->graph->tensor(
+ h3_fe::graph::Tensor_attributes()
+ .set_name("K").set_uid(K_UID)
+ .set_dim({b, h, s, d})
+ .set_stride({h * s * d, s * d, d, 1}));
+ auto v = entry->graph->tensor(
+ h3_fe::graph::Tensor_attributes()
+ .set_name("V").set_uid(V_UID)
+ .set_dim({b, h, s, d})
+ .set_stride({h * s * d, s * d, d, 1}));
+ auto options = h3_fe::graph::SDPA_attributes()
+ .set_name("h3_sdpa")
+ .set_generate_stats(false)
+ .set_attn_scale(scale);
+ auto result = entry->graph->sdpa(q, k, v, options);
+ auto output = result[0];
+ output->set_output(true).set_uid(O_UID).set_dim({b, h, s, d});
+ if (head_major_output)
+ output->set_stride({h * s * d, s * d, d, 1});
+ else
+ output->set_stride({h * s * d, d, h * d, 1});
+
+ auto status = entry->graph->build(entry->handle,
+ {h3_fe::HeurMode_t::A});
+ if (!status.is_good()) {
+ snprintf(reason, reason_size, "cuDNN graph build: %s",
+ status.get_message().c_str());
+ return entry;
+ }
+ int64_t workspace_size = 0;
+ auto workspace_status =
+ entry->graph->get_workspace_size(workspace_size);
+ if (!workspace_status.is_good() || workspace_size < 0) {
+ snprintf(reason, reason_size, "cuDNN workspace query: %s",
+ workspace_status.get_message().c_str());
+ return entry;
+ }
+ if (workspace_size > 0) {
+ cudaError_t cuda_status = cudaMalloc(&entry->workspace,
+ (size_t)workspace_size);
+ if (cuda_status != cudaSuccess) {
+ snprintf(reason, reason_size, "cuDNN workspace: %s",
+ cudaGetErrorString(cuda_status));
+ return entry;
+ }
+ }
+ entry->ready = 1;
+ return entry;
+ } catch (const std::exception &error) {
+ snprintf(reason, reason_size, "cuDNN frontend: %s", error.what());
+ return entry;
+ }
+}
+
+static int h3_cudnn_sdpa_execute(
+ h3_gpu *gpu, h3_cudnn_sdpa *entry, void *output,
+ const void *query, const void *key, const void *value,
+ char *reason, size_t reason_size) {
+ enum { Q_UID = 1, K_UID = 2, V_UID = 3, O_UID = 4 };
+ if (!entry || !entry->ready) return 0;
+ std::unordered_map pointers = {
+ {Q_UID, const_cast(query)},
+ {K_UID, const_cast(key)},
+ {V_UID, const_cast(value)},
+ {O_UID, output},
+ };
+ cudnnStatus_t cudnn_status = cudnnSetStream(entry->handle, gpu->stream);
+ if (cudnn_status != CUDNN_STATUS_SUCCESS) {
+ snprintf(reason, reason_size, "cudnnSetStream: %s",
+ cudnnGetErrorString(cudnn_status));
+ return 0;
+ }
+ auto status = entry->graph->execute(entry->handle, pointers,
+ entry->workspace);
+ if (!status.is_good()) {
+ snprintf(reason, reason_size, "cuDNN SDPA execute: %s",
+ status.get_message().c_str());
+ return 0;
+ }
+ return 1;
+}
+#endif
+
+static size_t h3_dtype_size(h3_gpu_dtype dtype) {
+ switch (dtype) {
+ case H3_GPU_F32: return sizeof(float);
+ case H3_GPU_BF16: return sizeof(uint16_t);
+ case H3_GPU_I8: return sizeof(int8_t);
+ case H3_GPU_U32: return sizeof(uint32_t);
+ }
+ return 0;
+}
+
+static h3_gpu_tensor *h3_tensor_new(h3_gpu *gpu, size_t elements,
+ h3_gpu_dtype dtype) {
+ if (!gpu) return NULL;
+ size_t item_size = h3_dtype_size(dtype);
+ if (!item_size || elements > SIZE_MAX / item_size) {
+ h3_set_error(gpu, "invalid or overflowing tensor size");
+ return NULL;
+ }
+ h3_gpu_tensor *tensor = (h3_gpu_tensor *)calloc(1, sizeof(*tensor));
+ if (!tensor) {
+ h3_set_error(gpu, "out of memory allocating tensor metadata");
+ return NULL;
+ }
+ tensor->gpu = gpu;
+ tensor->elements = elements;
+ tensor->bytes = elements * item_size;
+ tensor->dtype = dtype;
+ if (tensor->bytes && !h3_cuda_ok(gpu, cudaMalloc(&tensor->data, tensor->bytes),
+ "cudaMalloc")) {
+ free(tensor);
+ return NULL;
+ }
+ gpu->stats.allocated_bytes += tensor->bytes;
+ gpu->stats.live_bytes += tensor->bytes;
+ if (gpu->stats.live_bytes > gpu->stats.peak_live_bytes)
+ gpu->stats.peak_live_bytes = gpu->stats.live_bytes;
+ gpu->stats.tensor_allocations++;
+ return tensor;
+}
+
+static int h3_require_tensor(const h3_gpu_tensor *tensor,
+ h3_gpu_dtype dtype, size_t elements) {
+ return tensor && tensor->dtype == dtype && tensor->elements >= elements;
+}
+
+h3_gpu *h3_gpu_create(const char *shader_source_path,
+ char *error, size_t error_size) {
+ (void)shader_source_path;
+ h3_gpu *gpu = (h3_gpu *)calloc(1, sizeof(*gpu));
+ if (!gpu) {
+ if (error && error_size) snprintf(error, error_size, "out of memory");
+ return NULL;
+ }
+ cudaError_t status = cudaStreamCreateWithFlags(&gpu->stream,
+ cudaStreamNonBlocking);
+ cublasStatus_t blas_status = CUBLAS_STATUS_SUCCESS;
+ if (status == cudaSuccess) blas_status = cublasLtCreate(&gpu->blas);
+ if (status == cudaSuccess && blas_status != CUBLAS_STATUS_SUCCESS)
+ status = cudaErrorInitializationError;
+ if (status == cudaSuccess) status = cudaEventCreate(&gpu->begin_event);
+ if (status == cudaSuccess) status = cudaEventCreate(&gpu->end_event);
+ if (status == cudaSuccess) status = cudaEventCreate(&gpu->continue_event);
+ if (status != cudaSuccess) {
+ if (error && error_size)
+ snprintf(error, error_size, "CUDA initialization: %s",
+ cudaGetErrorString(status));
+ if (gpu->continue_event) cudaEventDestroy(gpu->continue_event);
+ if (gpu->end_event) cudaEventDestroy(gpu->end_event);
+ if (gpu->begin_event) cudaEventDestroy(gpu->begin_event);
+ if (gpu->blas) cublasLtDestroy(gpu->blas);
+ if (gpu->stream) cudaStreamDestroy(gpu->stream);
+ free(gpu);
+ return NULL;
+ }
+ snprintf(gpu->profile_label, sizeof(gpu->profile_label), "CUDA context");
+ gpu->profile_mark_time = h3_wall_time();
+ if (error && error_size) error[0] = '\0';
+ return gpu;
+}
+
+void h3_gpu_free(h3_gpu *gpu) {
+ if (!gpu) return;
+ cudaError_t status = cudaStreamSynchronize(gpu->stream);
+ if (getenv("H3_PROFILE")) {
+ fprintf(stderr, "%s: %.6fs GPU, %.6fs encode, %.6fs wait, "
+ "%llu bytes peak, %llu submissions%s%s\n",
+ gpu->profile_label, gpu->stats.gpu_seconds,
+ gpu->stats.command_encode_seconds,
+ gpu->stats.command_wait_seconds,
+ (unsigned long long)gpu->stats.peak_live_bytes,
+ (unsigned long long)gpu->stats.submissions,
+ status == cudaSuccess ? "" : ", teardown error: ",
+ status == cudaSuccess ? "" : cudaGetErrorString(status));
+ }
+ (void)cudaEventDestroy(gpu->continue_event);
+ (void)cudaEventDestroy(gpu->end_event);
+ (void)cudaEventDestroy(gpu->begin_event);
+#ifdef H3_USE_CUDNN
+ h3_cudnn_sdpa_free(gpu->cudnn_sdpa);
+#endif
+ (void)cublasLtDestroy(gpu->blas);
+ (void)cudaStreamDestroy(gpu->stream);
+ free(gpu);
+}
+
+int h3_gpu_is_m5(const h3_gpu *gpu) { (void)gpu; return 0; }
+int h3_gpu_has_nax_mlp(const h3_gpu *gpu) { (void)gpu; return 0; }
+int h3_gpu_has_int8_mlp(const h3_gpu *gpu) { (void)gpu; return 0; }
+
+h3_gpu_tensor *h3_gpu_tensor_new_f32(h3_gpu *gpu, size_t elements) {
+ return h3_tensor_new(gpu, elements, H3_GPU_F32);
+}
+h3_gpu_tensor *h3_gpu_tensor_new_bf16(h3_gpu *gpu, size_t elements) {
+ return h3_tensor_new(gpu, elements, H3_GPU_BF16);
+}
+h3_gpu_tensor *h3_gpu_tensor_new_i8(h3_gpu *gpu, size_t elements) {
+ return h3_tensor_new(gpu, elements, H3_GPU_I8);
+}
+
+static h3_gpu_tensor *h3_tensor_from(h3_gpu *gpu, const void *values,
+ size_t elements, h3_gpu_dtype dtype) {
+ if (elements && !values) {
+ h3_set_error(gpu, "tensor source is required");
+ return NULL;
+ }
+ h3_gpu_tensor *tensor = h3_tensor_new(gpu, elements, dtype);
+ if (!tensor) return NULL;
+ if (tensor->bytes && !h3_cuda_ok(gpu, cudaMemcpy(tensor->data, values,
+ tensor->bytes, cudaMemcpyHostToDevice), "tensor upload")) {
+ h3_gpu_tensor_free(tensor);
+ return NULL;
+ }
+ return tensor;
+}
+
+h3_gpu_tensor *h3_gpu_tensor_from_f32(h3_gpu *gpu, const float *values,
+ size_t elements) {
+ return h3_tensor_from(gpu, values, elements, H3_GPU_F32);
+}
+h3_gpu_tensor *h3_gpu_tensor_from_bf16(h3_gpu *gpu, const uint16_t *values,
+ size_t elements) {
+ return h3_tensor_from(gpu, values, elements, H3_GPU_BF16);
+}
+h3_gpu_tensor *h3_gpu_tensor_from_u32(h3_gpu *gpu, const uint32_t *values,
+ size_t elements) {
+ return h3_tensor_from(gpu, values, elements, H3_GPU_U32);
+}
+
+static int h3_read_file(h3_gpu_tensor *tensor, const char *path,
+ uint64_t file_offset, size_t elements, int streaming,
+ char *error, size_t error_size) {
+ if (!tensor || !path || tensor->dtype != H3_GPU_BF16 ||
+ elements > tensor->elements || file_offset > (uint64_t)INT64_MAX) {
+ if (error && error_size) snprintf(error, error_size, "invalid BF16 file read");
+ return 0;
+ }
+ size_t bytes = elements * sizeof(uint16_t);
+ if ((uint64_t)bytes > (uint64_t)INT64_MAX - file_offset) {
+ if (error && error_size) snprintf(error, error_size, "BF16 file range overflows off_t");
+ return 0;
+ }
+ void *staging = bytes ? malloc(bytes) : NULL;
+ if (bytes && !staging) {
+ if (error && error_size) snprintf(error, error_size, "cannot allocate pinned staging buffer");
+ return 0;
+ }
+ int fd = open(path, O_RDONLY);
+ if (fd < 0) {
+ if (error && error_size) snprintf(error, error_size, "cannot open %s: %s", path, strerror(errno));
+ free(staging);
+ return 0;
+ }
+ size_t done = 0;
+ while (done < bytes) {
+ ssize_t got = pread(fd, (char *)staging + done, bytes - done,
+ (off_t)(file_offset + done));
+ if (got <= 0) {
+ if (error && error_size) snprintf(error, error_size, "short read from %s", path);
+ close(fd);
+ free(staging);
+ return 0;
+ }
+ done += (size_t)got;
+ }
+ if (streaming && bytes)
+ (void)posix_fadvise(fd, (off_t)file_offset, (off_t)bytes,
+ POSIX_FADV_DONTNEED);
+ close(fd);
+ cudaError_t status = bytes ? cudaMemcpy(tensor->data, staging, bytes,
+ cudaMemcpyHostToDevice) : cudaSuccess;
+ free(staging);
+ if (status != cudaSuccess) {
+ if (error && error_size) snprintf(error, error_size, "CUDA file upload: %s", cudaGetErrorString(status));
+ return 0;
+ }
+ if (error && error_size) error[0] = '\0';
+ return 1;
+}
+
+h3_gpu_tensor *h3_gpu_tensor_load_bf16(h3_gpu *gpu, const char *path,
+ uint64_t file_offset, size_t elements) {
+ h3_gpu_tensor *tensor = h3_gpu_tensor_new_bf16(gpu, elements);
+ char error[256];
+ if (tensor && !h3_read_file(tensor, path, file_offset, elements, 0,
+ error, sizeof(error))) {
+ h3_set_error(gpu, "%s", error);
+ h3_gpu_tensor_free(tensor);
+ return NULL;
+ }
+ return tensor;
+}
+
+h3_gpu_tensor *h3_gpu_tensor_load_f32(h3_gpu *gpu, const char *path,
+ uint64_t file_offset, size_t elements) {
+ if (!gpu || !path || elements > SIZE_MAX / sizeof(float) ||
+ file_offset > (uint64_t)INT64_MAX) {
+ h3_set_error(gpu, "invalid F32 file read");
+ return NULL;
+ }
+ h3_gpu_tensor *tensor = h3_gpu_tensor_new_f32(gpu, elements);
+ if (!tensor) return NULL;
+ size_t bytes = elements * sizeof(float);
+ if ((uint64_t)bytes > (uint64_t)INT64_MAX - file_offset) {
+ h3_set_error(gpu, "F32 file range overflows off_t");
+ h3_gpu_tensor_free(tensor);
+ return NULL;
+ }
+ void *staging = bytes ? malloc(bytes) : NULL;
+ if (bytes && !staging) {
+ h3_set_error(gpu, "cannot allocate pinned staging buffer");
+ h3_gpu_tensor_free(tensor);
+ return NULL;
+ }
+ int fd = open(path, O_RDONLY);
+ if (fd < 0) {
+ h3_set_error(gpu, "cannot open %s: %s", path, strerror(errno));
+ free(staging);
+ h3_gpu_tensor_free(tensor);
+ return NULL;
+ }
+ size_t done = 0;
+ while (done < bytes) {
+ ssize_t got = pread(fd, (char *)staging + done, bytes - done,
+ (off_t)(file_offset + done));
+ if (got <= 0) break;
+ done += (size_t)got;
+ }
+ close(fd);
+ cudaError_t status = cudaSuccess;
+ if (done != bytes) status = cudaErrorInvalidValue;
+ else if (bytes) status = cudaMemcpy(tensor->data, staging, bytes,
+ cudaMemcpyHostToDevice);
+ free(staging);
+ if (done != bytes || status != cudaSuccess) {
+ h3_set_error(gpu, "cannot load F32 tensor from %s", path);
+ h3_gpu_tensor_free(tensor);
+ return NULL;
+ }
+ return tensor;
+}
+
+int h3_gpu_tensor_read_file_bf16(h3_gpu_tensor *tensor, const char *path,
+ uint64_t file_offset, size_t elements,
+ char *error, size_t error_size) {
+ return h3_read_file(tensor, path, file_offset, elements, 0, error, error_size);
+}
+int h3_gpu_tensor_stream_file_bf16(h3_gpu_tensor *tensor, const char *path,
+ uint64_t file_offset, size_t elements,
+ char *error, size_t error_size) {
+ return h3_read_file(tensor, path, file_offset, elements, 1, error, error_size);
+}
+
+void h3_gpu_tensor_free(h3_gpu_tensor *tensor) {
+ if (!tensor) return;
+ if (tensor->data) cudaFree(tensor->data);
+ if (tensor->gpu && tensor->gpu->stats.live_bytes >= tensor->bytes)
+ tensor->gpu->stats.live_bytes -= tensor->bytes;
+ free(tensor);
+}
+size_t h3_gpu_tensor_elements(const h3_gpu_tensor *tensor) {
+ return tensor ? tensor->elements : 0;
+}
+h3_gpu_dtype h3_gpu_tensor_dtype(const h3_gpu_tensor *tensor) {
+ return tensor ? tensor->dtype : H3_GPU_F32;
+}
+
+__global__ static void h3_bf16_to_f32_kernel(float *output,
+ const __nv_bfloat16 *input,
+ size_t count) {
+ size_t index = (size_t)blockIdx.x * blockDim.x + threadIdx.x;
+ if (index < count) output[index] = __bfloat162float(input[index]);
+}
+__global__ static void h3_f32_to_bf16_kernel(__nv_bfloat16 *output,
+ const float *input,
+ size_t count) {
+ size_t index = (size_t)blockIdx.x * blockDim.x + threadIdx.x;
+ if (index < count) output[index] = __float2bfloat16(input[index]);
+}
+
+int h3_gpu_tensor_read_f32_range(const h3_gpu_tensor *tensor,
+ size_t source_offset, float *values,
+ size_t elements) {
+ if (!tensor || !values || source_offset > tensor->elements ||
+ elements > tensor->elements - source_offset)
+ return h3_set_error(tensor ? tensor->gpu : NULL, "invalid F32 tensor read range");
+ if (tensor->dtype == H3_GPU_F32) {
+ return h3_cuda_ok(tensor->gpu, cudaMemcpy(values,
+ (const float *)tensor->data + source_offset,
+ elements * sizeof(float), cudaMemcpyDeviceToHost), "read F32 tensor");
+ }
+ if (tensor->dtype != H3_GPU_BF16)
+ return h3_set_error(tensor->gpu, "tensor is neither F32 nor BF16");
+ float *temporary = NULL;
+ if (elements && cudaMalloc(&temporary, elements * sizeof(float)) != cudaSuccess)
+ return h3_set_error(tensor->gpu, "cannot allocate BF16 conversion buffer");
+ if (elements) h3_bf16_to_f32_kernel<<<(elements + 255) / 256, 256, 0,
+ tensor->gpu->stream>>>(temporary,
+ (const __nv_bfloat16 *)tensor->data + source_offset, elements);
+ cudaError_t status = cudaGetLastError();
+ if (status == cudaSuccess && elements) status = cudaMemcpyAsync(values, temporary,
+ elements * sizeof(float), cudaMemcpyDeviceToHost, tensor->gpu->stream);
+ if (status == cudaSuccess) status = cudaStreamSynchronize(tensor->gpu->stream);
+ if (temporary) cudaFree(temporary);
+ return h3_cuda_ok(tensor->gpu, status, "read BF16 tensor as F32");
+}
+int h3_gpu_tensor_read_f32(const h3_gpu_tensor *tensor, float *values,
+ size_t elements) {
+ return h3_gpu_tensor_read_f32_range(tensor, 0, values, elements);
+}
+int h3_gpu_tensor_read_bf16(const h3_gpu_tensor *tensor, uint16_t *values,
+ size_t elements) {
+ if (!h3_require_tensor(tensor, H3_GPU_BF16, elements) || !values)
+ return h3_set_error(tensor ? tensor->gpu : NULL, "invalid BF16 tensor read");
+ return h3_cuda_ok(tensor->gpu, cudaMemcpy(values, tensor->data,
+ elements * sizeof(uint16_t), cudaMemcpyDeviceToHost), "read BF16 tensor");
+}
+
+int h3_gpu_tensor_write_f32_range(h3_gpu_tensor *tensor,
+ size_t destination_offset,
+ const float *values, size_t elements) {
+ if (!tensor || !values || destination_offset > tensor->elements ||
+ elements > tensor->elements - destination_offset)
+ return h3_set_error(tensor ? tensor->gpu : NULL, "invalid F32 tensor write range");
+ if (tensor->dtype == H3_GPU_F32) {
+ return h3_cuda_ok(tensor->gpu, cudaMemcpy(
+ (float *)tensor->data + destination_offset, values,
+ elements * sizeof(float), cudaMemcpyHostToDevice), "write F32 tensor");
+ }
+ if (tensor->dtype != H3_GPU_BF16)
+ return h3_set_error(tensor->gpu, "tensor is neither F32 nor BF16");
+ float *temporary = NULL;
+ if (elements && cudaMalloc(&temporary, elements * sizeof(float)) != cudaSuccess)
+ return h3_set_error(tensor->gpu, "cannot allocate BF16 conversion buffer");
+ cudaError_t status = elements ? cudaMemcpyAsync(temporary, values,
+ elements * sizeof(float), cudaMemcpyHostToDevice, tensor->gpu->stream) : cudaSuccess;
+ if (status == cudaSuccess && elements) h3_f32_to_bf16_kernel<<<
+ (elements + 255) / 256, 256, 0, tensor->gpu->stream>>>(
+ (__nv_bfloat16 *)tensor->data + destination_offset, temporary, elements);
+ if (status == cudaSuccess) status = cudaGetLastError();
+ if (status == cudaSuccess) status = cudaStreamSynchronize(tensor->gpu->stream);
+ if (temporary) cudaFree(temporary);
+ return h3_cuda_ok(tensor->gpu, status, "write F32 as BF16");
+}
+int h3_gpu_tensor_write_f32(h3_gpu_tensor *tensor, const float *values,
+ size_t elements) {
+ return h3_gpu_tensor_write_f32_range(tensor, 0, values, elements);
+}
+int h3_gpu_tensor_write_bf16_range(h3_gpu_tensor *tensor,
+ size_t destination_offset,
+ const uint16_t *values, size_t elements) {
+ if (!tensor || tensor->dtype != H3_GPU_BF16 || !values ||
+ destination_offset > tensor->elements ||
+ elements > tensor->elements - destination_offset)
+ return h3_set_error(tensor ? tensor->gpu : NULL, "invalid BF16 tensor write range");
+ return h3_cuda_ok(tensor->gpu, cudaMemcpy(
+ (uint16_t *)tensor->data + destination_offset, values,
+ elements * sizeof(uint16_t), cudaMemcpyHostToDevice), "write BF16 tensor");
+}
+int h3_gpu_tensor_write_bf16(h3_gpu_tensor *tensor, const uint16_t *values,
+ size_t elements) {
+ return h3_gpu_tensor_write_bf16_range(tensor, 0, values, elements);
+}
+
+int h3_gpu_begin(h3_gpu *gpu) {
+ if (!gpu || gpu->recording) return h3_set_error(gpu, "command stream is already active");
+ gpu->error[0] = '\0';
+ cudaError_t status = cudaEventRecord(gpu->begin_event, gpu->stream);
+ if (status != cudaSuccess)
+ return h3_cuda_ok(gpu, status, "record begin event");
+ gpu->recording = 1;
+ gpu->encode_start_time = h3_wall_time();
+ return 1;
+}
+int h3_gpu_continue(h3_gpu *gpu) {
+ if (!gpu || !gpu->recording) return h3_set_error(gpu, "no active command stream");
+ cudaError_t status = cudaEventRecord(gpu->continue_event, gpu->stream);
+ if (status == cudaSuccess)
+ status = cudaStreamWaitEvent(gpu->stream, gpu->continue_event, 0);
+ if (status == cudaSuccess) gpu->stats.submissions++;
+ return h3_cuda_ok(gpu, status, "record CUDA continuation boundary");
+}
+int h3_gpu_submit(h3_gpu *gpu) {
+ if (!gpu || !gpu->recording) return h3_set_error(gpu, "no active command stream");
+ double wait_start = h3_wall_time();
+ gpu->stats.command_encode_seconds += wait_start - gpu->encode_start_time;
+ cudaError_t status = cudaEventRecord(gpu->end_event, gpu->stream);
+ if (status == cudaSuccess) status = cudaEventSynchronize(gpu->end_event);
+ gpu->stats.command_wait_seconds += h3_wall_time() - wait_start;
+ if (status == cudaSuccess) {
+ float milliseconds = 0.0f;
+ status = cudaEventElapsedTime(&milliseconds, gpu->begin_event,
+ gpu->end_event);
+ gpu->stats.gpu_seconds += (double)milliseconds / 1000.0;
+ }
+ gpu->recording = 0;
+ if (status == cudaSuccess) gpu->stats.submissions++;
+ return h3_cuda_ok(gpu, status, "submit CUDA stream");
+}
+const char *h3_gpu_error(const h3_gpu *gpu) {
+ return gpu ? gpu->error : "CUDA context is null";
+}
+int h3_gpu_get_stats(const h3_gpu *gpu, h3_gpu_stats *stats) {
+ if (!gpu || !stats) return 0;
+ *stats = gpu->stats;
+ return 1;
+}
+void h3_gpu_profile_set_label(h3_gpu *gpu, const char *label) {
+ if (!gpu) return;
+ snprintf(gpu->profile_label, sizeof(gpu->profile_label), "%.127s",
+ label ? label : "CUDA context");
+}
+void h3_gpu_profile_mark(h3_gpu *gpu, const char *phase) {
+ if (!gpu || !getenv("H3_PROFILE")) return;
+ cudaError_t status = cudaStreamSynchronize(gpu->stream);
+ if (status != cudaSuccess) {
+ h3_cuda_ok(gpu, status, "profile stream synchronization");
+ return;
+ }
+ double now = h3_wall_time();
+ fprintf(stderr, "%s: %s %.6fs\n", gpu->profile_label,
+ phase ? phase : "mark", now - gpu->profile_mark_time);
+ gpu->profile_mark_time = now;
+}
+
+static int h3_copy(h3_gpu *gpu, h3_gpu_tensor *destination,
+ size_t destination_offset, const h3_gpu_tensor *source,
+ size_t source_offset, size_t elements,
+ h3_gpu_dtype dtype) {
+ if (!gpu || !destination || !source || destination->gpu != gpu ||
+ source->gpu != gpu || destination->dtype != dtype ||
+ source->dtype != dtype || destination_offset > destination->elements ||
+ source_offset > source->elements ||
+ elements > destination->elements - destination_offset ||
+ elements > source->elements - source_offset)
+ return h3_set_error(gpu, "invalid tensor copy");
+ size_t item_size = h3_dtype_size(dtype);
+ cudaError_t status = cudaMemcpyAsync(
+ (char *)destination->data + destination_offset * item_size,
+ (const char *)source->data + source_offset * item_size,
+ elements * item_size, cudaMemcpyDeviceToDevice, gpu->stream);
+ if (status == cudaSuccess) gpu->stats.blit_copies++;
+ return h3_cuda_ok(gpu, status, "CUDA tensor copy");
+}
+int h3_gpu_copy_bf16(h3_gpu *gpu, h3_gpu_tensor *destination,
+ size_t destination_offset,
+ const h3_gpu_tensor *source, size_t source_offset,
+ size_t elements) {
+ return h3_copy(gpu, destination, destination_offset, source, source_offset,
+ elements, H3_GPU_BF16);
+}
+int h3_gpu_copy_f32(h3_gpu *gpu, h3_gpu_tensor *destination,
+ size_t destination_offset,
+ const h3_gpu_tensor *source, size_t source_offset,
+ size_t elements) {
+ return h3_copy(gpu, destination, destination_offset, source, source_offset,
+ elements, H3_GPU_F32);
+}
+
+static int h3_launch_ok(h3_gpu *gpu, const char *operation) {
+ cudaError_t status = cudaGetLastError();
+ if (status == cudaSuccess) gpu->stats.direct_dispatches++;
+ return h3_cuda_ok(gpu, status, operation);
+}
+
+static int h3_tensor_is(const h3_gpu_tensor *tensor, const h3_gpu *gpu,
+ h3_gpu_dtype dtype, size_t elements) {
+ return tensor && tensor->gpu == gpu && tensor->dtype == dtype &&
+ tensor->elements >= elements;
+}
+
+static int h3_mul_size(size_t left, size_t right, size_t *result) {
+ if (left && right > SIZE_MAX / left) return 0;
+ *result = left * right;
+ return 1;
+}
+
+static int h3_blas_ok(h3_gpu *gpu, cublasStatus_t status,
+ const char *operation) {
+ if (status == CUBLAS_STATUS_SUCCESS) return 1;
+ return h3_set_error(gpu, "%s: cuBLASLt status %d", operation,
+ (int)status);
+}
+
+static int h3_linear_lt(h3_gpu *gpu, void *output, const void *input,
+ const void *weight, uint32_t rows,
+ uint32_t input_dim, uint32_t output_dim,
+ cudaDataType_t input_type, cudaDataType_t weight_type,
+ cudaDataType_t output_type) {
+ cublasLtMatmulDesc_t operation = NULL;
+ cublasLtMatrixLayout_t input_layout = NULL;
+ cublasLtMatrixLayout_t weight_layout = NULL;
+ cublasLtMatrixLayout_t output_layout = NULL;
+ cublasOperation_t transpose = CUBLAS_OP_T;
+ cublasLtOrder_t row_major = CUBLASLT_ORDER_ROW;
+ float alpha = 1.0f;
+ float beta = 0.0f;
+ cublasStatus_t status = cublasLtMatmulDescCreate(
+ &operation, CUBLAS_COMPUTE_32F, CUDA_R_32F);
+ if (status == CUBLAS_STATUS_SUCCESS)
+ status = cublasLtMatmulDescSetAttribute(
+ operation, CUBLASLT_MATMUL_DESC_TRANSB, &transpose,
+ sizeof(transpose));
+ if (status == CUBLAS_STATUS_SUCCESS)
+ status = cublasLtMatrixLayoutCreate(
+ &input_layout, input_type, rows, input_dim, input_dim);
+ if (status == CUBLAS_STATUS_SUCCESS)
+ status = cublasLtMatrixLayoutCreate(
+ &weight_layout, weight_type, output_dim, input_dim, input_dim);
+ if (status == CUBLAS_STATUS_SUCCESS)
+ status = cublasLtMatrixLayoutCreate(
+ &output_layout, output_type, rows, output_dim, output_dim);
+ if (status == CUBLAS_STATUS_SUCCESS)
+ status = cublasLtMatrixLayoutSetAttribute(
+ input_layout, CUBLASLT_MATRIX_LAYOUT_ORDER, &row_major,
+ sizeof(row_major));
+ if (status == CUBLAS_STATUS_SUCCESS)
+ status = cublasLtMatrixLayoutSetAttribute(
+ weight_layout, CUBLASLT_MATRIX_LAYOUT_ORDER, &row_major,
+ sizeof(row_major));
+ if (status == CUBLAS_STATUS_SUCCESS)
+ status = cublasLtMatrixLayoutSetAttribute(
+ output_layout, CUBLASLT_MATRIX_LAYOUT_ORDER, &row_major,
+ sizeof(row_major));
+ if (status == CUBLAS_STATUS_SUCCESS)
+ status = cublasLtMatmul(gpu->blas, operation, &alpha, input,
+ input_layout, weight, weight_layout, &beta, output, output_layout,
+ output, output_layout, NULL, NULL, 0, gpu->stream);
+ if (output_layout) cublasLtMatrixLayoutDestroy(output_layout);
+ if (weight_layout) cublasLtMatrixLayoutDestroy(weight_layout);
+ if (input_layout) cublasLtMatrixLayoutDestroy(input_layout);
+ if (operation) cublasLtMatmulDescDestroy(operation);
+ if (status == CUBLAS_STATUS_SUCCESS) gpu->stats.mps_linear_dispatches++;
+ return h3_blas_ok(gpu, status, "cuBLASLt linear");
+}
+
+template
+__global__ static void h3_linear_bias_kernel(T *output, const T *bias,
+ size_t elements,
+ uint32_t columns) {
+ size_t index = (size_t)blockIdx.x * blockDim.x + threadIdx.x;
+ if (index >= elements) return;
+ float value = (float)output[index] + (float)bias[index % columns];
+ output[index] = (T)value;
+}
+
+static int h3_linear_bias(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *bias, size_t elements,
+ uint32_t columns) {
+ if (!bias) return 1;
+ unsigned blocks = (unsigned)((elements + 255) / 256);
+ if (output->dtype == H3_GPU_F32)
+ h3_linear_bias_kernel<<stream>>>(
+ (float *)output->data, (const float *)bias->data, elements,
+ columns);
+ else
+ h3_linear_bias_kernel<<stream>>>(
+ (__nv_bfloat16 *)output->data,
+ (const __nv_bfloat16 *)bias->data, elements, columns);
+ return h3_launch_ok(gpu, "linear bias");
+}
+
+__global__ static void h3_patch_convert_kernel(__nv_bfloat16 *output,
+ const float *input,
+ const float *bias,
+ size_t elements,
+ uint32_t columns,
+ int has_bias) {
+ size_t index = (size_t)blockIdx.x * blockDim.x + threadIdx.x;
+ if (index >= elements) return;
+ float value = input[index];
+ if (has_bias) value += bias[index % columns];
+ output[index] = __float2bfloat16(value);
+}
+
+static int h3_patch_convert(h3_gpu *gpu, __nv_bfloat16 *output,
+ const float *input,
+ const h3_gpu_tensor *bias, size_t elements,
+ uint32_t columns) {
+ unsigned blocks = (unsigned)((elements + 255) / 256);
+ h3_patch_convert_kernel<<stream>>>(
+ output, input, bias ? (const float *)bias->data : input, elements,
+ columns, bias != NULL);
+ return h3_launch_ok(gpu, "patch linear conversion");
+}
+
+int h3_gpu_linear_f32(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *input, const h3_gpu_tensor *weight,
+ const h3_gpu_tensor *bias, uint32_t rows, uint32_t input_dim,
+ uint32_t output_dim) {
+ size_t inputs, weights, outputs;
+ if (!rows || !input_dim || !output_dim ||
+ !h3_mul_size(rows, input_dim, &inputs) ||
+ !h3_mul_size(output_dim, input_dim, &weights) ||
+ !h3_mul_size(rows, output_dim, &outputs) ||
+ !h3_tensor_is(input, gpu, H3_GPU_F32, inputs) ||
+ !h3_tensor_is(weight, gpu, H3_GPU_F32, weights) ||
+ !h3_tensor_is(output, gpu, H3_GPU_F32, outputs) ||
+ (bias && !h3_tensor_is(bias, gpu, H3_GPU_F32, output_dim)))
+ return h3_set_error(gpu, "invalid F32 linear tensors or shape");
+ return h3_linear_lt(gpu, output->data, input->data, weight->data, rows,
+ input_dim, output_dim, CUDA_R_32F, CUDA_R_32F,
+ CUDA_R_32F) &&
+ h3_linear_bias(gpu, output, bias, outputs, output_dim);
+}
+
+int h3_gpu_linear_bf16(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *input, const h3_gpu_tensor *weight,
+ const h3_gpu_tensor *bias, uint32_t rows, uint32_t input_dim,
+ uint32_t output_dim) {
+ size_t inputs, weights, outputs;
+ if (!rows || !input_dim || !output_dim ||
+ !h3_mul_size(rows, input_dim, &inputs) ||
+ !h3_mul_size(output_dim, input_dim, &weights) ||
+ !h3_mul_size(rows, output_dim, &outputs) ||
+ !h3_tensor_is(input, gpu, H3_GPU_BF16, inputs) ||
+ !h3_tensor_is(weight, gpu, H3_GPU_BF16, weights) ||
+ !h3_tensor_is(output, gpu, H3_GPU_BF16, outputs) ||
+ (bias && !h3_tensor_is(bias, gpu, H3_GPU_BF16, output_dim)))
+ return h3_set_error(gpu, "invalid BF16 linear tensors or shape");
+ return h3_linear_lt(gpu, output->data, input->data, weight->data, rows,
+ input_dim, output_dim, CUDA_R_16BF, CUDA_R_16BF,
+ CUDA_R_16BF) &&
+ h3_linear_bias(gpu, output, bias, outputs, output_dim);
+}
+
+int h3_gpu_patch_linear_bf16_offset(h3_gpu *gpu, h3_gpu_tensor *output,
+ size_t output_offset, const h3_gpu_tensor *input,
+ size_t input_offset, const h3_gpu_tensor *weight,
+ const h3_gpu_tensor *bias, uint32_t rows, uint32_t input_dim,
+ uint32_t output_dim) {
+ size_t inputs, weights, outputs;
+ if (!rows || output_dim != 5376 ||
+ (input_dim != 32 && input_dim != 96) ||
+ !h3_mul_size(rows, input_dim, &inputs) ||
+ !h3_mul_size(output_dim, input_dim, &weights) ||
+ !h3_mul_size(rows, output_dim, &outputs) ||
+ input_offset > SIZE_MAX - inputs ||
+ output_offset > SIZE_MAX - outputs ||
+ !h3_tensor_is(input, gpu, H3_GPU_F32, input_offset + inputs) ||
+ !h3_tensor_is(weight, gpu, H3_GPU_F32, weights) ||
+ !h3_tensor_is(output, gpu, H3_GPU_BF16, output_offset + outputs) ||
+ (bias && !h3_tensor_is(bias, gpu, H3_GPU_F32, output_dim)))
+ return h3_set_error(gpu, "invalid patch linear tensors or shape");
+ const float *input_data = (const float *)input->data + input_offset;
+ __nv_bfloat16 *output_data =
+ (__nv_bfloat16 *)output->data + output_offset;
+ float *temporary = NULL;
+ cudaError_t status = cudaMallocAsync((void **)&temporary,
+ outputs * sizeof(*temporary), gpu->stream);
+ if (!h3_cuda_ok(gpu, status, "patch linear temporary allocation"))
+ return 0;
+ int ok = h3_linear_lt(gpu, temporary, input_data, weight->data, rows,
+ input_dim, output_dim, CUDA_R_32F, CUDA_R_32F,
+ CUDA_R_32F) &&
+ h3_patch_convert(gpu, output_data, temporary, bias, outputs,
+ output_dim);
+ status = cudaFreeAsync(temporary, gpu->stream);
+ if (ok) ok = h3_cuda_ok(gpu, status, "patch linear temporary free");
+ return ok;
+}
+
+int h3_gpu_patch_linear_bf16(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *input, const h3_gpu_tensor *weight,
+ const h3_gpu_tensor *bias, uint32_t rows, uint32_t input_dim,
+ uint32_t output_dim) {
+ return h3_gpu_patch_linear_bf16_offset(gpu, output, 0, input, 0, weight,
+ bias, rows, input_dim,
+ output_dim);
+}
+
+__global__ static void h3_patch_scatter_kernel(
+ __nv_bfloat16 *output, const float *input, const float *bias,
+ const uint32_t *row_map, uint32_t output_rows, uint32_t rows,
+ uint32_t columns, int has_bias) {
+ size_t index = (size_t)blockIdx.x * blockDim.x + threadIdx.x;
+ size_t elements = (size_t)rows * columns;
+ if (index >= elements) return;
+ uint32_t source_row = (uint32_t)(index / columns);
+ uint32_t destination_row = row_map[source_row];
+ if (destination_row < output_rows)
+ output[(size_t)destination_row * columns + index % columns] =
+ __float2bfloat16(input[index] +
+ (has_bias ? bias[index % columns] : 0.0f));
+}
+
+int h3_gpu_patch_linear_bf16_map(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *input, const h3_gpu_tensor *weight,
+ const h3_gpu_tensor *bias, const h3_gpu_tensor *row_map,
+ uint32_t output_rows, uint32_t rows, uint32_t input_dim,
+ uint32_t output_dim) {
+ size_t inputs, weights, outputs, mapped_outputs;
+ if (!rows || !output_rows || output_dim != 5376 ||
+ (input_dim != 32 && input_dim != 96) ||
+ !h3_mul_size(rows, input_dim, &inputs) ||
+ !h3_mul_size(output_dim, input_dim, &weights) ||
+ !h3_mul_size(output_rows, output_dim, &outputs) ||
+ !h3_mul_size(rows, output_dim, &mapped_outputs) ||
+ !h3_tensor_is(input, gpu, H3_GPU_F32, inputs) ||
+ !h3_tensor_is(weight, gpu, H3_GPU_F32, weights) ||
+ !h3_tensor_is(output, gpu, H3_GPU_BF16, outputs) ||
+ !h3_tensor_is(row_map, gpu, H3_GPU_U32, rows) ||
+ (bias && !h3_tensor_is(bias, gpu, H3_GPU_F32, output_dim)))
+ return h3_set_error(gpu, "invalid mapped patch linear tensors or shape");
+ float *temporary = NULL;
+ cudaError_t status = cudaMallocAsync((void **)&temporary,
+ mapped_outputs * sizeof(*temporary), gpu->stream);
+ if (!h3_cuda_ok(gpu, status, "mapped patch temporary allocation"))
+ return 0;
+ int ok = h3_linear_lt(gpu, temporary, input->data, weight->data, rows,
+ input_dim, output_dim, CUDA_R_32F, CUDA_R_32F,
+ CUDA_R_32F);
+ if (ok) {
+ unsigned blocks = (unsigned)((mapped_outputs + 255) / 256);
+ h3_patch_scatter_kernel<<stream>>>(
+ (__nv_bfloat16 *)output->data, temporary,
+ bias ? (const float *)bias->data : temporary,
+ (const uint32_t *)row_map->data, output_rows, rows, output_dim,
+ bias != NULL);
+ ok = h3_launch_ok(gpu, "mapped patch scatter");
+ }
+ status = cudaFreeAsync(temporary, gpu->stream);
+ if (ok) ok = h3_cuda_ok(gpu, status, "mapped patch temporary free");
+ return ok;
+}
+
+__global__ static void h3_quantize_rows_kernel(
+ int8_t *output, float *scales, const __nv_bfloat16 *input,
+ uint32_t rows, uint32_t columns) {
+ uint32_t row = blockIdx.x * blockDim.x + threadIdx.x;
+ if (row >= rows) return;
+ size_t base = (size_t)row * columns;
+ float maximum = 0.0f;
+ for (uint32_t column = 0; column < columns; column++)
+ maximum = fmaxf(maximum, fabsf(__bfloat162float(input[base + column])));
+ float scale = maximum > 0.0f ? maximum / 127.0f : 1.0f / 127.0f;
+ float inverse = 1.0f / scale;
+ scales[row] = scale;
+ for (uint32_t column = 0; column < columns; column++) {
+ int value = (int)nearbyintf(
+ __bfloat162float(input[base + column]) * inverse);
+ output[base + column] = (int8_t)max(-127, min(127, value));
+ }
+}
+
+static int h3_quantize_rows(h3_gpu *gpu, h3_gpu_tensor *output,
+ h3_gpu_tensor *scales,
+ const h3_gpu_tensor *input, uint32_t rows,
+ uint32_t columns) {
+ size_t elements;
+ if (!rows || !columns || !h3_mul_size(rows, columns, &elements) ||
+ !h3_tensor_is(input, gpu, H3_GPU_BF16, elements) ||
+ !h3_tensor_is(output, gpu, H3_GPU_I8, elements) ||
+ !h3_tensor_is(scales, gpu, H3_GPU_F32, rows))
+ return h3_set_error(gpu, "invalid INT8 quantization tensors or shape");
+ h3_quantize_rows_kernel<<<(rows + 127) / 128, 128, 0, gpu->stream>>>(
+ (int8_t *)output->data, (float *)scales->data,
+ (const __nv_bfloat16 *)input->data, rows, columns);
+ return h3_launch_ok(gpu, "BF16 row quantization");
+}
+
+int h3_gpu_quantize_weight_int8(h3_gpu *gpu, h3_gpu_tensor *output,
+ h3_gpu_tensor *scales, const h3_gpu_tensor *input, uint32_t rows,
+ uint32_t columns) {
+ return h3_quantize_rows(gpu, output, scales, input, rows, columns);
+}
+
+__global__ static void h3_linear_int8_kernel(
+ __nv_bfloat16 *output, const int8_t *input, const int8_t *weight,
+ const float *input_scales, const float *weight_scales,
+ uint32_t rows, uint32_t input_dim, uint32_t output_dim) {
+ uint32_t column = blockIdx.x * blockDim.x + threadIdx.x;
+ uint32_t row = blockIdx.y;
+ if (column >= output_dim || row >= rows) return;
+ int32_t sum = 0;
+ size_t input_base = (size_t)row * input_dim;
+ size_t weight_base = (size_t)column * input_dim;
+ for (uint32_t inner = 0; inner < input_dim; inner++)
+ sum += (int32_t)input[input_base + inner] *
+ (int32_t)weight[weight_base + inner];
+ output[(size_t)row * output_dim + column] = __float2bfloat16(
+ (float)sum * input_scales[row] * weight_scales[column]);
+}
+
+static int h3_linear_int8_quantized(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *quantized_input,
+ const h3_gpu_tensor *input_scales, const h3_gpu_tensor *weight,
+ const h3_gpu_tensor *weight_scales, uint32_t rows,
+ uint32_t input_dim, uint32_t output_dim) {
+ size_t inputs, weights, outputs;
+ if (!h3_mul_size(rows, input_dim, &inputs) ||
+ !h3_mul_size(output_dim, input_dim, &weights) ||
+ !h3_mul_size(rows, output_dim, &outputs) ||
+ !h3_tensor_is(quantized_input, gpu, H3_GPU_I8, inputs) ||
+ !h3_tensor_is(input_scales, gpu, H3_GPU_F32, rows) ||
+ !h3_tensor_is(weight, gpu, H3_GPU_I8, weights) ||
+ !h3_tensor_is(weight_scales, gpu, H3_GPU_F32, output_dim) ||
+ !h3_tensor_is(output, gpu, H3_GPU_BF16, outputs))
+ return h3_set_error(gpu, "invalid quantized INT8 linear tensors");
+ dim3 grid((output_dim + 127) / 128, rows);
+ h3_linear_int8_kernel<<stream>>>(
+ (__nv_bfloat16 *)output->data, (const int8_t *)quantized_input->data,
+ (const int8_t *)weight->data, (const float *)input_scales->data,
+ (const float *)weight_scales->data, rows, input_dim, output_dim);
+ return h3_launch_ok(gpu, "INT8 linear");
+}
+
+int h3_gpu_linear_int8_bf16(h3_gpu *gpu, h3_gpu_tensor *output,
+ h3_gpu_tensor *quantized_input, h3_gpu_tensor *input_scales,
+ const h3_gpu_tensor *input, const h3_gpu_tensor *weight,
+ const h3_gpu_tensor *weight_scales, uint32_t rows,
+ uint32_t input_dim, uint32_t output_dim,
+ int use_slower_uncached_int8_scales) {
+ (void)use_slower_uncached_int8_scales;
+ size_t inputs, weights, outputs;
+ if (!rows || !input_dim || !output_dim ||
+ !h3_mul_size(rows, input_dim, &inputs) ||
+ !h3_mul_size(output_dim, input_dim, &weights) ||
+ !h3_mul_size(rows, output_dim, &outputs) ||
+ !h3_tensor_is(input, gpu, H3_GPU_BF16, inputs) ||
+ !h3_tensor_is(quantized_input, gpu, H3_GPU_I8, inputs) ||
+ !h3_tensor_is(input_scales, gpu, H3_GPU_F32, rows) ||
+ !h3_tensor_is(weight, gpu, H3_GPU_I8, weights) ||
+ !h3_tensor_is(weight_scales, gpu, H3_GPU_F32, output_dim) ||
+ !h3_tensor_is(output, gpu, H3_GPU_BF16, outputs))
+ return h3_set_error(gpu, "invalid INT8 linear tensors or shape");
+ if (!h3_quantize_rows(gpu, quantized_input, input_scales, input, rows,
+ input_dim)) return 0;
+ return h3_linear_int8_quantized(gpu, output, quantized_input,
+ input_scales, weight, weight_scales, rows, input_dim, output_dim);
+}
+
+__global__ static void h3_quantize_head_major_kernel(
+ int8_t *output, float *scales, const __nv_bfloat16 *input,
+ uint32_t rows, uint32_t heads, uint32_t head_dim) {
+ uint32_t row = blockIdx.x * blockDim.x + threadIdx.x;
+ if (row >= rows) return;
+ uint32_t columns = heads * head_dim;
+ float maximum = 0.0f;
+ for (uint32_t head = 0; head < heads; head++)
+ for (uint32_t dimension = 0; dimension < head_dim; dimension++) {
+ size_t source = ((size_t)head * rows + row) * head_dim + dimension;
+ maximum = fmaxf(maximum, fabsf(__bfloat162float(input[source])));
+ }
+ float scale = maximum > 0.0f ? maximum / 127.0f : 1.0f / 127.0f;
+ float inverse = 1.0f / scale;
+ scales[row] = scale;
+ for (uint32_t head = 0; head < heads; head++)
+ for (uint32_t dimension = 0; dimension < head_dim; dimension++) {
+ size_t source = ((size_t)head * rows + row) * head_dim + dimension;
+ int value = (int)nearbyintf(
+ __bfloat162float(input[source]) * inverse);
+ output[(size_t)row * columns + (size_t)head * head_dim + dimension] =
+ (int8_t)max(-127, min(127, value));
+ }
+}
+
+int h3_gpu_linear_int8_head_major_bf16(h3_gpu *gpu,
+ h3_gpu_tensor *output, h3_gpu_tensor *quantized_input,
+ h3_gpu_tensor *input_scales, const h3_gpu_tensor *input,
+ const h3_gpu_tensor *weight, const h3_gpu_tensor *weight_scales,
+ uint32_t rows, uint32_t heads, uint32_t head_dim,
+ uint32_t output_dim) {
+ size_t columns, inputs;
+ if (!rows || !heads || !head_dim || !output_dim ||
+ !h3_mul_size(heads, head_dim, &columns) || columns > UINT32_MAX ||
+ !h3_mul_size(rows, columns, &inputs) ||
+ !h3_tensor_is(input, gpu, H3_GPU_BF16, inputs) ||
+ !h3_tensor_is(quantized_input, gpu, H3_GPU_I8, inputs) ||
+ !h3_tensor_is(input_scales, gpu, H3_GPU_F32, rows))
+ return h3_set_error(gpu, "invalid head-major INT8 linear tensors");
+ h3_quantize_head_major_kernel<<<(rows + 127) / 128, 128, 0,
+ gpu->stream>>>((int8_t *)quantized_input->data,
+ (float *)input_scales->data, (const __nv_bfloat16 *)input->data,
+ rows, heads, head_dim);
+ if (!h3_launch_ok(gpu, "head-major INT8 quantization")) return 0;
+ return h3_linear_int8_quantized(gpu, output, quantized_input,
+ input_scales, weight, weight_scales, rows, (uint32_t)columns,
+ output_dim);
+}
+
+int h3_gpu_mlp_int8_bf16(h3_gpu *gpu, h3_gpu_tensor *output,
+ h3_gpu_tensor *activated, h3_gpu_tensor *quantized_activation,
+ h3_gpu_tensor *activation_scales, const h3_gpu_tensor *input,
+ const h3_gpu_tensor *fc1_weight, const h3_gpu_tensor *fc1_scales,
+ const h3_gpu_tensor *fc2_weight, const h3_gpu_tensor *fc2_scales,
+ const h3_gpu_tensor *fc1_bf16, const h3_gpu_tensor *fc2_bf16,
+ uint32_t rows, uint32_t input_dim, uint32_t hidden_dim,
+ uint32_t output_dim, int use_slower_grouped_quantizer,
+ int use_slower_dynamic_fc1_k, int use_int8_row_fc2,
+ int input_is_quantized) {
+ (void)fc1_bf16;
+ (void)fc2_bf16;
+ (void)use_slower_grouped_quantizer;
+ (void)use_slower_dynamic_fc1_k;
+ (void)use_int8_row_fc2;
+ size_t fused_elements, activation_elements, input_elements;
+ if (!rows || !input_dim || !hidden_dim || !output_dim ||
+ hidden_dim > UINT32_MAX / 2 ||
+ !h3_mul_size(rows, (size_t)hidden_dim * 2, &fused_elements) ||
+ !h3_mul_size(rows, hidden_dim, &activation_elements) ||
+ !h3_mul_size(rows, input_dim, &input_elements) ||
+ !h3_tensor_is(input, gpu, H3_GPU_BF16, input_elements) ||
+ !h3_tensor_is(activated, gpu, H3_GPU_BF16, activation_elements) ||
+ !h3_tensor_is(quantized_activation, gpu, H3_GPU_I8,
+ input_elements > activation_elements ?
+ input_elements : activation_elements) ||
+ !h3_tensor_is(activation_scales, gpu, H3_GPU_F32, rows))
+ return h3_set_error(gpu, "invalid INT8 MLP activation tensors");
+ __nv_bfloat16 *fused_data = NULL;
+ cudaError_t status = cudaMallocAsync((void **)&fused_data,
+ fused_elements * sizeof(*fused_data), gpu->stream);
+ if (!h3_cuda_ok(gpu, status, "INT8 MLP temporary allocation")) return 0;
+ h3_gpu_tensor fused = {gpu, fused_data, fused_elements,
+ fused_elements * sizeof(*fused_data), H3_GPU_BF16};
+ int ok;
+ if (input_is_quantized)
+ ok = h3_linear_int8_quantized(gpu, &fused, quantized_activation,
+ activation_scales, fc1_weight, fc1_scales, rows, input_dim,
+ hidden_dim * 2);
+ else
+ ok = h3_gpu_linear_int8_bf16(gpu, &fused, quantized_activation,
+ activation_scales, input, fc1_weight, fc1_scales, rows, input_dim,
+ hidden_dim * 2, 0);
+ if (ok)
+ ok = h3_gpu_swiglu_bf16(gpu, activated, &fused, rows, hidden_dim);
+ if (ok)
+ ok = h3_gpu_linear_int8_bf16(gpu, output, quantized_activation,
+ activation_scales, activated, fc2_weight, fc2_scales, rows,
+ hidden_dim, output_dim, 0);
+ status = cudaFreeAsync(fused_data, gpu->stream);
+ if (ok) ok = h3_cuda_ok(gpu, status, "INT8 MLP temporary free");
+ return ok;
+}
+
+__global__ static void h3_rms_inverse_kernel(
+ float *inverse, const __nv_bfloat16 *input, size_t input_offset,
+ uint32_t rows, uint32_t width, float epsilon) {
+ uint32_t row = blockIdx.x * blockDim.x + threadIdx.x;
+ if (row >= rows) return;
+ float sum = 0.0f;
+ size_t base = input_offset + (size_t)row * width;
+ for (uint32_t column = 0; column < width; column++) {
+ float value = __bfloat162float(input[base + column]);
+ sum = fmaf(value, value, sum);
+ }
+ inverse[row] = rsqrtf(sum / (float)width + epsilon);
+}
+
+int h3_gpu_adaln_linear_bf16(h3_gpu *gpu, h3_gpu_tensor *output,
+ h3_gpu_tensor *inverse, const h3_gpu_tensor *input,
+ size_t input_offset, const h3_gpu_tensor *norm_weight,
+ const h3_gpu_tensor *modulation, const h3_gpu_tensor *row_map,
+ const h3_gpu_tensor *weight, const h3_gpu_tensor *bias,
+ uint32_t rows, uint32_t width, uint32_t output_dim, uint32_t slots,
+ uint32_t shift_slot, uint32_t scale_slot, float epsilon) {
+ size_t elements;
+ if (!rows || !width || !output_dim || epsilon < 0.0f ||
+ !h3_mul_size(rows, width, &elements) ||
+ !h3_tensor_is(inverse, gpu, H3_GPU_F32, rows))
+ return h3_set_error(gpu, "invalid AdaLN linear inverse or shape");
+ __nv_bfloat16 *normalized_data = NULL;
+ cudaError_t status = cudaMallocAsync((void **)&normalized_data,
+ elements * sizeof(*normalized_data), gpu->stream);
+ if (!h3_cuda_ok(gpu, status, "AdaLN linear temporary allocation"))
+ return 0;
+ h3_gpu_tensor normalized = {gpu, normalized_data, elements,
+ elements * sizeof(*normalized_data), H3_GPU_BF16};
+ h3_rms_inverse_kernel<<<(rows + 127) / 128, 128, 0, gpu->stream>>>(
+ (float *)inverse->data, (const __nv_bfloat16 *)input->data,
+ input_offset, rows, width, epsilon);
+ int ok = h3_launch_ok(gpu, "AdaLN inverse RMS") &&
+ h3_gpu_adaln_bf16_offset(gpu, &normalized, input, input_offset,
+ norm_weight, modulation, row_map, rows, width, slots, shift_slot,
+ scale_slot, epsilon) &&
+ h3_gpu_linear_bf16(gpu, output, &normalized, weight, bias, rows,
+ width, output_dim);
+ status = cudaFreeAsync(normalized_data, gpu->stream);
+ if (ok) ok = h3_cuda_ok(gpu, status, "AdaLN linear temporary free");
+ return ok;
+}
+
+__global__ static void h3_fill_scales_kernel(float *scales, uint32_t begin,
+ uint32_t end) {
+ uint32_t row = begin + blockIdx.x * blockDim.x + threadIdx.x;
+ if (row < end) scales[row] = 1.0f;
+}
+
+int h3_gpu_gate_adaln_quantize_int8(h3_gpu *gpu,
+ h3_gpu_tensor *gated_residual, h3_gpu_tensor *quantized_output,
+ h3_gpu_tensor *quantized_scales, const h3_gpu_tensor *residual,
+ const h3_gpu_tensor *branch, const h3_gpu_tensor *norm_weight,
+ const h3_gpu_tensor *gate_modulation,
+ const h3_gpu_tensor *norm_modulation, const h3_gpu_tensor *row_map,
+ uint32_t rows, uint32_t padded_rows, uint32_t width, uint32_t slots,
+ uint32_t gate_slot, uint32_t shift_slot, uint32_t scale_slot,
+ float epsilon) {
+ size_t elements, padded_elements;
+ if (!rows || padded_rows < rows || !width ||
+ !h3_mul_size(rows, width, &elements) ||
+ !h3_mul_size(padded_rows, width, &padded_elements) ||
+ !h3_tensor_is(quantized_output, gpu, H3_GPU_I8, padded_elements) ||
+ !h3_tensor_is(quantized_scales, gpu, H3_GPU_F32, padded_rows))
+ return h3_set_error(gpu, "invalid fused gate/AdaLN INT8 tensors");
+ __nv_bfloat16 *normalized_data = NULL;
+ cudaError_t status = cudaMallocAsync((void **)&normalized_data,
+ elements * sizeof(*normalized_data), gpu->stream);
+ if (status == cudaSuccess && padded_rows > rows)
+ status = cudaMemsetAsync((int8_t *)quantized_output->data + elements,
+ 0, (padded_elements - elements) * sizeof(int8_t), gpu->stream);
+ if (!h3_cuda_ok(gpu, status, "fused gate/AdaLN temporary setup")) {
+ if (normalized_data) (void)cudaFreeAsync(normalized_data, gpu->stream);
+ return 0;
+ }
+ h3_gpu_tensor normalized = {gpu, normalized_data, elements,
+ elements * sizeof(*normalized_data), H3_GPU_BF16};
+ int ok = h3_gpu_gate_adaln_bf16(gpu, gated_residual, &normalized,
+ residual, branch, norm_weight, gate_modulation, norm_modulation,
+ row_map, rows, width, slots, gate_slot, shift_slot, scale_slot,
+ epsilon) &&
+ h3_quantize_rows(gpu, quantized_output, quantized_scales, &normalized,
+ rows, width);
+ if (ok && padded_rows > rows) {
+ h3_fill_scales_kernel<<<(padded_rows - rows + 127) / 128, 128, 0,
+ gpu->stream>>>((float *)quantized_scales->data, rows, padded_rows);
+ ok = h3_launch_ok(gpu, "INT8 padding scales");
+ }
+ status = cudaFreeAsync(normalized_data, gpu->stream);
+ if (ok) ok = h3_cuda_ok(gpu, status, "fused gate/AdaLN temporary free");
+ return ok;
+}
+
+int h3_gpu_grouped_qkv_linear_rope_bf16(h3_gpu *gpu,
+ h3_gpu_tensor *query, h3_gpu_tensor *key, h3_gpu_tensor *value,
+ h3_gpu_tensor *qkv, const h3_gpu_tensor *input,
+ const h3_gpu_tensor *weight, const h3_gpu_tensor *q_norm,
+ const h3_gpu_tensor *k_norm, const h3_gpu_tensor *rope_cos,
+ const h3_gpu_tensor *rope_sin, uint32_t rows, uint32_t input_dim,
+ uint32_t heads, uint32_t head_dim, uint32_t rope_half,
+ float epsilon) {
+ size_t inner;
+ if (!h3_mul_size(heads, head_dim, &inner) || inner > UINT32_MAX / 3)
+ return h3_set_error(gpu, "grouped QKV projection shape overflows");
+ return h3_gpu_linear_bf16(gpu, qkv, input, weight, NULL, rows, input_dim,
+ (uint32_t)inner * 3) &&
+ h3_gpu_grouped_qkv_rope_bf16(gpu, query, key, value, qkv, q_norm,
+ k_norm, rope_cos, rope_sin, rows, heads, head_dim, rope_half,
+ epsilon);
+}
+
+int h3_gpu_grouped_qkv_linear_rope_int8(h3_gpu *gpu,
+ h3_gpu_tensor *query, h3_gpu_tensor *key, h3_gpu_tensor *value,
+ h3_gpu_tensor *quantized_input, h3_gpu_tensor *input_scales,
+ const h3_gpu_tensor *input, const h3_gpu_tensor *weight,
+ const h3_gpu_tensor *weight_scales, const h3_gpu_tensor *q_norm,
+ const h3_gpu_tensor *k_norm, const h3_gpu_tensor *rope_cos,
+ const h3_gpu_tensor *rope_sin, uint32_t rows, uint32_t input_dim,
+ uint32_t heads, uint32_t head_dim, uint32_t rope_half, float epsilon,
+ int input_is_quantized, int use_slower_unfused_qkv_rope,
+ int use_slower_scalar_qkv_rms,
+ int use_slower_uncached_int8_scales) {
+ (void)use_slower_unfused_qkv_rope;
+ (void)use_slower_scalar_qkv_rms;
+ size_t inner, qkv_elements;
+ if (!h3_mul_size(heads, head_dim, &inner) || inner > UINT32_MAX / 3 ||
+ !h3_mul_size(rows, inner * 3, &qkv_elements))
+ return h3_set_error(gpu, "INT8 grouped QKV shape overflows");
+ __nv_bfloat16 *qkv_data = NULL;
+ cudaError_t status = cudaMallocAsync((void **)&qkv_data,
+ qkv_elements * sizeof(*qkv_data), gpu->stream);
+ if (!h3_cuda_ok(gpu, status, "INT8 QKV temporary allocation")) return 0;
+ h3_gpu_tensor qkv = {gpu, qkv_data, qkv_elements,
+ qkv_elements * sizeof(*qkv_data), H3_GPU_BF16};
+ int ok;
+ if (input_is_quantized)
+ ok = h3_linear_int8_quantized(gpu, &qkv, quantized_input,
+ input_scales, weight, weight_scales, rows, input_dim,
+ (uint32_t)inner * 3);
+ else
+ ok = h3_gpu_linear_int8_bf16(gpu, &qkv, quantized_input,
+ input_scales, input, weight, weight_scales, rows, input_dim,
+ (uint32_t)inner * 3, use_slower_uncached_int8_scales);
+ if (ok)
+ ok = h3_gpu_grouped_qkv_rope_bf16(gpu, query, key, value, &qkv,
+ q_norm, k_norm, rope_cos, rope_sin, rows, heads, head_dim,
+ rope_half, epsilon);
+ status = cudaFreeAsync(qkv_data, gpu->stream);
+ if (ok) ok = h3_cuda_ok(gpu, status, "INT8 QKV temporary free");
+ return ok;
+}
+
+template
+__global__ static void h3_attention_kernel(
+ T *output, const T *query, const T *key, const T *value,
+ uint32_t batch, uint32_t sequence, uint32_t query_heads,
+ uint32_t kv_heads, uint32_t head_dim, float scale, int causal,
+ int head_major_output, int head_major_input) {
+ __shared__ float partial[1024];
+ __shared__ float rescale;
+ __shared__ float weight;
+ __shared__ float denominator;
+ uint32_t query_head = blockIdx.x;
+ uint32_t row = blockIdx.y;
+ uint32_t batch_index = blockIdx.z;
+ uint32_t kv_head = query_head / (query_heads / kv_heads);
+ uint32_t keys = causal ? row + 1 : sequence;
+ size_t query_base = head_major_input ?
+ (((size_t)batch_index * query_heads + query_head) * sequence + row) *
+ head_dim :
+ (((size_t)batch_index * sequence + row) * query_heads + query_head) *
+ head_dim;
+ uint32_t dimension = threadIdx.x;
+ float result = 0.0f;
+ float maximum = -INFINITY;
+ if (threadIdx.x == 0) denominator = 0.0f;
+ __syncthreads();
+ for (uint32_t key_row = 0; key_row < keys; key_row++) {
+ size_t key_base = head_major_input ?
+ (((size_t)batch_index * kv_heads + kv_head) * sequence + key_row) *
+ head_dim :
+ (((size_t)batch_index * sequence + key_row) * kv_heads + kv_head) *
+ head_dim;
+ float product = 0.0f;
+ if (dimension < head_dim) {
+ if constexpr (std::is_same::value)
+ product = query[query_base + dimension] *
+ key[key_base + dimension];
+ else
+ product = __bfloat162float(query[query_base + dimension]) *
+ __bfloat162float(key[key_base + dimension]);
+ }
+ partial[threadIdx.x] = product;
+ __syncthreads();
+ for (uint32_t offset = blockDim.x / 2; offset; offset >>= 1) {
+ if (threadIdx.x < offset)
+ partial[threadIdx.x] += partial[threadIdx.x + offset];
+ __syncthreads();
+ }
+ if (threadIdx.x == 0) {
+ float score = partial[0] * scale;
+ float next_maximum = fmaxf(maximum, score);
+ rescale = expf(maximum - next_maximum);
+ weight = expf(score - next_maximum);
+ denominator = denominator * rescale + weight;
+ maximum = next_maximum;
+ }
+ __syncthreads();
+ if (dimension < head_dim) {
+ size_t value_index = key_base + dimension;
+ float value_element;
+ if constexpr (std::is_same::value)
+ value_element = value[value_index];
+ else
+ value_element = __bfloat162float(value[value_index]);
+ result = result * rescale + weight * value_element;
+ }
+ __syncthreads();
+ }
+ if (dimension < head_dim) {
+ result /= denominator;
+ size_t output_index = head_major_output ?
+ (((size_t)batch_index * query_heads + query_head) * sequence + row) *
+ head_dim + dimension :
+ (((size_t)batch_index * sequence + row) * query_heads + query_head) *
+ head_dim + dimension;
+ if constexpr (std::is_same::value)
+ output[output_index] = result;
+ else
+ output[output_index] = __float2bfloat16(result);
+ }
+}
+
+__global__ static void h3_attention_tiled_bf16_kernel(
+ __nv_bfloat16 *output, const __nv_bfloat16 *query,
+ const __nv_bfloat16 *key, const __nv_bfloat16 *value,
+ uint32_t sequence, uint32_t heads, float scale,
+ int head_major_output) {
+ enum { HEAD_DIM = 128, QUERIES = 8 };
+ __shared__ float shared_key[HEAD_DIM];
+ __shared__ float shared_value[HEAD_DIM];
+ uint32_t warp = threadIdx.x / 32;
+ uint32_t lane = threadIdx.x % 32;
+ uint32_t head = blockIdx.x;
+ uint32_t row = blockIdx.y * QUERIES + warp;
+ int active = row < sequence;
+ size_t query_base = ((size_t)head * sequence + row) * HEAD_DIM;
+ float result[4] = {0.0f, 0.0f, 0.0f, 0.0f};
+ float maximum = -INFINITY;
+ float denominator = 0.0f;
+ for (uint32_t key_row = 0; key_row < sequence; key_row++) {
+ if (threadIdx.x < HEAD_DIM) {
+ size_t index = ((size_t)head * sequence + key_row) * HEAD_DIM +
+ threadIdx.x;
+ shared_key[threadIdx.x] = __bfloat162float(key[index]);
+ shared_value[threadIdx.x] = __bfloat162float(value[index]);
+ }
+ __syncthreads();
+ float product = 0.0f;
+ if (active) {
+#pragma unroll
+ for (uint32_t item = 0; item < 4; item++) {
+ uint32_t dimension = lane + item * 32;
+ product += __bfloat162float(query[query_base + dimension]) *
+ shared_key[dimension];
+ }
+#pragma unroll
+ for (uint32_t offset = 16; offset; offset >>= 1)
+ product += __shfl_down_sync(0xffffffffu, product, offset);
+ }
+ float rescale = 1.0f;
+ float weight = 0.0f;
+ if (active && lane == 0) {
+ float score = product * scale;
+ float next_maximum = fmaxf(maximum, score);
+ rescale = expf(maximum - next_maximum);
+ weight = expf(score - next_maximum);
+ denominator = denominator * rescale + weight;
+ maximum = next_maximum;
+ }
+ rescale = __shfl_sync(0xffffffffu, rescale, 0);
+ weight = __shfl_sync(0xffffffffu, weight, 0);
+ if (active) {
+#pragma unroll
+ for (uint32_t item = 0; item < 4; item++) {
+ uint32_t dimension = lane + item * 32;
+ result[item] = result[item] * rescale +
+ weight * shared_value[dimension];
+ }
+ }
+ __syncthreads();
+ }
+ denominator = __shfl_sync(0xffffffffu, denominator, 0);
+ if (!active) return;
+#pragma unroll
+ for (uint32_t item = 0; item < 4; item++) {
+ uint32_t dimension = lane + item * 32;
+ size_t index = head_major_output ?
+ ((size_t)head * sequence + row) * HEAD_DIM + dimension :
+ ((size_t)row * heads + head) * HEAD_DIM + dimension;
+ output[index] = __float2bfloat16(result[item] / denominator);
+ }
+}
+
+template
+__global__ static void h3_attention_tiled_f32_kernel(
+ float *output, const float *query, const float *key,
+ const float *value, uint32_t sequence, uint32_t heads, float scale,
+ int head_major_output) {
+ enum { QUERIES = 8, ITEMS = HEAD_DIM / 32 };
+ __shared__ float shared_key[HEAD_DIM];
+ __shared__ float shared_value[HEAD_DIM];
+ uint32_t warp = threadIdx.x / 32;
+ uint32_t lane = threadIdx.x % 32;
+ uint32_t head = blockIdx.x;
+ uint32_t row = blockIdx.y * QUERIES + warp;
+ int active = row < sequence;
+ size_t query_base = ((size_t)head * sequence + row) * HEAD_DIM;
+ float result[ITEMS];
+#pragma unroll
+ for (uint32_t item = 0; item < ITEMS; item++) result[item] = 0.0f;
+ float maximum = -INFINITY;
+ float denominator = 0.0f;
+ for (uint32_t key_row = 0; key_row < sequence; key_row++) {
+ if (threadIdx.x < HEAD_DIM) {
+ size_t index = ((size_t)head * sequence + key_row) * HEAD_DIM +
+ threadIdx.x;
+ shared_key[threadIdx.x] = key[index];
+ shared_value[threadIdx.x] = value[index];
+ }
+ __syncthreads();
+ float product = 0.0f;
+ if (active) {
+#pragma unroll
+ for (uint32_t item = 0; item < ITEMS; item++) {
+ uint32_t dimension = lane + item * 32;
+ product += query[query_base + dimension] *
+ shared_key[dimension];
+ }
+#pragma unroll
+ for (uint32_t offset = 16; offset; offset >>= 1)
+ product += __shfl_down_sync(0xffffffffu, product, offset);
+ }
+ float rescale = 1.0f;
+ float weight = 0.0f;
+ if (active && lane == 0) {
+ float score = product * scale;
+ float next_maximum = fmaxf(maximum, score);
+ rescale = expf(maximum - next_maximum);
+ weight = expf(score - next_maximum);
+ denominator = denominator * rescale + weight;
+ maximum = next_maximum;
+ }
+ rescale = __shfl_sync(0xffffffffu, rescale, 0);
+ weight = __shfl_sync(0xffffffffu, weight, 0);
+ if (active) {
+#pragma unroll
+ for (uint32_t item = 0; item < ITEMS; item++) {
+ uint32_t dimension = lane + item * 32;
+ result[item] = result[item] * rescale +
+ weight * shared_value[dimension];
+ }
+ }
+ __syncthreads();
+ }
+ denominator = __shfl_sync(0xffffffffu, denominator, 0);
+ if (!active) return;
+#pragma unroll
+ for (uint32_t item = 0; item < ITEMS; item++) {
+ uint32_t dimension = lane + item * 32;
+ size_t index = head_major_output ?
+ ((size_t)head * sequence + row) * HEAD_DIM + dimension :
+ ((size_t)row * heads + head) * HEAD_DIM + dimension;
+ output[index] = result[item] / denominator;
+ }
+}
+
+static int h3_attention_dispatch(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *query, const h3_gpu_tensor *key,
+ const h3_gpu_tensor *value, uint32_t batch, uint32_t sequence,
+ uint32_t query_heads, uint32_t kv_heads, uint32_t head_dim,
+ float scale, h3_gpu_dtype dtype, int causal, int head_major_output) {
+ int head_major_input = causal != 2;
+ if (causal == 2) causal = 1;
+ size_t query_elements, kv_elements;
+ size_t batch_sequence, query_rows, kv_rows;
+ if (!batch || !sequence || !query_heads || !kv_heads || !head_dim ||
+ query_heads % kv_heads || head_dim > 1024 ||
+ !h3_mul_size(batch, sequence, &batch_sequence) ||
+ !h3_mul_size(batch_sequence, query_heads, &query_rows) ||
+ !h3_mul_size(batch_sequence, kv_heads, &kv_rows) ||
+ !h3_mul_size(query_rows, head_dim, &query_elements) ||
+ !h3_mul_size(kv_rows, head_dim, &kv_elements) ||
+ !h3_tensor_is(query, gpu, dtype, query_elements) ||
+ !h3_tensor_is(key, gpu, dtype, kv_elements) ||
+ !h3_tensor_is(value, gpu, dtype, kv_elements) ||
+ !h3_tensor_is(output, gpu, dtype, query_elements))
+ return h3_set_error(gpu, "invalid attention tensors or shape");
+ uint32_t threads = 128;
+ while (threads < head_dim) threads <<= 1;
+ dim3 grid(query_heads, sequence, batch);
+ int used_cudnn = 0;
+#ifdef H3_USE_CUDNN
+ if (!getenv("H3_DISABLE_CUDNN_ATTENTION") && dtype == H3_GPU_BF16 &&
+ batch == 1 && !causal && head_dim == 128 &&
+ query_heads == kv_heads && head_major_input) {
+ char reason[512] = {0};
+ h3_cudnn_sdpa *entry = h3_cudnn_sdpa_get(
+ gpu, sequence, query_heads, scale, head_major_output,
+ reason, sizeof(reason));
+ if (entry && entry->ready) {
+ if (!h3_cudnn_sdpa_execute(
+ gpu, entry, output->data, query->data, key->data,
+ value->data, reason, sizeof(reason)))
+ return h3_set_error(gpu, "%s", reason);
+ used_cudnn = 1;
+ } else if (getenv("H3_REQUIRE_CUDNN_ATTENTION")) {
+ return h3_set_error(gpu, "%s", reason[0] ? reason :
+ "cuDNN SDPA is unavailable");
+ }
+ }
+#else
+ if (getenv("H3_REQUIRE_CUDNN_ATTENTION"))
+ return h3_set_error(gpu, "cuDNN SDPA was not enabled at build time");
+#endif
+ if (used_cudnn) {
+ /* The graph is enqueued on the same nonblocking stream. */
+ } else if (!getenv("H3_DISABLE_TILED_ATTENTION") &&
+ dtype == H3_GPU_BF16 &&
+ batch == 1 && !causal && head_dim == 128 &&
+ query_heads == kv_heads && head_major_input) {
+ dim3 tiled_grid(query_heads, (sequence + 7) / 8, batch);
+ h3_attention_tiled_bf16_kernel<<stream>>>(
+ (__nv_bfloat16 *)output->data,
+ (const __nv_bfloat16 *)query->data,
+ (const __nv_bfloat16 *)key->data,
+ (const __nv_bfloat16 *)value->data, sequence, query_heads, scale,
+ head_major_output);
+ } else if (!getenv("H3_DISABLE_TILED_ATTENTION") &&
+ dtype == H3_GPU_F32 &&
+ batch == 1 && !causal && head_dim == 64 &&
+ query_heads == kv_heads && head_major_input) {
+ dim3 tiled_grid(query_heads, (sequence + 7) / 8, batch);
+ h3_attention_tiled_f32_kernel<64><<stream>>>(
+ (float *)output->data, (const float *)query->data,
+ (const float *)key->data, (const float *)value->data, sequence,
+ query_heads, scale, head_major_output);
+ } else if (dtype == H3_GPU_F32)
+ h3_attention_kernel<<stream>>>(
+ (float *)output->data, (const float *)query->data,
+ (const float *)key->data, (const float *)value->data, batch,
+ sequence, query_heads, kv_heads, head_dim, scale, causal,
+ head_major_output, head_major_input);
+ else
+ h3_attention_kernel<__nv_bfloat16><<stream>>>((__nv_bfloat16 *)output->data,
+ (const __nv_bfloat16 *)query->data,
+ (const __nv_bfloat16 *)key->data,
+ (const __nv_bfloat16 *)value->data, batch, sequence, query_heads,
+ kv_heads, head_dim, scale, causal, head_major_output,
+ head_major_input);
+ int ok = h3_launch_ok(gpu, "scaled dot-product attention");
+ if (ok) gpu->stats.mps_sdpa_dispatches++;
+ return ok;
+}
+
+int h3_gpu_sdpa_f32(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *query, const h3_gpu_tensor *key,
+ const h3_gpu_tensor *value, uint32_t sequence, uint32_t heads,
+ uint32_t head_dim, float scale) {
+ return h3_attention_dispatch(gpu, output, query, key, value, 1, sequence,
+ heads, heads, head_dim, scale, H3_GPU_F32, 0, 0);
+}
+
+int h3_gpu_sdpa_causal_f32(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *query, const h3_gpu_tensor *key,
+ const h3_gpu_tensor *value, uint32_t batch, uint32_t sequence,
+ uint32_t heads, uint32_t head_dim, float scale) {
+ return h3_attention_dispatch(gpu, output, query, key, value, batch,
+ sequence, heads, heads, head_dim, scale, H3_GPU_F32, 1, 0);
+}
+
+int h3_gpu_sdpa_bf16(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *query, const h3_gpu_tensor *key,
+ const h3_gpu_tensor *value, uint32_t sequence, uint32_t heads,
+ uint32_t head_dim, float scale) {
+ return h3_attention_dispatch(gpu, output, query, key, value, 1, sequence,
+ heads, heads, head_dim, scale, H3_GPU_BF16, 0, 0);
+}
+
+int h3_gpu_sdpa_bf16_head_major_output(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *query, const h3_gpu_tensor *key,
+ const h3_gpu_tensor *value, uint32_t sequence, uint32_t heads,
+ uint32_t head_dim, float scale) {
+ return h3_attention_dispatch(gpu, output, query, key, value, 1, sequence,
+ heads, heads, head_dim, scale, H3_GPU_BF16, 0, 1);
+}
+
+int h3_gpu_gqa_causal_bf16(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *query, const h3_gpu_tensor *key,
+ const h3_gpu_tensor *value, uint32_t sequence, uint32_t query_heads,
+ uint32_t kv_heads, uint32_t head_dim, float scale) {
+ return h3_attention_dispatch(gpu, output, query, key, value, 1, sequence,
+ query_heads, kv_heads, head_dim, scale, H3_GPU_BF16, 2, 0);
+}
+
+__global__ static void h3_conv1d_kernel(float *output, const float *input,
+ const float *weight, const float *bias, uint32_t batch,
+ uint32_t length, uint32_t input_channels, uint32_t output_channels,
+ uint32_t kernel, uint32_t stride, uint32_t padding,
+ uint32_t dilation, uint32_t output_length, size_t output_elements,
+ int has_bias) {
+ size_t index = (size_t)blockIdx.x * blockDim.x + threadIdx.x;
+ if (index >= output_elements) return;
+ uint32_t output_channel = (uint32_t)(index % output_channels);
+ size_t row = index / output_channels;
+ uint32_t output_time = (uint32_t)(row % output_length);
+ uint32_t batch_index = (uint32_t)(row / output_length);
+ float sum = has_bias ? bias[output_channel] : 0.0f;
+ for (uint32_t input_channel = 0; input_channel < input_channels;
+ input_channel++)
+ for (uint32_t tap = 0; tap < kernel; tap++) {
+ int64_t input_time = (int64_t)output_time * stride - padding +
+ (int64_t)tap * dilation;
+ if (input_time < 0 || input_time >= length) continue;
+ size_t input_index = ((size_t)batch_index * length +
+ (size_t)input_time) * input_channels +
+ input_channel;
+ size_t weight_index = ((size_t)output_channel * input_channels +
+ input_channel) * kernel + tap;
+ sum = fmaf(input[input_index], weight[weight_index], sum);
+ }
+ output[((size_t)batch_index * output_length + output_time) *
+ output_channels + output_channel] = sum;
+}
+
+int h3_gpu_conv1d_stride_f32(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *input, const h3_gpu_tensor *weight,
+ const h3_gpu_tensor *bias, uint32_t batch, uint32_t length,
+ uint32_t input_channels, uint32_t output_channels, uint32_t kernel,
+ uint32_t stride, uint32_t padding, uint32_t dilation) {
+ uint64_t effective = (uint64_t)dilation * (kernel ? kernel - 1 : 0) + 1;
+ if (!batch || !length || !input_channels || !output_channels || !kernel ||
+ !stride || !dilation || (uint64_t)length + 2ull * padding < effective)
+ return h3_set_error(gpu, "invalid Conv1d shape");
+ uint64_t output_length64 = ((uint64_t)length + 2ull * padding - effective) /
+ stride + 1;
+ if (output_length64 > UINT32_MAX)
+ return h3_set_error(gpu, "Conv1d output length overflows");
+ uint32_t output_length = (uint32_t)output_length64;
+ size_t input_elements, weight_elements, output_elements;
+ if (!h3_mul_size((size_t)batch * length, input_channels, &input_elements) ||
+ !h3_mul_size((size_t)output_channels * input_channels, kernel,
+ &weight_elements) ||
+ !h3_mul_size((size_t)batch * output_length, output_channels,
+ &output_elements) ||
+ !h3_tensor_is(input, gpu, H3_GPU_F32, input_elements) ||
+ !h3_tensor_is(weight, gpu, H3_GPU_F32, weight_elements) ||
+ !h3_tensor_is(output, gpu, H3_GPU_F32, output_elements) ||
+ (bias && !h3_tensor_is(bias, gpu, H3_GPU_F32, output_channels)))
+ return h3_set_error(gpu, "invalid Conv1d tensors");
+ h3_conv1d_kernel<<<(output_elements + 255) / 256, 256, 0, gpu->stream>>>(
+ (float *)output->data, (const float *)input->data,
+ (const float *)weight->data,
+ bias ? (const float *)bias->data : (const float *)input->data,
+ batch, length, input_channels, output_channels, kernel, stride,
+ padding, dilation, output_length, output_elements, bias != NULL);
+ int ok = h3_launch_ok(gpu, "Conv1d");
+ if (ok) gpu->stats.mps_conv_dispatches++;
+ return ok;
+}
+
+int h3_gpu_conv1d_f32(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *input, const h3_gpu_tensor *weight,
+ const h3_gpu_tensor *bias, uint32_t batch, uint32_t length,
+ uint32_t input_channels, uint32_t output_channels, uint32_t kernel,
+ uint32_t padding, uint32_t dilation) {
+ return h3_gpu_conv1d_stride_f32(gpu, output, input, weight, bias, batch,
+ length, input_channels, output_channels, kernel, 1, padding, dilation);
+}
+
+__global__ static void h3_conv_transpose1d_kernel(float *output,
+ const float *input, const float *weight, const float *bias,
+ uint32_t batch, uint32_t length, uint32_t input_channels,
+ uint32_t output_channels, uint32_t kernel, uint32_t stride,
+ uint32_t padding, uint32_t output_length, size_t output_elements,
+ int has_bias) {
+ size_t index = (size_t)blockIdx.x * blockDim.x + threadIdx.x;
+ if (index >= output_elements) return;
+ uint32_t output_channel = (uint32_t)(index % output_channels);
+ size_t row = index / output_channels;
+ uint32_t output_time = (uint32_t)(row % output_length);
+ uint32_t batch_index = (uint32_t)(row / output_length);
+ float sum = has_bias ? bias[output_channel] : 0.0f;
+ for (uint32_t input_channel = 0; input_channel < input_channels;
+ input_channel++)
+ for (uint32_t tap = 0; tap < kernel; tap++) {
+ int64_t numerator = (int64_t)output_time + padding - tap;
+ if (numerator < 0 || numerator % stride) continue;
+ uint64_t input_time = (uint64_t)numerator / stride;
+ if (input_time >= length) continue;
+ size_t input_index = ((size_t)batch_index * length + input_time) *
+ input_channels + input_channel;
+ size_t weight_index = ((size_t)input_channel * output_channels +
+ output_channel) * kernel + tap;
+ sum = fmaf(input[input_index], weight[weight_index], sum);
+ }
+ output[((size_t)batch_index * output_length + output_time) *
+ output_channels + output_channel] = sum;
+}
+
+int h3_gpu_conv_transpose1d_f32(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *input, const h3_gpu_tensor *weight,
+ const h3_gpu_tensor *bias, uint32_t batch, uint32_t length,
+ uint32_t input_channels, uint32_t output_channels, uint32_t kernel,
+ uint32_t stride, uint32_t padding) {
+ uint64_t full = length ? (uint64_t)(length - 1) * stride + kernel : 0;
+ if (!batch || !length || !input_channels || !output_channels || !kernel ||
+ !stride || full < 2ull * padding || full - 2ull * padding > UINT32_MAX)
+ return h3_set_error(gpu, "invalid ConvTranspose1d shape");
+ uint32_t output_length = (uint32_t)(full - 2ull * padding);
+ size_t input_elements, weight_elements, output_elements;
+ if (!h3_mul_size((size_t)batch * length, input_channels, &input_elements) ||
+ !h3_mul_size((size_t)input_channels * output_channels, kernel,
+ &weight_elements) ||
+ !h3_mul_size((size_t)batch * output_length, output_channels,
+ &output_elements) ||
+ !h3_tensor_is(input, gpu, H3_GPU_F32, input_elements) ||
+ !h3_tensor_is(weight, gpu, H3_GPU_F32, weight_elements) ||
+ !h3_tensor_is(output, gpu, H3_GPU_F32, output_elements) ||
+ (bias && !h3_tensor_is(bias, gpu, H3_GPU_F32, output_channels)))
+ return h3_set_error(gpu, "invalid ConvTranspose1d tensors");
+ h3_conv_transpose1d_kernel<<<(output_elements + 255) / 256, 256, 0,
+ gpu->stream>>>(
+ (float *)output->data, (const float *)input->data,
+ (const float *)weight->data,
+ bias ? (const float *)bias->data : (const float *)input->data,
+ batch, length, input_channels, output_channels, kernel, stride,
+ padding, output_length, output_elements, bias != NULL);
+ int ok = h3_launch_ok(gpu, "ConvTranspose1d");
+ if (ok) gpu->stats.mps_conv_dispatches++;
+ return ok;
+}
+
+__global__ static void h3_audio_qkv_split_kernel(float *query, float *key,
+ float *value, const float *qkv, const float *q_bias,
+ const float *k_bias, const float *v_bias, size_t count,
+ uint32_t length, uint32_t heads, uint32_t head_dim) {
+ size_t index = (size_t)blockIdx.x * blockDim.x + threadIdx.x;
+ if (index >= count) return;
+ uint32_t width = heads * head_dim;
+ uint32_t column = (uint32_t)(index % width);
+ size_t row = index / width;
+ uint32_t time = (uint32_t)(row % length);
+ uint32_t batch_index = (uint32_t)(row / length);
+ uint32_t head = column / head_dim;
+ uint32_t dimension = column % head_dim;
+ size_t output = (((size_t)batch_index * heads + head) * length + time) *
+ head_dim + dimension;
+ size_t base = row * width * 3;
+ query[output] = qkv[base + column] + q_bias[column];
+ key[output] = qkv[base + width + column] + k_bias[column];
+ value[output] = qkv[base + width * 2 + column] + v_bias[column];
+}
+
+int h3_gpu_audio_qkv_split_f32(h3_gpu *gpu, h3_gpu_tensor *query,
+ h3_gpu_tensor *key, h3_gpu_tensor *value, const h3_gpu_tensor *qkv,
+ const h3_gpu_tensor *q_bias, const h3_gpu_tensor *k_bias,
+ const h3_gpu_tensor *v_bias, uint32_t batch, uint32_t length,
+ uint32_t heads, uint32_t head_dim) {
+ size_t width, count;
+ if (!batch || !length || !h3_mul_size(heads, head_dim, &width) || !width ||
+ !h3_mul_size((size_t)batch * length, width, &count) ||
+ count > SIZE_MAX / 3 || !h3_tensor_is(qkv, gpu, H3_GPU_F32, count * 3) ||
+ !h3_tensor_is(q_bias, gpu, H3_GPU_F32, width) ||
+ !h3_tensor_is(k_bias, gpu, H3_GPU_F32, width) ||
+ !h3_tensor_is(v_bias, gpu, H3_GPU_F32, width) ||
+ !h3_tensor_is(query, gpu, H3_GPU_F32, count) ||
+ !h3_tensor_is(key, gpu, H3_GPU_F32, count) ||
+ !h3_tensor_is(value, gpu, H3_GPU_F32, count))
+ return h3_set_error(gpu, "invalid audio QKV tensors or shape");
+ h3_audio_qkv_split_kernel<<<(count + 255) / 256, 256, 0, gpu->stream>>>(
+ (float *)query->data, (float *)key->data, (float *)value->data,
+ (const float *)qkv->data, (const float *)q_bias->data,
+ (const float *)k_bias->data, (const float *)v_bias->data, count,
+ length, heads, head_dim);
+ return h3_launch_ok(gpu, "audio QKV split");
+}
+
+__global__ static void h3_audio_pool_kernel(float *output,
+ const float *attended, size_t count, uint32_t heads,
+ uint32_t head_dim, uint32_t output_dim) {
+ size_t index = (size_t)blockIdx.x * blockDim.x + threadIdx.x;
+ if (index >= count) return;
+ uint32_t column = (uint32_t)(index % output_dim);
+ size_t row = index / output_dim;
+ uint32_t pool = head_dim / output_dim;
+ float sum = 0.0f;
+ for (uint32_t head = 0; head < heads; head++) {
+ size_t base = (row * heads + head) * head_dim + column * pool;
+ for (uint32_t item = 0; item < pool; item++) sum += attended[base + item];
+ }
+ output[index] = sum / (float)(heads * pool);
+}
+
+int h3_gpu_audio_attention_pool_f32(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *attended, uint32_t batch, uint32_t length,
+ uint32_t heads, uint32_t head_dim, uint32_t output_dim) {
+ size_t input_elements, output_elements;
+ if (!batch || !length || !heads || !head_dim || !output_dim ||
+ head_dim % output_dim ||
+ !h3_mul_size((size_t)batch * length * heads, head_dim,
+ &input_elements) ||
+ !h3_mul_size((size_t)batch * length, output_dim, &output_elements) ||
+ !h3_tensor_is(attended, gpu, H3_GPU_F32, input_elements) ||
+ !h3_tensor_is(output, gpu, H3_GPU_F32, output_elements))
+ return h3_set_error(gpu, "invalid audio attention pool tensors");
+ h3_audio_pool_kernel<<<(output_elements + 255) / 256, 256, 0,
+ gpu->stream>>>((float *)output->data, (const float *)attended->data,
+ output_elements, heads, head_dim, output_dim);
+ return h3_launch_ok(gpu, "audio attention pool");
+}
+
+__global__ static void h3_alias_free_snake_kernel(float *output,
+ const float *input, const float *alpha_log, const float *beta_log,
+ const float *upsample_filter, const float *downsample_filter,
+ uint32_t batch, uint32_t length, uint32_t channels, size_t elements) {
+ size_t index = (size_t)blockIdx.x * blockDim.x + threadIdx.x;
+ if (index >= elements) return;
+ uint32_t channel = (uint32_t)(index % channels);
+ size_t row = index / channels;
+ uint32_t time = (uint32_t)(row % length);
+ uint32_t batch_index = (uint32_t)(row / length);
+ float alpha = expf(alpha_log[channel]);
+ float beta = expf(beta_log[channel]);
+ float result = 0.0f;
+ for (int down_tap = 0; down_tap < 12; down_tap++) {
+ int up_time = max(0, min((int)length * 2 - 1,
+ (int)time * 2 + down_tap - 5));
+ int raw_time = up_time + 15;
+ float upsampled = 0.0f;
+ for (int up_tap = 0; up_tap < 12; up_tap++) {
+ int numerator = raw_time - up_tap;
+ if (numerator < 0 || (numerator & 1)) continue;
+ int source_time = max(0, min((int)length - 1,
+ numerator / 2 - 5));
+ size_t source = ((size_t)batch_index * length + source_time) *
+ channels + channel;
+ upsampled = fmaf(input[source], 2.0f * upsample_filter[up_tap],
+ upsampled);
+ }
+ float sine = sinf(alpha * upsampled);
+ float activated = upsampled + sine * sine / (beta + 1e-9f);
+ result = fmaf(activated, downsample_filter[down_tap], result);
+ }
+ output[((size_t)batch_index * length + time) * channels + channel] = result;
+}
+
+int h3_gpu_alias_free_snake_f32(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *input, const h3_gpu_tensor *alpha_log,
+ const h3_gpu_tensor *beta_log,
+ const h3_gpu_tensor *upsample_filter,
+ const h3_gpu_tensor *downsample_filter, uint32_t batch,
+ uint32_t length, uint32_t channels) {
+ size_t elements;
+ if (!batch || !length || !channels ||
+ !h3_mul_size((size_t)batch * length, channels, &elements) ||
+ !h3_tensor_is(input, gpu, H3_GPU_F32, elements) ||
+ !h3_tensor_is(output, gpu, H3_GPU_F32, elements) ||
+ !h3_tensor_is(alpha_log, gpu, H3_GPU_F32, channels) ||
+ !h3_tensor_is(beta_log, gpu, H3_GPU_F32, channels) ||
+ !h3_tensor_is(upsample_filter, gpu, H3_GPU_F32, 12) ||
+ !h3_tensor_is(downsample_filter, gpu, H3_GPU_F32, 12))
+ return h3_set_error(gpu, "invalid alias-free Snake tensors");
+ h3_alias_free_snake_kernel<<<(elements + 255) / 256, 256, 0, gpu->stream>>>(
+ (float *)output->data, (const float *)input->data,
+ (const float *)alpha_log->data, (const float *)beta_log->data,
+ (const float *)upsample_filter->data,
+ (const float *)downsample_filter->data, batch, length, channels,
+ elements);
+ return h3_launch_ok(gpu, "alias-free Snake");
+}
+
+__device__ static int h3_reflect(int coordinate, int length) {
+ if (coordinate < 0) return -coordinate;
+ if (coordinate >= length) return 2 * length - coordinate - 2;
+ return coordinate;
+}
+
+__global__ static void h3_vae_pad_kernel(float *output, const float *input,
+ uint32_t batch, uint32_t depth, uint32_t height, uint32_t width,
+ uint32_t channels, uint32_t depth_front, uint32_t height_before,
+ uint32_t height_after, uint32_t width_before, uint32_t width_after) {
+ uint32_t channel = blockIdx.x * blockDim.x + threadIdx.x;
+ uint32_t output_x = blockIdx.y;
+ uint32_t plane = blockIdx.z;
+ uint32_t output_depth = depth + depth_front;
+ uint32_t output_height = height + height_before + height_after;
+ uint32_t output_width = width + width_before + width_after;
+ if (channel >= channels || output_x >= output_width ||
+ plane >= batch * output_depth * output_height) return;
+ uint32_t output_y = plane % output_height;
+ uint32_t temporal = plane / output_height;
+ uint32_t output_t = temporal % output_depth;
+ uint32_t batch_index = temporal / output_depth;
+ size_t destination = ((((size_t)batch_index * output_depth + output_t) *
+ output_height + output_y) * output_width + output_x) * channels +
+ channel;
+ if (output_t < depth_front) { output[destination] = 0.0f; return; }
+ int source_y = h3_reflect((int)output_y - (int)height_before, (int)height);
+ int source_x = h3_reflect((int)output_x - (int)width_before, (int)width);
+ uint32_t source_t = output_t - depth_front;
+ size_t source = ((((size_t)batch_index * depth + source_t) * height +
+ (uint32_t)source_y) * width + (uint32_t)source_x) * channels + channel;
+ output[destination] = input[source];
+}
+
+int h3_gpu_vae_encoder_pad_f32(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *input, uint32_t batch, uint32_t depth,
+ uint32_t height, uint32_t width, uint32_t channels,
+ uint32_t depth_front, uint32_t height_before, uint32_t height_after,
+ uint32_t width_before, uint32_t width_after) {
+ if (!batch || !depth || height < 2 || width < 2 || !channels ||
+ height_before >= height || height_after >= height ||
+ width_before >= width || width_after >= width)
+ return h3_set_error(gpu, "invalid VAE padding shape");
+ size_t input_elements, output_elements;
+ uint64_t output_depth = (uint64_t)depth + depth_front;
+ uint64_t output_height = (uint64_t)height + height_before + height_after;
+ uint64_t output_width = (uint64_t)width + width_before + width_after;
+ if (output_depth > UINT32_MAX || output_height > UINT32_MAX ||
+ output_width > UINT32_MAX ||
+ !h3_mul_size((size_t)batch * depth * height * width, channels,
+ &input_elements) ||
+ !h3_mul_size((size_t)batch * (size_t)output_depth *
+ (size_t)output_height * (size_t)output_width, channels,
+ &output_elements) ||
+ !h3_tensor_is(input, gpu, H3_GPU_F32, input_elements) ||
+ !h3_tensor_is(output, gpu, H3_GPU_F32, output_elements))
+ return h3_set_error(gpu, "invalid VAE padding tensors");
+ dim3 grid((channels + 127) / 128, (uint32_t)output_width,
+ (uint32_t)((uint64_t)batch * output_depth * output_height));
+ h3_vae_pad_kernel<<stream>>>(
+ (float *)output->data, (const float *)input->data, batch, depth,
+ height, width, channels, depth_front, height_before, height_after,
+ width_before, width_after);
+ return h3_launch_ok(gpu, "VAE encoder padding");
+}
+
+__global__ static void h3_conv3d_kernel(float *output, const float *input,
+ const float *weight, const float *bias, uint32_t depth,
+ uint32_t height, uint32_t width, uint32_t input_channels,
+ uint32_t output_channels, uint32_t kernel_depth,
+ uint32_t kernel_height, uint32_t kernel_width, uint32_t stride_depth,
+ uint32_t stride_height, uint32_t stride_width, uint32_t output_depth,
+ uint32_t output_height, uint32_t output_width, size_t output_elements,
+ int has_bias) {
+ size_t index = (size_t)blockIdx.x * blockDim.x + threadIdx.x;
+ if (index >= output_elements) return;
+ uint32_t output_channel = (uint32_t)(index % output_channels);
+ size_t row = index / output_channels;
+ uint32_t spatial_count = output_depth * output_height * output_width;
+ uint32_t spatial = (uint32_t)(row % spatial_count);
+ uint32_t batch_index = (uint32_t)(row / spatial_count);
+ uint32_t output_x = spatial % output_width;
+ uint32_t output_y = (spatial / output_width) % output_height;
+ uint32_t output_t = spatial / (output_width * output_height);
+ if (output_t >= output_depth) return;
+ float sum = has_bias ? bias[output_channel] : 0.0f;
+ for (uint32_t input_channel = 0; input_channel < input_channels;
+ input_channel++)
+ for (uint32_t kt = 0; kt < kernel_depth; kt++)
+ for (uint32_t ky = 0; ky < kernel_height; ky++)
+ for (uint32_t kx = 0; kx < kernel_width; kx++) {
+ uint32_t it = output_t * stride_depth + kt;
+ uint32_t iy = output_y * stride_height + ky;
+ uint32_t ix = output_x * stride_width + kx;
+ size_t input_index = ((((size_t)batch_index * depth + it) *
+ height + iy) * width + ix) * input_channels + input_channel;
+ size_t weight_index = (((((size_t)output_channel *
+ input_channels + input_channel) * kernel_depth + kt) *
+ kernel_height + ky) * kernel_width + kx);
+ sum = fmaf(input[input_index], weight[weight_index], sum);
+ }
+ size_t destination = ((((size_t)batch_index * output_depth + output_t) *
+ output_height + output_y) * output_width + output_x) * output_channels +
+ output_channel;
+ output[destination] = sum;
+}
+
+int h3_gpu_conv3d_f32(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *input, const h3_gpu_tensor *weight,
+ const h3_gpu_tensor *bias, uint32_t batch, uint32_t depth,
+ uint32_t height, uint32_t width, uint32_t input_channels,
+ uint32_t output_channels, uint32_t kernel_depth,
+ uint32_t kernel_height, uint32_t kernel_width, uint32_t stride_depth,
+ uint32_t stride_height, uint32_t stride_width) {
+ if (!batch || !depth || !height || !width || !input_channels ||
+ !output_channels || !kernel_depth || !kernel_height || !kernel_width ||
+ !stride_depth || !stride_height || !stride_width || depth < kernel_depth ||
+ height < kernel_height || width < kernel_width)
+ return h3_set_error(gpu, "invalid Conv3d shape");
+ uint32_t od = (depth - kernel_depth) / stride_depth + 1;
+ uint32_t oh = (height - kernel_height) / stride_height + 1;
+ uint32_t ow = (width - kernel_width) / stride_width + 1;
+ size_t input_elements, weight_elements, output_elements;
+ if (!h3_mul_size((size_t)batch * depth * height * width, input_channels,
+ &input_elements) ||
+ !h3_mul_size((size_t)output_channels * input_channels * kernel_depth *
+ kernel_height, kernel_width, &weight_elements) ||
+ !h3_mul_size((size_t)batch * od * oh * ow, output_channels,
+ &output_elements) ||
+ !h3_tensor_is(input, gpu, H3_GPU_F32, input_elements) ||
+ !h3_tensor_is(weight, gpu, H3_GPU_F32, weight_elements) ||
+ !h3_tensor_is(output, gpu, H3_GPU_F32, output_elements) ||
+ (bias && !h3_tensor_is(bias, gpu, H3_GPU_F32, output_channels)))
+ return h3_set_error(gpu, "invalid Conv3d tensors");
+ h3_conv3d_kernel<<<(output_elements + 255) / 256, 256, 0, gpu->stream>>>(
+ (float *)output->data, (const float *)input->data,
+ (const float *)weight->data,
+ bias ? (const float *)bias->data : (const float *)input->data,
+ depth, height, width, input_channels, output_channels, kernel_depth,
+ kernel_height, kernel_width, stride_depth, stride_height, stride_width,
+ od, oh, ow, output_elements, bias != NULL);
+ int ok = h3_launch_ok(gpu, "Conv3d");
+ if (ok) gpu->stats.mps_conv_dispatches++;
+ return ok;
+}
+
+__global__ static void h3_group_norm_silu_kernel(float *output,
+ const float *input, const float *weight, const float *bias,
+ uint32_t depth, uint32_t height, uint32_t width, uint32_t channels,
+ uint32_t groups, uint32_t rows, float epsilon) {
+ uint32_t row = blockIdx.x * blockDim.x + threadIdx.x;
+ if (row >= rows) return;
+ uint32_t channels_per_group = channels / groups;
+ uint32_t group = row % groups;
+ uint32_t temporal = row / groups;
+ uint32_t elements = height * width * channels_per_group;
+ float mean = 0.0f;
+ for (uint32_t index = 0; index < elements; index++) {
+ uint32_t spatial = index / channels_per_group;
+ uint32_t channel = group * channels_per_group + index % channels_per_group;
+ mean += input[((size_t)temporal * height * width + spatial) * channels +
+ channel];
+ }
+ mean /= (float)elements;
+ float variance = 0.0f;
+ for (uint32_t index = 0; index < elements; index++) {
+ uint32_t spatial = index / channels_per_group;
+ uint32_t channel = group * channels_per_group + index % channels_per_group;
+ float centered = input[((size_t)temporal * height * width + spatial) *
+ channels + channel] - mean;
+ variance = fmaf(centered, centered, variance);
+ }
+ float inverse = rsqrtf(variance / (float)elements + epsilon);
+ for (uint32_t index = 0; index < elements; index++) {
+ uint32_t spatial = index / channels_per_group;
+ uint32_t channel = group * channels_per_group + index % channels_per_group;
+ size_t destination = ((size_t)temporal * height * width + spatial) *
+ channels + channel;
+ float value = (input[destination] - mean) * inverse * weight[channel] +
+ bias[channel];
+ output[destination] = value / (1.0f + expf(-value));
+ }
+ (void)depth;
+}
+
+int h3_gpu_vae_encoder_group_norm_silu_f32(h3_gpu *gpu,
+ h3_gpu_tensor *output, const h3_gpu_tensor *input,
+ const h3_gpu_tensor *weight, const h3_gpu_tensor *bias,
+ uint32_t batch, uint32_t depth, uint32_t height, uint32_t width,
+ uint32_t channels, uint32_t groups, float epsilon) {
+ size_t elements;
+ uint64_t rows64 = (uint64_t)batch * depth * groups;
+ if (!batch || !depth || !height || !width || !channels || !groups ||
+ channels % groups || !(epsilon > 0.0f) || rows64 > UINT32_MAX ||
+ !h3_mul_size((size_t)batch * depth * height * width, channels,
+ &elements) ||
+ !h3_tensor_is(input, gpu, H3_GPU_F32, elements) ||
+ !h3_tensor_is(output, gpu, H3_GPU_F32, elements) ||
+ !h3_tensor_is(weight, gpu, H3_GPU_F32, channels) ||
+ !h3_tensor_is(bias, gpu, H3_GPU_F32, channels))
+ return h3_set_error(gpu, "invalid VAE group norm tensors or shape");
+ uint32_t rows = (uint32_t)rows64;
+ h3_group_norm_silu_kernel<<<(rows + 127) / 128, 128, 0, gpu->stream>>>(
+ (float *)output->data, (const float *)input->data,
+ (const float *)weight->data, (const float *)bias->data, depth, height,
+ width, channels, groups, rows, epsilon);
+ return h3_launch_ok(gpu, "VAE group norm SiLU");
+}
+
+static int h3_mlp_bf16_impl(h3_gpu *gpu, h3_gpu_tensor *output,
+ h3_gpu_tensor *activated, const h3_gpu_tensor *input,
+ const h3_gpu_tensor *fc1_weight, const h3_gpu_tensor *fc2_weight,
+ uint32_t rows, uint32_t input_dim, uint32_t hidden_dim,
+ uint32_t output_dim) {
+ size_t fused_elements, activated_elements, output_elements;
+ if (!rows || !input_dim || !hidden_dim || !output_dim ||
+ hidden_dim > UINT32_MAX / 2 ||
+ !h3_mul_size(rows, (size_t)hidden_dim * 2, &fused_elements) ||
+ !h3_mul_size(rows, hidden_dim, &activated_elements) ||
+ !h3_mul_size(rows, output_dim, &output_elements) ||
+ !h3_tensor_is(input, gpu, H3_GPU_BF16,
+ (size_t)rows * input_dim) ||
+ !h3_tensor_is(fc1_weight, gpu, H3_GPU_BF16,
+ (size_t)hidden_dim * 2 * input_dim) ||
+ !h3_tensor_is(fc2_weight, gpu, H3_GPU_BF16,
+ (size_t)output_dim * hidden_dim) ||
+ !h3_tensor_is(output, gpu, H3_GPU_BF16, output_elements) ||
+ (activated && !h3_tensor_is(activated, gpu, H3_GPU_BF16,
+ activated_elements)))
+ return h3_set_error(gpu, "invalid BF16 MLP tensors or shape");
+ __nv_bfloat16 *fused_data = NULL;
+ __nv_bfloat16 *activated_data = activated ?
+ (__nv_bfloat16 *)activated->data : NULL;
+ cudaError_t status = cudaMallocAsync((void **)&fused_data,
+ fused_elements * sizeof(*fused_data), gpu->stream);
+ if (status == cudaSuccess && !activated_data)
+ status = cudaMallocAsync((void **)&activated_data,
+ activated_elements * sizeof(*activated_data), gpu->stream);
+ if (!h3_cuda_ok(gpu, status, "BF16 MLP temporary allocation")) {
+ if (fused_data) (void)cudaFreeAsync(fused_data, gpu->stream);
+ return 0;
+ }
+ h3_gpu_tensor fused = {gpu, fused_data, fused_elements,
+ fused_elements * sizeof(*fused_data), H3_GPU_BF16};
+ h3_gpu_tensor activation = {gpu, activated_data, activated_elements,
+ activated_elements * sizeof(*activated_data), H3_GPU_BF16};
+ int ok = h3_gpu_linear_bf16(gpu, &fused, input, fc1_weight, NULL, rows,
+ input_dim, hidden_dim * 2) &&
+ h3_gpu_swiglu_bf16(gpu, &activation, &fused, rows, hidden_dim) &&
+ h3_gpu_linear_bf16(gpu, output, &activation, fc2_weight, NULL,
+ rows, hidden_dim, output_dim);
+ status = cudaFreeAsync(fused_data, gpu->stream);
+ if (ok && !activated)
+ status = cudaFreeAsync(activated_data, gpu->stream);
+ if (ok) ok = h3_cuda_ok(gpu, status, "BF16 MLP temporary free");
+ return ok;
+}
+
+int h3_gpu_mlp_bf16(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *input, const h3_gpu_tensor *fc1_weight,
+ const h3_gpu_tensor *fc2_weight, uint32_t rows, uint32_t input_dim,
+ uint32_t hidden_dim, uint32_t output_dim) {
+ return h3_mlp_bf16_impl(gpu, output, NULL, input, fc1_weight, fc2_weight,
+ rows, input_dim, hidden_dim, output_dim);
+}
+
+int h3_gpu_mlp_nax_bf16(h3_gpu *gpu, h3_gpu_tensor *output,
+ h3_gpu_tensor *activated, const h3_gpu_tensor *input,
+ const h3_gpu_tensor *fc1_weight, const h3_gpu_tensor *fc2_weight,
+ uint32_t rows, uint32_t input_dim, uint32_t hidden_dim,
+ uint32_t output_dim) {
+ return h3_mlp_bf16_impl(gpu, output, activated, input, fc1_weight,
+ fc2_weight, rows, input_dim, hidden_dim,
+ output_dim);
+}
+
+enum h3_unary_kind { H3_SILU, H3_GELU_EXACT, H3_GELU_APPROX, H3_CLIP };
+
+__global__ static void h3_unary_f32_kernel(float *output, const float *input,
+ size_t count, int kind,
+ float minimum, float maximum) {
+ size_t index = (size_t)blockIdx.x * blockDim.x + threadIdx.x;
+ if (index >= count) return;
+ float value = input[index];
+ if (kind == H3_SILU) value /= 1.0f + expf(-value);
+ else if (kind == H3_CLIP) value = fminf(maximum, fmaxf(minimum, value));
+ output[index] = value;
+}
+
+__global__ static void h3_unary_bf16_kernel(__nv_bfloat16 *output,
+ const __nv_bfloat16 *input,
+ size_t count, int kind) {
+ size_t index = (size_t)blockIdx.x * blockDim.x + threadIdx.x;
+ if (index >= count) return;
+ float value = __bfloat162float(input[index]);
+ if (kind == H3_SILU) value /= 1.0f + expf(-value);
+ else if (kind == H3_GELU_APPROX) {
+ float inner = 0.7978845608028654f *
+ (value + 0.044715f * value * value * value);
+ value = inner <= -10.0f ? 0.0f : inner >= 10.0f ? value :
+ 0.5f * value * (1.0f + tanhf(inner));
+ } else {
+ value = value <= -10.0f ? 0.0f : value >= 10.0f ? value :
+ 0.5f * value * (1.0f + erff(value * 0.7071067811865475f));
+ }
+ output[index] = __float2bfloat16(value);
+}
+
+int h3_gpu_silu_f32(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *input, uint32_t elements) {
+ if (!h3_tensor_is(output, gpu, H3_GPU_F32, elements) ||
+ !h3_tensor_is(input, gpu, H3_GPU_F32, elements))
+ return h3_set_error(gpu, "invalid F32 SiLU tensors");
+ if (elements) h3_unary_f32_kernel<<<(elements + 255) / 256, 256, 0,
+ gpu->stream>>>((float *)output->data, (const float *)input->data,
+ elements, H3_SILU, 0.0f, 0.0f);
+ return h3_launch_ok(gpu, "F32 SiLU");
+}
+
+int h3_gpu_cast_f32_to_bf16(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *input, uint32_t elements) {
+ if (!h3_tensor_is(output, gpu, H3_GPU_BF16, elements) ||
+ !h3_tensor_is(input, gpu, H3_GPU_F32, elements))
+ return h3_set_error(gpu, "invalid F32-to-BF16 tensors");
+ if (elements) h3_f32_to_bf16_kernel<<<(elements + 255) / 256, 256, 0,
+ gpu->stream>>>((__nv_bfloat16 *)output->data,
+ (const float *)input->data, elements);
+ return h3_launch_ok(gpu, "F32-to-BF16 cast");
+}
+
+int h3_gpu_cast_bf16_to_f32(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *input, uint32_t elements) {
+ if (!h3_tensor_is(output, gpu, H3_GPU_F32, elements) ||
+ !h3_tensor_is(input, gpu, H3_GPU_BF16, elements))
+ return h3_set_error(gpu, "invalid BF16-to-F32 tensors");
+ if (elements) h3_bf16_to_f32_kernel<<<(elements + 255) / 256, 256, 0,
+ gpu->stream>>>((float *)output->data,
+ (const __nv_bfloat16 *)input->data, elements);
+ return h3_launch_ok(gpu, "BF16-to-F32 cast");
+}
+
+int h3_gpu_clip_f32(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *input, uint32_t elements,
+ float minimum, float maximum) {
+ if (!h3_tensor_is(output, gpu, H3_GPU_F32, elements) ||
+ !h3_tensor_is(input, gpu, H3_GPU_F32, elements) || minimum > maximum)
+ return h3_set_error(gpu, "invalid F32 clip arguments");
+ if (elements) h3_unary_f32_kernel<<<(elements + 255) / 256, 256, 0,
+ gpu->stream>>>((float *)output->data, (const float *)input->data,
+ elements, H3_CLIP, minimum, maximum);
+ return h3_launch_ok(gpu, "F32 clip");
+}
+
+int h3_gpu_silu_bf16(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *input, uint32_t elements) {
+ if (!h3_tensor_is(output, gpu, H3_GPU_BF16, elements) ||
+ !h3_tensor_is(input, gpu, H3_GPU_BF16, elements))
+ return h3_set_error(gpu, "invalid BF16 SiLU tensors");
+ if (elements) h3_unary_bf16_kernel<<<(elements + 255) / 256, 256, 0,
+ gpu->stream>>>((__nv_bfloat16 *)output->data,
+ (const __nv_bfloat16 *)input->data, elements, H3_SILU);
+ return h3_launch_ok(gpu, "BF16 SiLU");
+}
+
+int h3_gpu_gelu_bf16(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *input, uint32_t elements,
+ int approximate) {
+ if (!h3_tensor_is(output, gpu, H3_GPU_BF16, elements) ||
+ !h3_tensor_is(input, gpu, H3_GPU_BF16, elements))
+ return h3_set_error(gpu, "invalid BF16 GELU tensors");
+ if (elements) h3_unary_bf16_kernel<<<(elements + 255) / 256, 256, 0,
+ gpu->stream>>>((__nv_bfloat16 *)output->data,
+ (const __nv_bfloat16 *)input->data, elements,
+ approximate ? H3_GELU_APPROX : H3_GELU_EXACT);
+ return h3_launch_ok(gpu, "BF16 GELU");
+}
+
+enum h3_binary_kind { H3_ADD, H3_SUB, H3_SILU_MUL };
+
+__global__ static void h3_binary_bf16_kernel(__nv_bfloat16 *output,
+ const __nv_bfloat16 *left, const __nv_bfloat16 *right,
+ size_t count, int kind) {
+ size_t index = (size_t)blockIdx.x * blockDim.x + threadIdx.x;
+ if (index >= count) return;
+ float a = __bfloat162float(left[index]);
+ float b = __bfloat162float(right[index]);
+ float value = kind == H3_ADD ? a + b : kind == H3_SUB ? a - b :
+ a / (1.0f + expf(-a)) * b;
+ output[index] = __float2bfloat16(value);
+}
+
+static int h3_binary_bf16(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *left, const h3_gpu_tensor *right,
+ uint32_t elements, int kind, const char *label) {
+ if (!h3_tensor_is(output, gpu, H3_GPU_BF16, elements) ||
+ !h3_tensor_is(left, gpu, H3_GPU_BF16, elements) ||
+ !h3_tensor_is(right, gpu, H3_GPU_BF16, elements))
+ return h3_set_error(gpu, "invalid %s tensors", label);
+ if (elements) h3_binary_bf16_kernel<<<(elements + 255) / 256, 256, 0,
+ gpu->stream>>>((__nv_bfloat16 *)output->data,
+ (const __nv_bfloat16 *)left->data,
+ (const __nv_bfloat16 *)right->data, elements, kind);
+ return h3_launch_ok(gpu, label);
+}
+
+int h3_gpu_add_bf16(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *left, const h3_gpu_tensor *right,
+ uint32_t elements) {
+ return h3_binary_bf16(gpu, output, left, right, elements, H3_ADD, "BF16 add");
+}
+int h3_gpu_sub_bf16(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *left, const h3_gpu_tensor *right,
+ uint32_t elements) {
+ return h3_binary_bf16(gpu, output, left, right, elements, H3_SUB, "BF16 subtract");
+}
+int h3_gpu_silu_mul_bf16(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *gate,
+ const h3_gpu_tensor *up, uint32_t elements) {
+ return h3_binary_bf16(gpu, output, gate, up, elements, H3_SILU_MUL, "BF16 SiLU multiply");
+}
+
+__global__ static void h3_add_scaled_kernel(float *output, const float *left,
+ const float *right, size_t count, float left_scale,
+ float right_scale) {
+ size_t index = (size_t)blockIdx.x * blockDim.x + threadIdx.x;
+ if (index < count)
+ output[index] = left[index] * left_scale + right[index] * right_scale;
+}
+
+int h3_gpu_add_scaled_f32(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *left,
+ const h3_gpu_tensor *right, float left_scale,
+ float right_scale, uint32_t elements) {
+ if (!h3_tensor_is(output, gpu, H3_GPU_F32, elements) ||
+ !h3_tensor_is(left, gpu, H3_GPU_F32, elements) ||
+ !h3_tensor_is(right, gpu, H3_GPU_F32, elements))
+ return h3_set_error(gpu, "invalid F32 scaled-add tensors");
+ if (elements) h3_add_scaled_kernel<<<(elements + 255) / 256, 256, 0,
+ gpu->stream>>>((float *)output->data, (const float *)left->data,
+ (const float *)right->data, elements, left_scale, right_scale);
+ return h3_launch_ok(gpu, "F32 scaled add");
+}
+
+__global__ static void h3_euler_kernel(float *sample, size_t sample_offset,
+ const __nv_bfloat16 *last, const __nv_bfloat16 *previous,
+ size_t count, float delta, float ratio) {
+ size_t index = (size_t)blockIdx.x * blockDim.x + threadIdx.x;
+ if (index >= count) return;
+ float last_value = __bfloat162float(last[index]);
+ float velocity = fmaf(ratio,
+ last_value - __bfloat162float(previous[index]), last_value);
+ sample[sample_offset + index] =
+ fmaf(delta, velocity, sample[sample_offset + index]);
+}
+
+int h3_gpu_euler_bf16(h3_gpu *gpu, h3_gpu_tensor *sample,
+ size_t sample_offset, const h3_gpu_tensor *last,
+ const h3_gpu_tensor *previous, uint32_t elements,
+ float delta, float ratio) {
+ if (!h3_tensor_is(sample, gpu, H3_GPU_F32, sample_offset + elements) ||
+ !h3_tensor_is(last, gpu, H3_GPU_BF16, elements) ||
+ !h3_tensor_is(previous, gpu, H3_GPU_BF16, elements))
+ return h3_set_error(gpu, "invalid Euler tensors");
+ if (elements) h3_euler_kernel<<<(elements + 255) / 256, 256, 0,
+ gpu->stream>>>((float *)sample->data, sample_offset,
+ (const __nv_bfloat16 *)last->data,
+ (const __nv_bfloat16 *)previous->data, elements, delta, ratio);
+ return h3_launch_ok(gpu, "BF16 Euler update");
+}
+
+__global__ static void h3_rms_norm_f32_kernel(float *output,
+ const float *input, const float *weight, uint32_t rows,
+ uint32_t width, float epsilon) {
+ uint32_t row = blockIdx.x;
+ if (row >= rows) return;
+ __shared__ float sums[256];
+ float sum = 0.0f;
+ for (uint32_t column = threadIdx.x; column < width; column += blockDim.x) {
+ float value = input[(size_t)row * width + column];
+ sum = fmaf(value, value, sum);
+ }
+ sums[threadIdx.x] = sum;
+ __syncthreads();
+ for (uint32_t stride = blockDim.x / 2; stride; stride /= 2) {
+ if (threadIdx.x < stride) sums[threadIdx.x] += sums[threadIdx.x + stride];
+ __syncthreads();
+ }
+ float inverse = rsqrtf(sums[0] / (float)width + epsilon);
+ for (uint32_t column = threadIdx.x; column < width; column += blockDim.x) {
+ size_t index = (size_t)row * width + column;
+ output[index] = input[index] * inverse * weight[column];
+ }
+}
+
+__global__ static void h3_rms_norm_bf16_kernel(__nv_bfloat16 *output,
+ const __nv_bfloat16 *input, const __nv_bfloat16 *weight,
+ uint32_t rows, uint32_t width, float epsilon) {
+ uint32_t row = blockIdx.x;
+ if (row >= rows) return;
+ __shared__ float sums[256];
+ float sum = 0.0f;
+ for (uint32_t column = threadIdx.x; column < width; column += blockDim.x) {
+ float value = __bfloat162float(input[(size_t)row * width + column]);
+ sum = fmaf(value, value, sum);
+ }
+ sums[threadIdx.x] = sum;
+ __syncthreads();
+ for (uint32_t stride = blockDim.x / 2; stride; stride /= 2) {
+ if (threadIdx.x < stride) sums[threadIdx.x] += sums[threadIdx.x + stride];
+ __syncthreads();
+ }
+ float inverse = rsqrtf(sums[0] / (float)width + epsilon);
+ for (uint32_t column = threadIdx.x; column < width; column += blockDim.x) {
+ size_t index = (size_t)row * width + column;
+ float value = __bfloat162float(input[index]) * inverse *
+ __bfloat162float(weight[column]);
+ output[index] = __float2bfloat16(value);
+ }
+}
+
+static int h3_matrix_elements(h3_gpu *gpu, uint32_t rows, uint32_t width,
+ size_t *elements) {
+ if (!rows || !width || (size_t)rows > SIZE_MAX / width)
+ return h3_set_error(gpu, "invalid matrix shape");
+ *elements = (size_t)rows * width;
+ return 1;
+}
+
+int h3_gpu_rms_norm_f32(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *input,
+ const h3_gpu_tensor *weight, uint32_t rows,
+ uint32_t width, float epsilon) {
+ size_t elements = 0;
+ if (!h3_matrix_elements(gpu, rows, width, &elements) || epsilon < 0.0f ||
+ !h3_tensor_is(output, gpu, H3_GPU_F32, elements) ||
+ !h3_tensor_is(input, gpu, H3_GPU_F32, elements) ||
+ !h3_tensor_is(weight, gpu, H3_GPU_F32, width))
+ return h3_set_error(gpu, "invalid F32 RMS norm arguments");
+ h3_rms_norm_f32_kernel<<stream>>>(
+ (float *)output->data, (const float *)input->data,
+ (const float *)weight->data, rows, width, epsilon);
+ return h3_launch_ok(gpu, "F32 RMS norm");
+}
+
+int h3_gpu_rms_norm_bf16(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *input,
+ const h3_gpu_tensor *weight, uint32_t rows,
+ uint32_t width, float epsilon) {
+ size_t elements = 0;
+ if (!h3_matrix_elements(gpu, rows, width, &elements) || epsilon < 0.0f ||
+ !h3_tensor_is(output, gpu, H3_GPU_BF16, elements) ||
+ !h3_tensor_is(input, gpu, H3_GPU_BF16, elements) ||
+ !h3_tensor_is(weight, gpu, H3_GPU_BF16, width))
+ return h3_set_error(gpu, "invalid BF16 RMS norm arguments");
+ h3_rms_norm_bf16_kernel<<stream>>>(
+ (__nv_bfloat16 *)output->data,
+ (const __nv_bfloat16 *)input->data,
+ (const __nv_bfloat16 *)weight->data, rows, width, epsilon);
+ return h3_launch_ok(gpu, "BF16 RMS norm");
+}
+
+__global__ static void h3_layer_norm_f32_kernel(float *output,
+ const float *input, const float *weight, const float *bias,
+ uint32_t rows, uint32_t width, float epsilon) {
+ uint32_t row = blockIdx.x;
+ if (row >= rows) return;
+ __shared__ float sums[256];
+ float sum = 0.0f;
+ for (uint32_t column = threadIdx.x; column < width; column += blockDim.x)
+ sum += input[(size_t)row * width + column];
+ sums[threadIdx.x] = sum;
+ __syncthreads();
+ for (uint32_t stride = blockDim.x / 2; stride; stride /= 2) {
+ if (threadIdx.x < stride) sums[threadIdx.x] += sums[threadIdx.x + stride];
+ __syncthreads();
+ }
+ float mean = sums[0] / (float)width;
+ sum = 0.0f;
+ for (uint32_t column = threadIdx.x; column < width; column += blockDim.x) {
+ float centered = input[(size_t)row * width + column] - mean;
+ sum = fmaf(centered, centered, sum);
+ }
+ sums[threadIdx.x] = sum;
+ __syncthreads();
+ for (uint32_t stride = blockDim.x / 2; stride; stride /= 2) {
+ if (threadIdx.x < stride) sums[threadIdx.x] += sums[threadIdx.x + stride];
+ __syncthreads();
+ }
+ float inverse = rsqrtf(sums[0] / (float)width + epsilon);
+ for (uint32_t column = threadIdx.x; column < width; column += blockDim.x) {
+ size_t index = (size_t)row * width + column;
+ output[index] = (input[index] - mean) * inverse * weight[column] + bias[column];
+ }
+}
+
+__global__ static void h3_layer_norm_bf16_kernel(__nv_bfloat16 *output,
+ const __nv_bfloat16 *input, const __nv_bfloat16 *weight,
+ const __nv_bfloat16 *bias, uint32_t rows, uint32_t width,
+ float epsilon) {
+ uint32_t row = blockIdx.x;
+ if (row >= rows) return;
+ __shared__ float sums[256];
+ float sum = 0.0f;
+ for (uint32_t column = threadIdx.x; column < width; column += blockDim.x)
+ sum += __bfloat162float(input[(size_t)row * width + column]);
+ sums[threadIdx.x] = sum;
+ __syncthreads();
+ for (uint32_t stride = blockDim.x / 2; stride; stride /= 2) {
+ if (threadIdx.x < stride) sums[threadIdx.x] += sums[threadIdx.x + stride];
+ __syncthreads();
+ }
+ float mean = sums[0] / (float)width;
+ sum = 0.0f;
+ for (uint32_t column = threadIdx.x; column < width; column += blockDim.x) {
+ float centered = __bfloat162float(input[(size_t)row * width + column]) - mean;
+ sum = fmaf(centered, centered, sum);
+ }
+ sums[threadIdx.x] = sum;
+ __syncthreads();
+ for (uint32_t stride = blockDim.x / 2; stride; stride /= 2) {
+ if (threadIdx.x < stride) sums[threadIdx.x] += sums[threadIdx.x + stride];
+ __syncthreads();
+ }
+ float inverse = rsqrtf(sums[0] / (float)width + epsilon);
+ for (uint32_t column = threadIdx.x; column < width; column += blockDim.x) {
+ size_t index = (size_t)row * width + column;
+ float value = (__bfloat162float(input[index]) - mean) * inverse *
+ __bfloat162float(weight[column]) +
+ __bfloat162float(bias[column]);
+ output[index] = __float2bfloat16(value);
+ }
+}
+
+int h3_gpu_layer_norm_f32(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *input, const h3_gpu_tensor *weight,
+ const h3_gpu_tensor *bias, uint32_t rows, uint32_t width,
+ float epsilon) {
+ size_t elements = 0;
+ if (!h3_matrix_elements(gpu, rows, width, &elements) || epsilon < 0.0f ||
+ !h3_tensor_is(output, gpu, H3_GPU_F32, elements) ||
+ !h3_tensor_is(input, gpu, H3_GPU_F32, elements) ||
+ !h3_tensor_is(weight, gpu, H3_GPU_F32, width) ||
+ !h3_tensor_is(bias, gpu, H3_GPU_F32, width))
+ return h3_set_error(gpu, "invalid F32 layer norm arguments");
+ h3_layer_norm_f32_kernel<<stream>>>(
+ (float *)output->data, (const float *)input->data,
+ (const float *)weight->data, (const float *)bias->data,
+ rows, width, epsilon);
+ return h3_launch_ok(gpu, "F32 layer norm");
+}
+
+int h3_gpu_layer_norm_bf16(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *input, const h3_gpu_tensor *weight,
+ const h3_gpu_tensor *bias, uint32_t rows, uint32_t width,
+ float epsilon) {
+ size_t elements = 0;
+ if (!h3_matrix_elements(gpu, rows, width, &elements) || epsilon < 0.0f ||
+ !h3_tensor_is(output, gpu, H3_GPU_BF16, elements) ||
+ !h3_tensor_is(input, gpu, H3_GPU_BF16, elements) ||
+ !h3_tensor_is(weight, gpu, H3_GPU_BF16, width) ||
+ !h3_tensor_is(bias, gpu, H3_GPU_BF16, width))
+ return h3_set_error(gpu, "invalid BF16 layer norm arguments");
+ h3_layer_norm_bf16_kernel<<stream>>>(
+ (__nv_bfloat16 *)output->data,
+ (const __nv_bfloat16 *)input->data,
+ (const __nv_bfloat16 *)weight->data,
+ (const __nv_bfloat16 *)bias->data, rows, width, epsilon);
+ return h3_launch_ok(gpu, "BF16 layer norm");
+}
+
+template
+__device__ static float h3_value(T value);
+template <>
+__device__ float h3_value(float value) { return value; }
+template <>
+__device__ float h3_value<__nv_bfloat16>(__nv_bfloat16 value) {
+ return __bfloat162float(value);
+}
+template
+__device__ static T h3_store(float value);
+template <>
+__device__ float h3_store(float value) { return value; }
+template <>
+__device__ __nv_bfloat16 h3_store<__nv_bfloat16>(float value) {
+ return __float2bfloat16(value);
+}
+
+template
+__global__ static void h3_adaln_kernel(T *output, const T *input,
+ const T *weight, const T *modulation, const uint32_t *row_map,
+ uint32_t rows, uint32_t width, uint32_t slots,
+ uint32_t shift_slot, uint32_t scale_slot, float epsilon,
+ size_t input_offset) {
+ __shared__ float inverse;
+ uint32_t row = blockIdx.x;
+ if (row >= rows) return;
+ const T *source = input + input_offset + (size_t)row * width;
+ if (threadIdx.x == 0) {
+ float square_sum = 0.0f;
+ for (uint32_t k = 0; k < width; ++k) {
+ float value = h3_value(source[k]);
+ square_sum = fmaf(value, value, square_sum);
+ }
+ inverse = rsqrtf(square_sum / (float)width + epsilon);
+ }
+ __syncthreads();
+ size_t base = (size_t)row_map[row] * slots * width;
+ for (uint32_t column = threadIdx.x; column < width;
+ column += blockDim.x) {
+ float normalized = h3_value(source[column]) * inverse *
+ h3_value(weight[column]);
+ float shift = h3_value(
+ modulation[base + (size_t)shift_slot * width + column]);
+ float scale = h3_value(
+ modulation[base + (size_t)scale_slot * width + column]);
+ output[(size_t)row * width + column] =
+ h3_store(normalized * (1.0f + scale) + shift);
+ }
+}
+
+template
+__global__ static void h3_gate_kernel(T *output, const T *residual,
+ const T *branch, const T *modulation, const uint32_t *row_map,
+ uint32_t rows, uint32_t width, uint32_t slots, uint32_t gate_slot) {
+ uint32_t column = blockIdx.x * blockDim.x + threadIdx.x;
+ uint32_t row = blockIdx.y;
+ if (row >= rows || column >= width) return;
+ size_t index = (size_t)row * width + column;
+ size_t base = (size_t)row_map[row] * slots * width;
+ float gate = h3_value(modulation[base + (size_t)gate_slot * width + column]);
+ output[index] = h3_store(h3_value(residual[index]) +
+ h3_value(branch[index]) * gate);
+}
+
+static int h3_adaln_validate(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *input, size_t input_offset,
+ const h3_gpu_tensor *weight, const h3_gpu_tensor *modulation,
+ const h3_gpu_tensor *row_map, uint32_t rows, uint32_t width,
+ uint32_t slots, uint32_t shift_slot, uint32_t scale_slot,
+ h3_gpu_dtype dtype, size_t *elements) {
+ if (!h3_matrix_elements(gpu, rows, width, elements) || !slots ||
+ shift_slot >= slots || scale_slot >= slots ||
+ input_offset > SIZE_MAX - *elements ||
+ !h3_tensor_is(output, gpu, dtype, *elements) ||
+ !h3_tensor_is(input, gpu, dtype, input_offset + *elements) ||
+ !h3_tensor_is(weight, gpu, dtype, width) ||
+ !modulation || modulation->gpu != gpu || modulation->dtype != dtype ||
+ !h3_tensor_is(row_map, gpu, H3_GPU_U32, rows))
+ return h3_set_error(gpu, "invalid AdaLN arguments");
+ return 1;
+}
+
+int h3_gpu_adaln_f32(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *input, const h3_gpu_tensor *norm_weight,
+ const h3_gpu_tensor *modulation, const h3_gpu_tensor *row_map,
+ uint32_t rows, uint32_t width, uint32_t slots, uint32_t shift_slot,
+ uint32_t scale_slot, float epsilon) {
+ size_t elements = 0;
+ if (epsilon < 0.0f || !h3_adaln_validate(gpu, output, input, 0,
+ norm_weight, modulation, row_map, rows, width, slots, shift_slot,
+ scale_slot, H3_GPU_F32, &elements)) return 0;
+ h3_adaln_kernel<<stream>>>(
+ (float *)output->data, (const float *)input->data,
+ (const float *)norm_weight->data, (const float *)modulation->data,
+ (const uint32_t *)row_map->data, rows, width, slots, shift_slot,
+ scale_slot, epsilon, 0);
+ return h3_launch_ok(gpu, "F32 AdaLN");
+}
+
+int h3_gpu_adaln_bf16_offset(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *input, size_t input_offset,
+ const h3_gpu_tensor *norm_weight, const h3_gpu_tensor *modulation,
+ const h3_gpu_tensor *row_map, uint32_t rows, uint32_t width,
+ uint32_t slots, uint32_t shift_slot, uint32_t scale_slot,
+ float epsilon) {
+ size_t elements = 0;
+ if (epsilon < 0.0f || !h3_adaln_validate(gpu, output, input, input_offset,
+ norm_weight, modulation, row_map, rows, width, slots, shift_slot,
+ scale_slot, H3_GPU_BF16, &elements)) return 0;
+ h3_adaln_kernel<__nv_bfloat16><<stream>>>(
+ (__nv_bfloat16 *)output->data,
+ (const __nv_bfloat16 *)input->data,
+ (const __nv_bfloat16 *)norm_weight->data,
+ (const __nv_bfloat16 *)modulation->data,
+ (const uint32_t *)row_map->data, rows, width, slots, shift_slot,
+ scale_slot, epsilon, input_offset);
+ return h3_launch_ok(gpu, "BF16 AdaLN");
+}
+
+int h3_gpu_adaln_bf16(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *input, const h3_gpu_tensor *norm_weight,
+ const h3_gpu_tensor *modulation, const h3_gpu_tensor *row_map,
+ uint32_t rows, uint32_t width, uint32_t slots, uint32_t shift_slot,
+ uint32_t scale_slot, float epsilon) {
+ return h3_gpu_adaln_bf16_offset(gpu, output, input, 0, norm_weight,
+ modulation, row_map, rows, width, slots, shift_slot, scale_slot,
+ epsilon);
+}
+
+static int h3_gate_dispatch(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *residual, const h3_gpu_tensor *branch,
+ const h3_gpu_tensor *modulation, const h3_gpu_tensor *row_map,
+ uint32_t rows, uint32_t width, uint32_t slots, uint32_t gate_slot,
+ h3_gpu_dtype dtype) {
+ size_t elements = 0;
+ if (!h3_matrix_elements(gpu, rows, width, &elements) || !slots ||
+ gate_slot >= slots || !h3_tensor_is(output, gpu, dtype, elements) ||
+ !h3_tensor_is(residual, gpu, dtype, elements) ||
+ !h3_tensor_is(branch, gpu, dtype, elements) ||
+ !modulation || modulation->gpu != gpu || modulation->dtype != dtype ||
+ !h3_tensor_is(row_map, gpu, H3_GPU_U32, rows))
+ return h3_set_error(gpu, "invalid gate arguments");
+ dim3 grid((width + 255) / 256, rows);
+ if (dtype == H3_GPU_F32)
+ h3_gate_kernel<<stream>>>(
+ (float *)output->data, (const float *)residual->data,
+ (const float *)branch->data, (const float *)modulation->data,
+ (const uint32_t *)row_map->data, rows, width, slots, gate_slot);
+ else
+ h3_gate_kernel<__nv_bfloat16><<stream>>>(
+ (__nv_bfloat16 *)output->data,
+ (const __nv_bfloat16 *)residual->data,
+ (const __nv_bfloat16 *)branch->data,
+ (const __nv_bfloat16 *)modulation->data,
+ (const uint32_t *)row_map->data, rows, width, slots, gate_slot);
+ return h3_launch_ok(gpu, dtype == H3_GPU_F32 ? "F32 gate" : "BF16 gate");
+}
+
+int h3_gpu_gate_f32(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *residual, const h3_gpu_tensor *branch,
+ const h3_gpu_tensor *modulation, const h3_gpu_tensor *row_map,
+ uint32_t rows, uint32_t width, uint32_t slots, uint32_t gate_slot) {
+ return h3_gate_dispatch(gpu, output, residual, branch, modulation, row_map,
+ rows, width, slots, gate_slot, H3_GPU_F32);
+}
+int h3_gpu_gate_bf16(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *residual, const h3_gpu_tensor *branch,
+ const h3_gpu_tensor *modulation, const h3_gpu_tensor *row_map,
+ uint32_t rows, uint32_t width, uint32_t slots, uint32_t gate_slot) {
+ return h3_gate_dispatch(gpu, output, residual, branch, modulation, row_map,
+ rows, width, slots, gate_slot, H3_GPU_BF16);
+}
+
+__global__ static void h3_embedding_kernel(__nv_bfloat16 *output,
+ const __nv_bfloat16 *weight, const uint32_t *token_ids,
+ uint32_t tokens, uint32_t vocab_size, uint32_t width) {
+ uint32_t column = blockIdx.x * blockDim.x + threadIdx.x;
+ uint32_t token = blockIdx.y;
+ if (token >= tokens || column >= width) return;
+ uint32_t id = token_ids[token];
+ output[(size_t)token * width + column] = id < vocab_size ?
+ weight[(size_t)id * width + column] : __float2bfloat16(0.0f);
+}
+
+int h3_gpu_embedding_bf16(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *weight, const h3_gpu_tensor *token_ids,
+ uint32_t tokens, uint32_t vocab_size, uint32_t width) {
+ size_t output_elements = 0;
+ size_t weight_elements = 0;
+ if (!h3_matrix_elements(gpu, tokens, width, &output_elements) ||
+ !h3_matrix_elements(gpu, vocab_size, width, &weight_elements) ||
+ !h3_tensor_is(output, gpu, H3_GPU_BF16, output_elements) ||
+ !h3_tensor_is(weight, gpu, H3_GPU_BF16, weight_elements) ||
+ !h3_tensor_is(token_ids, gpu, H3_GPU_U32, tokens))
+ return h3_set_error(gpu, "invalid embedding arguments");
+ dim3 grid((width + 255) / 256, tokens);
+ h3_embedding_kernel<<stream>>>(
+ (__nv_bfloat16 *)output->data,
+ (const __nv_bfloat16 *)weight->data,
+ (const uint32_t *)token_ids->data, tokens, vocab_size, width);
+ return h3_launch_ok(gpu, "BF16 embedding");
+}
+
+template
+__global__ static void h3_swiglu_kernel(T *output, const T *fused,
+ uint32_t rows, uint32_t width) {
+ uint32_t column = blockIdx.x * blockDim.x + threadIdx.x;
+ uint32_t row = blockIdx.y;
+ if (row >= rows || column >= width) return;
+ size_t base = (size_t)row * width * 2;
+ float gate = h3_value(fused[base + column]);
+ float up = h3_value(fused[base + width + column]);
+ output[(size_t)row * width + column] =
+ h3_store(gate / (1.0f + expf(-gate)) * up);
+}
+
+static int h3_swiglu_dispatch(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *fused, uint32_t rows, uint32_t width,
+ h3_gpu_dtype dtype) {
+ size_t output_elements = 0;
+ if (!h3_matrix_elements(gpu, rows, width, &output_elements) ||
+ output_elements > SIZE_MAX / 2 ||
+ !h3_tensor_is(output, gpu, dtype, output_elements) ||
+ !h3_tensor_is(fused, gpu, dtype, output_elements * 2))
+ return h3_set_error(gpu, "invalid SwiGLU arguments");
+ dim3 grid((width + 255) / 256, rows);
+ if (dtype == H3_GPU_F32)
+ h3_swiglu_kernel<<stream>>>(
+ (float *)output->data, (const float *)fused->data, rows, width);
+ else
+ h3_swiglu_kernel<__nv_bfloat16><<stream>>>(
+ (__nv_bfloat16 *)output->data,
+ (const __nv_bfloat16 *)fused->data, rows, width);
+ return h3_launch_ok(gpu, dtype == H3_GPU_F32 ? "F32 SwiGLU" : "BF16 SwiGLU");
+}
+
+int h3_gpu_swiglu_f32(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *fused, uint32_t rows,
+ uint32_t width) {
+ return h3_swiglu_dispatch(gpu, output, fused, rows, width, H3_GPU_F32);
+}
+int h3_gpu_swiglu_bf16(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *fused, uint32_t rows,
+ uint32_t width) {
+ return h3_swiglu_dispatch(gpu, output, fused, rows, width, H3_GPU_BF16);
+}
+
+__global__ static void h3_scale_add_kernel(float *output,
+ const float *residual, const float *branch, const float *scale,
+ uint32_t rows, uint32_t width) {
+ uint32_t column = blockIdx.x * blockDim.x + threadIdx.x;
+ uint32_t row = blockIdx.y;
+ if (row >= rows || column >= width) return;
+ size_t index = (size_t)row * width + column;
+ output[index] = residual[index] + branch[index] * scale[column];
+}
+
+int h3_gpu_scale_add_f32(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *residual, const h3_gpu_tensor *branch,
+ const h3_gpu_tensor *scale, uint32_t rows, uint32_t width) {
+ size_t elements = 0;
+ if (!h3_matrix_elements(gpu, rows, width, &elements) ||
+ !h3_tensor_is(output, gpu, H3_GPU_F32, elements) ||
+ !h3_tensor_is(residual, gpu, H3_GPU_F32, elements) ||
+ !h3_tensor_is(branch, gpu, H3_GPU_F32, elements) ||
+ !h3_tensor_is(scale, gpu, H3_GPU_F32, width))
+ return h3_set_error(gpu, "invalid scale-add arguments");
+ dim3 grid((width + 255) / 256, rows);
+ h3_scale_add_kernel<<stream>>>(
+ (float *)output->data, (const float *)residual->data,
+ (const float *)branch->data, (const float *)scale->data, rows, width);
+ return h3_launch_ok(gpu, "F32 scale add");
+}
+
+__global__ static void h3_geglu_kernel(float *output, const float *gate,
+ const float *linear, uint32_t count) {
+ uint32_t index = blockIdx.x * blockDim.x + threadIdx.x;
+ if (index >= count) return;
+ float value = gate[index];
+ float gelu = 0.5f * value * (1.0f + tanhf(0.7978845608028654f *
+ (value + 0.044715f * value * value * value)));
+ output[index] = gelu * linear[index];
+}
+
+int h3_gpu_geglu_f32(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *gate, const h3_gpu_tensor *linear,
+ uint32_t elements) {
+ if (!h3_tensor_is(output, gpu, H3_GPU_F32, elements) ||
+ !h3_tensor_is(gate, gpu, H3_GPU_F32, elements) ||
+ !h3_tensor_is(linear, gpu, H3_GPU_F32, elements))
+ return h3_set_error(gpu, "invalid GEGLU arguments");
+ if (elements) h3_geglu_kernel<<<(elements + 255) / 256, 256, 0,
+ gpu->stream>>>((float *)output->data, (const float *)gate->data,
+ (const float *)linear->data, elements);
+ return h3_launch_ok(gpu, "F32 GEGLU");
+}
+
+__global__ static void h3_snake_kernel(float *output, const float *input,
+ const float *alpha, size_t count, uint32_t channels) {
+ size_t index = (size_t)blockIdx.x * blockDim.x + threadIdx.x;
+ if (index >= count) return;
+ float a = alpha[index % channels];
+ float value = input[index];
+ float wave = sinf(a * value);
+ output[index] = value + wave * wave / (a + 1e-9f);
+}
+
+int h3_gpu_snake1d_f32(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *input, const h3_gpu_tensor *alpha,
+ uint32_t batch, uint32_t length, uint32_t channels) {
+ size_t count = (size_t)batch * length;
+ if (!batch || !length || !channels || count > SIZE_MAX / channels)
+ return h3_set_error(gpu, "invalid Snake shape");
+ count *= channels;
+ if (!h3_tensor_is(output, gpu, H3_GPU_F32, count) ||
+ !h3_tensor_is(input, gpu, H3_GPU_F32, count) ||
+ !h3_tensor_is(alpha, gpu, H3_GPU_F32, channels))
+ return h3_set_error(gpu, "invalid Snake tensors");
+ h3_snake_kernel<<<(count + 255) / 256, 256, 0, gpu->stream>>>(
+ (float *)output->data, (const float *)input->data,
+ (const float *)alpha->data, count, channels);
+ return h3_launch_ok(gpu, "F32 Snake1d");
+}
+
+__global__ static void h3_weight_norm_kernel(float *output,
+ const float *vector, const float *magnitude, uint32_t outer,
+ uint32_t inner) {
+ uint32_t row = blockIdx.x * blockDim.x + threadIdx.x;
+ if (row >= outer) return;
+ size_t base = (size_t)row * inner;
+ float square_sum = 0.0f;
+ for (uint32_t column = 0; column < inner; column++)
+ square_sum = fmaf(vector[base + column], vector[base + column], square_sum);
+ float scale = magnitude[row] * rsqrtf(square_sum);
+ for (uint32_t column = 0; column < inner; column++)
+ output[base + column] = vector[base + column] * scale;
+}
+
+int h3_gpu_weight_norm_f32(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *vector, const h3_gpu_tensor *magnitude,
+ uint32_t outer, uint32_t inner) {
+ size_t elements = 0;
+ if (!h3_matrix_elements(gpu, outer, inner, &elements) ||
+ !h3_tensor_is(output, gpu, H3_GPU_F32, elements) ||
+ !h3_tensor_is(vector, gpu, H3_GPU_F32, elements) ||
+ !h3_tensor_is(magnitude, gpu, H3_GPU_F32, outer))
+ return h3_set_error(gpu, "invalid weight norm arguments");
+ h3_weight_norm_kernel<<<(outer + 255) / 256, 256, 0, gpu->stream>>>(
+ (float *)output->data, (const float *)vector->data,
+ (const float *)magnitude->data, outer, inner);
+ return h3_launch_ok(gpu, "F32 weight norm");
+}
+
+__global__ static void h3_head_rms_kernel(__nv_bfloat16 *tensor,
+ const __nv_bfloat16 *weight, uint32_t sequence, uint32_t heads,
+ uint32_t head_dim, float epsilon) {
+ uint32_t row = blockIdx.x * blockDim.x + threadIdx.x;
+ uint32_t head = blockIdx.y;
+ if (row >= sequence || head >= heads) return;
+ size_t base = ((size_t)row * heads + head) * head_dim;
+ float sum = 0.0f;
+ for (uint32_t d = 0; d < head_dim; d++) {
+ float value = __bfloat162float(tensor[base + d]);
+ sum = fmaf(value, value, sum);
+ }
+ float inverse = rsqrtf(sum / (float)head_dim + epsilon);
+ for (uint32_t d = 0; d < head_dim; d++)
+ tensor[base + d] = __float2bfloat16(
+ __bfloat162float(tensor[base + d]) * inverse *
+ __bfloat162float(weight[d]));
+}
+
+int h3_gpu_head_rms_norm_bf16(h3_gpu *gpu, h3_gpu_tensor *tensor,
+ const h3_gpu_tensor *weight, uint32_t sequence, uint32_t heads,
+ uint32_t head_dim, float epsilon) {
+ size_t elements = (size_t)sequence * heads;
+ if (!sequence || !heads || !head_dim || elements > SIZE_MAX / head_dim)
+ return h3_set_error(gpu, "invalid head RMS shape");
+ elements *= head_dim;
+ if (!h3_tensor_is(tensor, gpu, H3_GPU_BF16, elements) ||
+ !h3_tensor_is(weight, gpu, H3_GPU_BF16, head_dim) || epsilon < 0.0f)
+ return h3_set_error(gpu, "invalid head RMS arguments");
+ dim3 grid((sequence + 127) / 128, heads);
+ h3_head_rms_kernel<<stream>>>(
+ (__nv_bfloat16 *)tensor->data,
+ (const __nv_bfloat16 *)weight->data, sequence, heads, head_dim, epsilon);
+ return h3_launch_ok(gpu, "BF16 head RMS norm");
+}
+
+__global__ static void h3_rope_text_kernel(__nv_bfloat16 *query,
+ __nv_bfloat16 *key, const float *rope_cos, const float *rope_sin,
+ uint32_t sequence, uint32_t query_heads, uint32_t kv_heads,
+ uint32_t head_dim) {
+ uint32_t row = blockIdx.x * blockDim.x + threadIdx.x;
+ uint32_t head = blockIdx.y;
+ if (row >= sequence) return;
+ uint32_t half = head_dim / 2;
+ for (uint32_t d = 0; d < half; d++) {
+ float c = rope_cos[(size_t)row * half + d];
+ float s = rope_sin[(size_t)row * half + d];
+ if (head < query_heads) {
+ size_t base = ((size_t)row * query_heads + head) * head_dim;
+ float first = __bfloat162float(query[base + d]);
+ float second = __bfloat162float(query[base + half + d]);
+ query[base + d] = __float2bfloat16(first * c - second * s);
+ query[base + half + d] = __float2bfloat16(second * c + first * s);
+ }
+ if (head < kv_heads) {
+ size_t base = ((size_t)row * kv_heads + head) * head_dim;
+ float first = __bfloat162float(key[base + d]);
+ float second = __bfloat162float(key[base + half + d]);
+ key[base + d] = __float2bfloat16(first * c - second * s);
+ key[base + half + d] = __float2bfloat16(second * c + first * s);
+ }
+ }
+}
+
+int h3_gpu_rope_text_bf16(h3_gpu *gpu, h3_gpu_tensor *query,
+ h3_gpu_tensor *key, const h3_gpu_tensor *rope_cos_f32,
+ const h3_gpu_tensor *rope_sin_f32, uint32_t sequence,
+ uint32_t query_heads, uint32_t kv_heads, uint32_t head_dim) {
+ if (!sequence || !query_heads || !kv_heads || !head_dim || head_dim % 2)
+ return h3_set_error(gpu, "invalid text RoPE shape");
+ size_t query_elements = (size_t)sequence * query_heads * head_dim;
+ size_t key_elements = (size_t)sequence * kv_heads * head_dim;
+ size_t rope_elements = (size_t)sequence * (head_dim / 2);
+ if (!h3_tensor_is(query, gpu, H3_GPU_BF16, query_elements) ||
+ !h3_tensor_is(key, gpu, H3_GPU_BF16, key_elements) ||
+ !h3_tensor_is(rope_cos_f32, gpu, H3_GPU_F32, rope_elements) ||
+ !h3_tensor_is(rope_sin_f32, gpu, H3_GPU_F32, rope_elements))
+ return h3_set_error(gpu, "invalid text RoPE tensors");
+ uint32_t maximum_heads = query_heads > kv_heads ? query_heads : kv_heads;
+ dim3 grid((sequence + 127) / 128, maximum_heads);
+ h3_rope_text_kernel<<stream>>>(
+ (__nv_bfloat16 *)query->data, (__nv_bfloat16 *)key->data,
+ (const float *)rope_cos_f32->data, (const float *)rope_sin_f32->data,
+ sequence, query_heads, kv_heads, head_dim);
+ return h3_launch_ok(gpu, "BF16 text RoPE");
+}
+
+template
+__global__ static void h3_qkv_rope_kernel(T *query, T *key, T *value,
+ const T *qkv, const T *q_weight, const T *k_weight,
+ const T *rope_cos, const T *rope_sin, uint32_t sequence,
+ uint32_t heads, uint32_t head_dim, uint32_t rope_half,
+ float epsilon, int grouped, int normalize, int weighted) {
+ uint32_t dimension = blockIdx.x * blockDim.x + threadIdx.x;
+ uint32_t head = blockIdx.y;
+ uint32_t row = blockIdx.z;
+ if (row >= sequence || head >= heads || dimension >= head_dim) return;
+ size_t inner = (size_t)heads * head_dim;
+ size_t row_base = (size_t)row * inner * 3;
+ size_t q_base = row_base + (size_t)head * head_dim;
+ size_t k_base = q_base + inner;
+ size_t v_base = q_base + inner * 2;
+ if (grouped) {
+ q_base = row_base + (size_t)head * head_dim * 3;
+ k_base = q_base + head_dim;
+ v_base = k_base + head_dim;
+ }
+ float q_inverse = 1.0f;
+ float k_inverse = 1.0f;
+ if (normalize) {
+ float q_sum = 0.0f, k_sum = 0.0f;
+ for (uint32_t d = 0; d < head_dim; d++) {
+ float q = h3_value(qkv[q_base + d]);
+ float k = h3_value(qkv[k_base + d]);
+ q_sum = fmaf(q, q, q_sum);
+ k_sum = fmaf(k, k, k_sum);
+ }
+ q_inverse = rsqrtf(q_sum / (float)head_dim + epsilon);
+ k_inverse = rsqrtf(k_sum / (float)head_dim + epsilon);
+ }
+ float qw = weighted ? h3_value(q_weight[dimension]) : 1.0f;
+ float kw = weighted ? h3_value(k_weight[dimension]) : 1.0f;
+ float q0 = h3_value(qkv[q_base + dimension]) * q_inverse * qw;
+ float k0 = h3_value(qkv[k_base + dimension]) * k_inverse * kw;
+ if (dimension < rope_half * 2) {
+ uint32_t rope_index = dimension % rope_half;
+ uint32_t pair = dimension < rope_half ? dimension + rope_half :
+ dimension - rope_half;
+ float q1 = h3_value(qkv[q_base + pair]) * q_inverse *
+ (weighted ? h3_value(q_weight[pair]) : 1.0f);
+ float k1 = h3_value(qkv[k_base + pair]) * k_inverse *
+ (weighted ? h3_value(k_weight[pair]) : 1.0f);
+ float c = h3_value(rope_cos[(size_t)row * rope_half + rope_index]);
+ float s = h3_value(rope_sin[(size_t)row * rope_half + rope_index]);
+ if (dimension < rope_half) {
+ q0 = q0 * c - q1 * s;
+ k0 = k0 * c - k1 * s;
+ } else {
+ q0 = q0 * c + q1 * s;
+ k0 = k0 * c + k1 * s;
+ }
+ }
+ size_t output_index = ((size_t)head * sequence + row) * head_dim + dimension;
+ query[output_index] = h3_store(q0);
+ key[output_index] = h3_store(k0);
+ value[output_index] = qkv[v_base + dimension];
+}
+
+static int h3_qkv_shape(h3_gpu *gpu, uint32_t sequence, uint32_t heads,
+ uint32_t head_dim, uint32_t rope_half, size_t *elements) {
+ if (!sequence || !heads || !head_dim || rope_half > head_dim / 2)
+ return h3_set_error(gpu, "invalid QKV/RoPE shape");
+ size_t count = (size_t)sequence * heads;
+ if (count > SIZE_MAX / head_dim) return h3_set_error(gpu, "QKV shape overflow");
+ *elements = count * head_dim;
+ return 1;
+}
+
+static int h3_qkv_validate(h3_gpu *gpu, h3_gpu_tensor *query,
+ h3_gpu_tensor *key, h3_gpu_tensor *value, const h3_gpu_tensor *qkv,
+ const h3_gpu_tensor *q_weight, const h3_gpu_tensor *k_weight,
+ const h3_gpu_tensor *rope_cos, const h3_gpu_tensor *rope_sin,
+ uint32_t sequence, uint32_t heads, uint32_t head_dim,
+ uint32_t rope_half, h3_gpu_dtype dtype, int weighted,
+ size_t *elements) {
+ if (!h3_qkv_shape(gpu, sequence, heads, head_dim, rope_half, elements) ||
+ *elements > SIZE_MAX / 3 ||
+ !h3_tensor_is(query, gpu, dtype, *elements) ||
+ !h3_tensor_is(key, gpu, dtype, *elements) ||
+ !h3_tensor_is(value, gpu, dtype, *elements) ||
+ !h3_tensor_is(qkv, gpu, dtype, *elements * 3) ||
+ !h3_tensor_is(rope_cos, gpu, dtype, (size_t)sequence * rope_half) ||
+ !h3_tensor_is(rope_sin, gpu, dtype, (size_t)sequence * rope_half) ||
+ (weighted && (!h3_tensor_is(q_weight, gpu, dtype, head_dim) ||
+ !h3_tensor_is(k_weight, gpu, dtype, head_dim))))
+ return h3_set_error(gpu, "invalid QKV/RoPE tensors");
+ return 1;
+}
+
+int h3_gpu_qkv_rope_f32(h3_gpu *gpu, h3_gpu_tensor *query,
+ h3_gpu_tensor *key, h3_gpu_tensor *value, const h3_gpu_tensor *qkv,
+ const h3_gpu_tensor *q_norm, const h3_gpu_tensor *k_norm,
+ const h3_gpu_tensor *rope_cos, const h3_gpu_tensor *rope_sin,
+ uint32_t sequence, uint32_t heads, uint32_t head_dim,
+ uint32_t rope_half, float epsilon) {
+ size_t elements = 0;
+ if (!h3_qkv_validate(gpu, query, key, value, qkv, q_norm, k_norm,
+ rope_cos, rope_sin, sequence, heads, head_dim, rope_half,
+ H3_GPU_F32, 1, &elements) || epsilon < 0.0f) return 0;
+ dim3 grid((head_dim + 127) / 128, heads, sequence);
+ h3_qkv_rope_kernel<<stream>>>(
+ (float *)query->data, (float *)key->data, (float *)value->data,
+ (const float *)qkv->data, (const float *)q_norm->data,
+ (const float *)k_norm->data, (const float *)rope_cos->data,
+ (const float *)rope_sin->data, sequence, heads, head_dim,
+ rope_half, epsilon, 0, 1, 1);
+ return h3_launch_ok(gpu, "F32 QKV RoPE");
+}
+
+int h3_gpu_video_qkv_rope_f32(h3_gpu *gpu, h3_gpu_tensor *query,
+ h3_gpu_tensor *key, h3_gpu_tensor *value, const h3_gpu_tensor *qkv,
+ const h3_gpu_tensor *rope_cos, const h3_gpu_tensor *rope_sin,
+ uint32_t sequence, uint32_t heads, uint32_t head_dim,
+ uint32_t rope_half, float epsilon) {
+ size_t elements = 0;
+ if (!h3_qkv_validate(gpu, query, key, value, qkv, NULL, NULL,
+ rope_cos, rope_sin, sequence, heads, head_dim, rope_half,
+ H3_GPU_F32, 0, &elements) || epsilon < 0.0f) return 0;
+ dim3 grid((head_dim + 127) / 128, heads, sequence);
+ h3_qkv_rope_kernel<<stream>>>(
+ (float *)query->data, (float *)key->data, (float *)value->data,
+ (const float *)qkv->data, NULL, NULL, (const float *)rope_cos->data,
+ (const float *)rope_sin->data, sequence, heads, head_dim,
+ rope_half, epsilon, 1, 1, 0);
+ return h3_launch_ok(gpu, "F32 video QKV RoPE");
+}
+
+static int h3_qkv_rope_bf16_dispatch(h3_gpu *gpu, h3_gpu_tensor *query,
+ h3_gpu_tensor *key, h3_gpu_tensor *value, const h3_gpu_tensor *qkv,
+ const h3_gpu_tensor *q_norm, const h3_gpu_tensor *k_norm,
+ const h3_gpu_tensor *rope_cos, const h3_gpu_tensor *rope_sin,
+ uint32_t sequence, uint32_t heads, uint32_t head_dim,
+ uint32_t rope_half, float epsilon, int grouped) {
+ size_t elements = 0;
+ if (!h3_qkv_validate(gpu, query, key, value, qkv, q_norm, k_norm,
+ rope_cos, rope_sin, sequence, heads, head_dim, rope_half,
+ H3_GPU_BF16, 1, &elements) || epsilon < 0.0f) return 0;
+ dim3 grid((head_dim + 127) / 128, heads, sequence);
+ h3_qkv_rope_kernel<__nv_bfloat16><<stream>>>(
+ (__nv_bfloat16 *)query->data, (__nv_bfloat16 *)key->data,
+ (__nv_bfloat16 *)value->data, (const __nv_bfloat16 *)qkv->data,
+ (const __nv_bfloat16 *)q_norm->data,
+ (const __nv_bfloat16 *)k_norm->data,
+ (const __nv_bfloat16 *)rope_cos->data,
+ (const __nv_bfloat16 *)rope_sin->data, sequence, heads, head_dim,
+ rope_half, epsilon, grouped, 1, 1);
+ return h3_launch_ok(gpu, grouped ? "BF16 grouped QKV RoPE" : "BF16 QKV RoPE");
+}
+
+int h3_gpu_qkv_rope_bf16(h3_gpu *gpu, h3_gpu_tensor *query,
+ h3_gpu_tensor *key, h3_gpu_tensor *value, const h3_gpu_tensor *qkv,
+ const h3_gpu_tensor *q_norm, const h3_gpu_tensor *k_norm,
+ const h3_gpu_tensor *rope_cos, const h3_gpu_tensor *rope_sin,
+ uint32_t sequence, uint32_t heads, uint32_t head_dim,
+ uint32_t rope_half, float epsilon) {
+ return h3_qkv_rope_bf16_dispatch(gpu, query, key, value, qkv, q_norm,
+ k_norm, rope_cos, rope_sin, sequence, heads, head_dim, rope_half,
+ epsilon, 0);
+}
+
+int h3_gpu_grouped_qkv_rope_bf16(h3_gpu *gpu, h3_gpu_tensor *query,
+ h3_gpu_tensor *key, h3_gpu_tensor *value, const h3_gpu_tensor *qkv,
+ const h3_gpu_tensor *q_norm, const h3_gpu_tensor *k_norm,
+ const h3_gpu_tensor *rope_cos, const h3_gpu_tensor *rope_sin,
+ uint32_t sequence, uint32_t heads, uint32_t head_dim,
+ uint32_t rope_half, float epsilon) {
+ return h3_qkv_rope_bf16_dispatch(gpu, query, key, value, qkv, q_norm,
+ k_norm, rope_cos, rope_sin, sequence, heads, head_dim, rope_half,
+ epsilon, 1);
+}
+
+int h3_gpu_vision_qkv_rope_bf16(h3_gpu *gpu, h3_gpu_tensor *query,
+ h3_gpu_tensor *key, h3_gpu_tensor *value, const h3_gpu_tensor *qkv,
+ const h3_gpu_tensor *rope_cos, const h3_gpu_tensor *rope_sin,
+ uint32_t sequence, uint32_t heads, uint32_t head_dim,
+ uint32_t rope_half) {
+ size_t elements = 0;
+ if (!h3_qkv_validate(gpu, query, key, value, qkv, NULL, NULL,
+ rope_cos, rope_sin, sequence, heads, head_dim, rope_half,
+ H3_GPU_BF16, 0, &elements)) return 0;
+ dim3 grid((head_dim + 127) / 128, heads, sequence);
+ h3_qkv_rope_kernel<__nv_bfloat16><<stream>>>(
+ (__nv_bfloat16 *)query->data, (__nv_bfloat16 *)key->data,
+ (__nv_bfloat16 *)value->data, (const __nv_bfloat16 *)qkv->data,
+ NULL, NULL, (const __nv_bfloat16 *)rope_cos->data,
+ (const __nv_bfloat16 *)rope_sin->data, sequence, heads, head_dim,
+ rope_half, 0.0f, 0, 0, 0);
+ return h3_launch_ok(gpu, "BF16 vision QKV RoPE");
+}
+
+__global__ static void h3_token_pool_kernel(__nv_bfloat16 *output,
+ const __nv_bfloat16 *input, size_t input_offset,
+ __nv_bfloat16 *original, size_t original_offset,
+ __nv_bfloat16 *baseline, size_t baseline_offset,
+ const uint32_t *baseline_indices, const uint32_t *pairs,
+ uint32_t rows, uint32_t width) {
+ uint32_t column = blockIdx.x * blockDim.x + threadIdx.x;
+ uint32_t row = blockIdx.y;
+ if (row >= rows || column >= width) return;
+ uint32_t first_row = pairs[(size_t)row * 2];
+ uint32_t second_row = pairs[(size_t)row * 2 + 1];
+ __nv_bfloat16 first = input[input_offset + (size_t)first_row * width + column];
+ original[original_offset + (size_t)first_row * width + column] = first;
+ __nv_bfloat16 pooled = first;
+ if (first_row != second_row) {
+ __nv_bfloat16 second = input[input_offset + (size_t)second_row * width + column];
+ original[original_offset + (size_t)second_row * width + column] = second;
+ pooled = __float2bfloat16((__bfloat162float(first) +
+ __bfloat162float(second)) * 0.5f);
+ }
+ output[(size_t)row * width + column] = pooled;
+ uint32_t baseline_row = baseline_indices[row];
+ if (baseline_row != UINT32_MAX)
+ baseline[baseline_offset + (size_t)baseline_row * width + column] = pooled;
+}
+
+int h3_gpu_token_pool_bf16(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *input, size_t input_offset,
+ h3_gpu_tensor *original, size_t original_offset,
+ h3_gpu_tensor *baseline, size_t baseline_offset,
+ const h3_gpu_tensor *baseline_indices, const h3_gpu_tensor *pairs,
+ uint32_t input_rows, uint32_t rows, uint32_t baseline_rows,
+ uint32_t width) {
+ size_t input_elements = 0, output_elements = 0, baseline_elements = 0;
+ if (!h3_matrix_elements(gpu, input_rows, width, &input_elements) ||
+ !h3_matrix_elements(gpu, rows, width, &output_elements) ||
+ !h3_matrix_elements(gpu, baseline_rows, width, &baseline_elements) ||
+ input_offset > SIZE_MAX - input_elements ||
+ original_offset > SIZE_MAX - input_elements ||
+ baseline_offset > SIZE_MAX - baseline_elements ||
+ !h3_tensor_is(output, gpu, H3_GPU_BF16, output_elements) ||
+ !h3_tensor_is(input, gpu, H3_GPU_BF16, input_offset + input_elements) ||
+ !h3_tensor_is(original, gpu, H3_GPU_BF16, original_offset + input_elements) ||
+ !h3_tensor_is(baseline, gpu, H3_GPU_BF16, baseline_offset + baseline_elements) ||
+ !h3_tensor_is(baseline_indices, gpu, H3_GPU_U32, rows) ||
+ rows > SIZE_MAX / 2 || !h3_tensor_is(pairs, gpu, H3_GPU_U32, (size_t)rows * 2))
+ return h3_set_error(gpu, "invalid token pool arguments");
+ dim3 grid((width + 255) / 256, rows);
+ h3_token_pool_kernel<<stream>>>(
+ (__nv_bfloat16 *)output->data, (const __nv_bfloat16 *)input->data,
+ input_offset, (__nv_bfloat16 *)original->data, original_offset,
+ (__nv_bfloat16 *)baseline->data, baseline_offset,
+ (const uint32_t *)baseline_indices->data,
+ (const uint32_t *)pairs->data, rows, width);
+ return h3_launch_ok(gpu, "BF16 token pool");
+}
+
+__global__ static void h3_token_expand_kernel(__nv_bfloat16 *output,
+ const __nv_bfloat16 *original, size_t original_offset,
+ const __nv_bfloat16 *reduced, const __nv_bfloat16 *baseline,
+ size_t baseline_offset, const uint32_t *baseline_indices,
+ const uint32_t *parents, uint32_t rows, uint32_t width,
+ uint32_t exact_prefix_rows, float update_scale) {
+ uint32_t column = blockIdx.x * blockDim.x + threadIdx.x;
+ uint32_t row = blockIdx.y;
+ if (row >= rows || column >= width) return;
+ uint32_t parent = parents[row];
+ size_t destination = (size_t)row * width + column;
+ size_t reduced_index = (size_t)parent * width + column;
+ uint32_t baseline_row = baseline_indices[parent];
+ if (row < exact_prefix_rows || baseline_row == UINT32_MAX) {
+ output[destination] = reduced[reduced_index];
+ return;
+ }
+ float update = __bfloat162float(reduced[reduced_index]) -
+ __bfloat162float(baseline[baseline_offset +
+ (size_t)baseline_row * width + column]);
+ output[destination] = __float2bfloat16(
+ __bfloat162float(original[original_offset + destination]) +
+ update_scale * update);
+}
+
+int h3_gpu_token_expand_delta_bf16(h3_gpu *gpu, h3_gpu_tensor *output,
+ const h3_gpu_tensor *original, size_t original_offset,
+ const h3_gpu_tensor *reduced, const h3_gpu_tensor *baseline,
+ size_t baseline_offset, const h3_gpu_tensor *baseline_indices,
+ const h3_gpu_tensor *parents, uint32_t rows, uint32_t reduced_rows,
+ uint32_t baseline_rows, uint32_t width, uint32_t exact_prefix_rows,
+ float update_scale) {
+ size_t output_elements = 0, reduced_elements = 0, baseline_elements = 0;
+ if (!h3_matrix_elements(gpu, rows, width, &output_elements) ||
+ !h3_matrix_elements(gpu, reduced_rows, width, &reduced_elements) ||
+ !h3_matrix_elements(gpu, baseline_rows, width, &baseline_elements) ||
+ exact_prefix_rows > rows || original_offset > SIZE_MAX - output_elements ||
+ baseline_offset > SIZE_MAX - baseline_elements ||
+ !h3_tensor_is(output, gpu, H3_GPU_BF16, output_elements) ||
+ !h3_tensor_is(original, gpu, H3_GPU_BF16, original_offset + output_elements) ||
+ !h3_tensor_is(reduced, gpu, H3_GPU_BF16, reduced_elements) ||
+ !h3_tensor_is(baseline, gpu, H3_GPU_BF16, baseline_offset + baseline_elements) ||
+ !h3_tensor_is(baseline_indices, gpu, H3_GPU_U32, reduced_rows) ||
+ !h3_tensor_is(parents, gpu, H3_GPU_U32, rows))
+ return h3_set_error(gpu, "invalid token expand arguments");
+ dim3 grid((width + 255) / 256, rows);
+ h3_token_expand_kernel<<stream>>>(
+ (__nv_bfloat16 *)output->data,
+ (const __nv_bfloat16 *)original->data, original_offset,
+ (const __nv_bfloat16 *)reduced->data,
+ (const __nv_bfloat16 *)baseline->data, baseline_offset,
+ (const uint32_t *)baseline_indices->data,
+ (const uint32_t *)parents->data, rows, width, exact_prefix_rows,
+ update_scale);
+ return h3_launch_ok(gpu, "BF16 token expand");
+}
+
+int h3_gpu_gate_adaln_bf16(h3_gpu *gpu, h3_gpu_tensor *gated_residual,
+ h3_gpu_tensor *output, const h3_gpu_tensor *residual,
+ const h3_gpu_tensor *branch, const h3_gpu_tensor *norm_weight,
+ const h3_gpu_tensor *gate_modulation,
+ const h3_gpu_tensor *norm_modulation, const h3_gpu_tensor *row_map,
+ uint32_t rows, uint32_t width, uint32_t slots, uint32_t gate_slot,
+ uint32_t shift_slot, uint32_t scale_slot, float epsilon) {
+ if (!h3_gpu_gate_bf16(gpu, gated_residual, residual, branch,
+ gate_modulation, row_map, rows, width, slots,
+ gate_slot)) return 0;
+ return h3_gpu_adaln_bf16(gpu, output, gated_residual, norm_weight,
+ norm_modulation, row_map, rows, width, slots,
+ shift_slot, scale_slot, epsilon);
+}
+
+int h3_gpu_token_pool_adaln_bf16(h3_gpu *gpu, h3_gpu_tensor *residual,
+ h3_gpu_tensor *output, const h3_gpu_tensor *input, size_t input_offset,
+ h3_gpu_tensor *original, size_t original_offset,
+ h3_gpu_tensor *baseline, size_t baseline_offset,
+ const h3_gpu_tensor *baseline_indices, const h3_gpu_tensor *pairs,
+ const h3_gpu_tensor *norm_weight, const h3_gpu_tensor *modulation,
+ const h3_gpu_tensor *row_map, uint32_t input_rows, uint32_t rows,
+ uint32_t baseline_rows, uint32_t width, uint32_t slots,
+ uint32_t shift_slot, uint32_t scale_slot, float epsilon) {
+ if (!h3_gpu_token_pool_bf16(gpu, residual, input, input_offset, original,
+ original_offset, baseline, baseline_offset, baseline_indices, pairs,
+ input_rows, rows, baseline_rows, width)) return 0;
+ return h3_gpu_adaln_bf16(gpu, output, residual, norm_weight, modulation,
+ row_map, rows, width, slots, shift_slot, scale_slot, epsilon);
+}
+
+int h3_gpu_token_expand_adaln_bf16(h3_gpu *gpu, h3_gpu_tensor *residual,
+ h3_gpu_tensor *output, const h3_gpu_tensor *original,
+ size_t original_offset, const h3_gpu_tensor *reduced,
+ const h3_gpu_tensor *baseline, size_t baseline_offset,
+ const h3_gpu_tensor *baseline_indices, const h3_gpu_tensor *parents,
+ const h3_gpu_tensor *norm_weight, const h3_gpu_tensor *modulation,
+ const h3_gpu_tensor *row_map, uint32_t rows, uint32_t reduced_rows,
+ uint32_t baseline_rows, uint32_t width, uint32_t exact_prefix_rows,
+ float update_scale, uint32_t slots, uint32_t shift_slot,
+ uint32_t scale_slot, float epsilon) {
+ if (!h3_gpu_token_expand_delta_bf16(gpu, residual, original,
+ original_offset, reduced, baseline, baseline_offset, baseline_indices,
+ parents, rows, reduced_rows, baseline_rows, width, exact_prefix_rows,
+ update_scale)) return 0;
+ return h3_gpu_adaln_bf16(gpu, output, residual, norm_weight, modulation,
+ row_map, rows, width, slots, shift_slot, scale_slot, epsilon);
+}
+
+__global__ static void h3_text_qk_rope_kernel(__nv_bfloat16 *query_output,
+ __nv_bfloat16 *key_output, const __nv_bfloat16 *query_input,
+ const __nv_bfloat16 *key_input, const __nv_bfloat16 *q_weight,
+ const __nv_bfloat16 *k_weight, const __nv_bfloat16 *rope_cos,
+ const __nv_bfloat16 *rope_sin, uint32_t sequence,
+ uint32_t query_heads, uint32_t kv_heads, uint32_t head_dim,
+ float epsilon) {
+ uint32_t dimension = blockIdx.x * blockDim.x + threadIdx.x;
+ uint32_t head = blockIdx.y;
+ uint32_t row = blockIdx.z;
+ if (dimension >= head_dim || head >= query_heads || row >= sequence) return;
+ uint32_t half = head_dim / 2;
+ uint32_t pair = dimension < half ? dimension + half : dimension - half;
+ uint32_t rope_index = dimension % half;
+ float c = __bfloat162float(rope_cos[(size_t)row * half + rope_index]);
+ float s = __bfloat162float(rope_sin[(size_t)row * half + rope_index]);
+ size_t q_base = ((size_t)row * query_heads + head) * head_dim;
+ float q_sum = 0.0f;
+ for (uint32_t d = 0; d < head_dim; d++) {
+ float value = __bfloat162float(query_input[q_base + d]);
+ q_sum = fmaf(value, value, q_sum);
+ }
+ float q_inverse = rsqrtf(q_sum / (float)head_dim + epsilon);
+ float q0 = __bfloat162float(query_input[q_base + dimension]) * q_inverse *
+ __bfloat162float(q_weight[dimension]);
+ float q1 = __bfloat162float(query_input[q_base + pair]) * q_inverse *
+ __bfloat162float(q_weight[pair]);
+ query_output[q_base + dimension] = __float2bfloat16(
+ dimension < half ? q0 * c - q1 * s : q0 * c + q1 * s);
+ if (head < kv_heads) {
+ size_t k_base = ((size_t)row * kv_heads + head) * head_dim;
+ float k_sum = 0.0f;
+ for (uint32_t d = 0; d < head_dim; d++) {
+ float value = __bfloat162float(key_input[k_base + d]);
+ k_sum = fmaf(value, value, k_sum);
+ }
+ float k_inverse = rsqrtf(k_sum / (float)head_dim + epsilon);
+ float k0 = __bfloat162float(key_input[k_base + dimension]) * k_inverse *
+ __bfloat162float(k_weight[dimension]);
+ float k1 = __bfloat162float(key_input[k_base + pair]) * k_inverse *
+ __bfloat162float(k_weight[pair]);
+ key_output[k_base + dimension] = __float2bfloat16(
+ dimension < half ? k0 * c - k1 * s : k0 * c + k1 * s);
+ }
+}
+
+int h3_gpu_text_qk_rope_bf16(h3_gpu *gpu,
+ h3_gpu_tensor *query_output, h3_gpu_tensor *key_output,
+ const h3_gpu_tensor *query_input, const h3_gpu_tensor *key_input,
+ const h3_gpu_tensor *q_norm, const h3_gpu_tensor *k_norm,
+ const h3_gpu_tensor *rope_cos, const h3_gpu_tensor *rope_sin,
+ uint32_t sequence, uint32_t query_heads, uint32_t kv_heads,
+ uint32_t head_dim, float epsilon) {
+ if (!sequence || !query_heads || !kv_heads || !head_dim || head_dim % 2 ||
+ query_heads < kv_heads || epsilon < 0.0f)
+ return h3_set_error(gpu, "invalid text QK/RoPE shape");
+ size_t query_elements = (size_t)sequence * query_heads * head_dim;
+ size_t key_elements = (size_t)sequence * kv_heads * head_dim;
+ size_t rope_elements = (size_t)sequence * (head_dim / 2);
+ if (!h3_tensor_is(query_output, gpu, H3_GPU_BF16, query_elements) ||
+ !h3_tensor_is(key_output, gpu, H3_GPU_BF16, key_elements) ||
+ !h3_tensor_is(query_input, gpu, H3_GPU_BF16, query_elements) ||
+ !h3_tensor_is(key_input, gpu, H3_GPU_BF16, key_elements) ||
+ !h3_tensor_is(q_norm, gpu, H3_GPU_BF16, head_dim) ||
+ !h3_tensor_is(k_norm, gpu, H3_GPU_BF16, head_dim) ||
+ !h3_tensor_is(rope_cos, gpu, H3_GPU_BF16, rope_elements) ||
+ !h3_tensor_is(rope_sin, gpu, H3_GPU_BF16, rope_elements))
+ return h3_set_error(gpu, "invalid text QK/RoPE tensors");
+ dim3 grid((head_dim + 127) / 128, query_heads, sequence);
+ h3_text_qk_rope_kernel<<stream>>>(
+ (__nv_bfloat16 *)query_output->data,
+ (__nv_bfloat16 *)key_output->data,
+ (const __nv_bfloat16 *)query_input->data,
+ (const __nv_bfloat16 *)key_input->data,
+ (const __nv_bfloat16 *)q_norm->data,
+ (const __nv_bfloat16 *)k_norm->data,
+ (const __nv_bfloat16 *)rope_cos->data,
+ (const __nv_bfloat16 *)rope_sin->data, sequence, query_heads,
+ kv_heads, head_dim, epsilon);
+ return h3_launch_ok(gpu, "BF16 text QK RoPE");
+}
diff --git a/h3_host.c b/h3_host.c
index a04a2a0a..ae3cd9da 100644
--- a/h3_host.c
+++ b/h3_host.c
@@ -1,6 +1,8 @@
#include "h3_host.h"
+#ifdef __APPLE__
#include
+#endif
#include
#include
@@ -551,6 +553,7 @@ int h3_resize_rgb24_high_quality(const uint8_t *input, int frames,
*output = pixels;
return 1;
}
+#ifdef __APPLE__
if (input_area > SIZE_MAX / 4 || output_area > SIZE_MAX / 4) {
free(pixels);
return 0;
@@ -594,6 +597,39 @@ int h3_resize_rgb24_high_quality(const uint8_t *input, int frames,
}
}
free(source_argb); free(output_argb);
+#else
+ size_t input_frame_bytes = input_area * 3;
+ size_t output_frame_bytes = output_area * 3;
+ for (int frame = 0; frame < frames; frame++) {
+ const uint8_t *source = input + (size_t)frame * input_frame_bytes;
+ uint8_t *destination = pixels + (size_t)frame * output_frame_bytes;
+ for (int y = 0; y < output_height; y++) {
+ double source_y = ((double)y + 0.5) * input_height / output_height - 0.5;
+ int y0 = (int)floor(source_y);
+ double fy = source_y - y0;
+ if (y0 < 0) { y0 = 0; fy = 0.0; }
+ int y1 = y0 + 1;
+ if (y1 >= input_height) { y1 = input_height - 1; fy = 0.0; }
+ for (int x = 0; x < output_width; x++) {
+ double source_x = ((double)x + 0.5) * input_width / output_width - 0.5;
+ int x0 = (int)floor(source_x);
+ double fx = source_x - x0;
+ if (x0 < 0) { x0 = 0; fx = 0.0; }
+ int x1 = x0 + 1;
+ if (x1 >= input_width) { x1 = input_width - 1; fx = 0.0; }
+ for (int channel = 0; channel < 3; channel++) {
+ double top = source[((size_t)y0 * input_width + x0) * 3 + channel] * (1.0 - fx) +
+ source[((size_t)y0 * input_width + x1) * 3 + channel] * fx;
+ double bottom = source[((size_t)y1 * input_width + x0) * 3 + channel] * (1.0 - fx) +
+ source[((size_t)y1 * input_width + x1) * 3 + channel] * fx;
+ double value = top * (1.0 - fy) + bottom * fy;
+ destination[((size_t)y * output_width + x) * 3 + channel] =
+ (uint8_t)lrint(fmin(255.0, fmax(0.0, value)));
+ }
+ }
+ }
+ }
+#endif
*output = pixels;
return 1;
}
diff --git a/h3_host.h b/h3_host.h
index f71eded7..f125d658 100644
--- a/h3_host.h
+++ b/h3_host.h
@@ -125,9 +125,9 @@ uint32_t h3_rng_u32(h3_rng *rng);
float h3_rng_normal(h3_rng *rng);
void h3_rng_fill_normal(h3_rng *rng, float *values, size_t count);
-/* Resize interleaved RGB24 frames with Accelerate/vImage high-quality
- * resampling. The caller owns *output. Identity geometry still returns an
- * independent copy. */
+/* Resize interleaved RGB24 frames with the platform high-quality resampler.
+ * The caller owns *output. Identity geometry still returns an independent
+ * copy. */
int h3_resize_rgb24_high_quality(const uint8_t *input, int frames,
int input_width, int input_height,
int output_width, int output_height,
diff --git a/h3_metal.h b/h3_metal.h
deleted file mode 100644
index beaf628d..00000000
--- a/h3_metal.h
+++ /dev/null
@@ -1,8 +0,0 @@
-#ifndef H3_METAL_H
-#define H3_METAL_H
-
-#include "h3.h"
-
-int h3_metal_probe(h3_device_info *info, char *error, size_t error_size);
-
-#endif
diff --git a/h3_metal.m b/h3_metal.m
index 85e1d4c4..137c9ba4 100644
--- a/h3_metal.m
+++ b/h3_metal.m
@@ -1,7 +1,7 @@
#import
#import
-#include "h3_metal.h"
+#include "h3_device.h"
#include
#include
@@ -12,7 +12,7 @@ static void h3_copy_string(char *destination, size_t size, NSString *value) {
snprintf(destination, size, "%s", source ? source : "unknown");
}
-int h3_metal_probe(h3_device_info *info, char *error, size_t error_size) {
+int h3_device_probe(h3_device_info *info, char *error, size_t error_size) {
if (!info) return 0;
memset(info, 0, sizeof(*info));
@autoreleasepool {
diff --git a/h3_terminal.c b/h3_terminal.c
index 5b216e2e..317f8cf8 100644
--- a/h3_terminal.c
+++ b/h3_terminal.c
@@ -192,7 +192,7 @@ static int encode_png(const uint8_t *pixels, size_t size,
unlink(raw_path);
return 0;
}
- int output = mkstemps(path, 4);
+ int output = mkstemp(path);
if (output < 0) {
fail(error, error_size, "cannot create terminal PNG temporary file: %s",
strerror(errno));
@@ -205,7 +205,7 @@ static int encode_png(const uint8_t *pixels, size_t size,
char *arguments[] = {
"ffmpeg", "-v", "error", "-y", "-f", "rawvideo",
"-pixel_format", "rgb24", "-video_size", dimensions,
- "-i", raw_path, "-frames:v", "1", path, NULL
+ "-i", raw_path, "-frames:v", "1", "-f", "image2", path, NULL
};
pid_t child = 0;
int spawn_error = posix_spawnp(&child, "ffmpeg", NULL, NULL,
@@ -261,7 +261,7 @@ static int iterm2_display(const uint8_t *pixels, size_t size,
fail(error, error_size, "invalid iTerm2 display dimensions");
return 0;
}
- char path[] = "/tmp/h3-terminal-XXXXXX.png";
+ char path[] = "/tmp/h3-terminal-XXXXXX";
if (!encode_png(pixels, size, width, height, path, error, error_size))
return 0;
size_t png_size = 0;
diff --git a/h3_tokenizer.c b/h3_tokenizer.c
new file mode 100644
index 00000000..f20a0b33
--- /dev/null
+++ b/h3_tokenizer.c
@@ -0,0 +1,938 @@
+#include "h3_tokenizer.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+typedef struct {
+ char *key;
+ uint32_t value;
+} h3_map_item;
+
+typedef struct {
+ h3_map_item *items;
+ size_t capacity;
+ size_t count;
+} h3_map;
+
+struct h3_tokenizer {
+ h3_map vocab;
+ h3_map merges;
+ h3_map added;
+ char **inverse_vocab;
+ char **inverse_added;
+ size_t inverse_count;
+ char *byte_encoder[256];
+ int16_t byte_decoder[324];
+};
+
+typedef struct {
+ const char *cursor;
+ const char *end;
+ char message[160];
+} h3_json;
+
+typedef struct {
+ char **values;
+ size_t count;
+ size_t capacity;
+} h3_strings;
+
+typedef struct {
+ uint32_t *values;
+ size_t count;
+ size_t capacity;
+} h3_ids;
+
+typedef struct {
+ uint32_t value;
+ size_t offset;
+ size_t length;
+} h3_codepoint;
+
+static void h3_error(char *error, size_t size, const char *message) {
+ if (error && size) snprintf(error, size, "%s", message ? message : "tokenizer failure");
+}
+
+static uint64_t h3_hash(const char *text) {
+ uint64_t value = UINT64_C(1469598103934665603);
+ for (const unsigned char *p = (const unsigned char *)text; *p; p++) {
+ value ^= *p;
+ value *= UINT64_C(1099511628211);
+ }
+ return value;
+}
+
+static int h3_map_grow(h3_map *map) {
+ size_t capacity = map->capacity ? map->capacity * 2 : 1024;
+ h3_map_item *items = calloc(capacity, sizeof(*items));
+ if (!items) return 0;
+ for (size_t index = 0; index < map->capacity; index++) {
+ h3_map_item item = map->items[index];
+ if (!item.key) continue;
+ size_t slot = (size_t)h3_hash(item.key) & (capacity - 1);
+ while (items[slot].key) slot = (slot + 1) & (capacity - 1);
+ items[slot] = item;
+ }
+ free(map->items);
+ map->items = items;
+ map->capacity = capacity;
+ return 1;
+}
+
+static int h3_map_put(h3_map *map, char *key, uint32_t value) {
+ if (!map->capacity || (map->count + 1) * 10 >= map->capacity * 7)
+ if (!h3_map_grow(map)) return 0;
+ size_t slot = (size_t)h3_hash(key) & (map->capacity - 1);
+ while (map->items[slot].key) {
+ if (!strcmp(map->items[slot].key, key)) {
+ free(key);
+ map->items[slot].value = value;
+ return 1;
+ }
+ slot = (slot + 1) & (map->capacity - 1);
+ }
+ map->items[slot] = (h3_map_item){key, value};
+ map->count++;
+ return 1;
+}
+
+static int h3_map_get(const h3_map *map, const char *key, uint32_t *value) {
+ if (!map->capacity) return 0;
+ size_t slot = (size_t)h3_hash(key) & (map->capacity - 1);
+ while (map->items[slot].key) {
+ if (!strcmp(map->items[slot].key, key)) {
+ if (value) *value = map->items[slot].value;
+ return 1;
+ }
+ slot = (slot + 1) & (map->capacity - 1);
+ }
+ return 0;
+}
+
+static void h3_map_free(h3_map *map) {
+ for (size_t index = 0; index < map->capacity; index++) free(map->items[index].key);
+ free(map->items);
+ memset(map, 0, sizeof(*map));
+}
+
+static void h3_json_space(h3_json *json) {
+ while (json->cursor < json->end && isspace((unsigned char)*json->cursor)) json->cursor++;
+}
+
+static int h3_json_fail(h3_json *json, const char *message) {
+ if (!json->message[0]) snprintf(json->message, sizeof(json->message), "%s", message);
+ return 0;
+}
+
+static int h3_json_take(h3_json *json, char wanted) {
+ h3_json_space(json);
+ if (json->cursor >= json->end || *json->cursor != wanted)
+ return h3_json_fail(json, "malformed tokenizer JSON");
+ json->cursor++;
+ return 1;
+}
+
+static int h3_utf8_append(char **buffer, size_t *length, size_t *capacity,
+ uint32_t codepoint) {
+ unsigned char bytes[4];
+ int count;
+ if (codepoint <= 0x7f) { bytes[0] = (unsigned char)codepoint; count = 1; }
+ else if (codepoint <= 0x7ff) {
+ bytes[0] = (unsigned char)(0xc0 | (codepoint >> 6));
+ bytes[1] = (unsigned char)(0x80 | (codepoint & 0x3f)); count = 2;
+ } else if (codepoint <= 0xffff) {
+ bytes[0] = (unsigned char)(0xe0 | (codepoint >> 12));
+ bytes[1] = (unsigned char)(0x80 | ((codepoint >> 6) & 0x3f));
+ bytes[2] = (unsigned char)(0x80 | (codepoint & 0x3f)); count = 3;
+ } else if (codepoint <= 0x10ffff) {
+ bytes[0] = (unsigned char)(0xf0 | (codepoint >> 18));
+ bytes[1] = (unsigned char)(0x80 | ((codepoint >> 12) & 0x3f));
+ bytes[2] = (unsigned char)(0x80 | ((codepoint >> 6) & 0x3f));
+ bytes[3] = (unsigned char)(0x80 | (codepoint & 0x3f)); count = 4;
+ } else return 0;
+ if (*length + (size_t)count + 1 > *capacity) {
+ size_t next = *capacity ? *capacity * 2 : 32;
+ while (next < *length + (size_t)count + 1) next *= 2;
+ char *grown = realloc(*buffer, next);
+ if (!grown) return 0;
+ *buffer = grown; *capacity = next;
+ }
+ memcpy(*buffer + *length, bytes, (size_t)count);
+ *length += (size_t)count;
+ (*buffer)[*length] = '\0';
+ return 1;
+}
+
+static int h3_hex(char value) {
+ if (value >= '0' && value <= '9') return value - '0';
+ if (value >= 'a' && value <= 'f') return value - 'a' + 10;
+ if (value >= 'A' && value <= 'F') return value - 'A' + 10;
+ return -1;
+}
+
+static int h3_json_u16(h3_json *json, uint32_t *value) {
+ if (json->end - json->cursor < 4) return h3_json_fail(json, "truncated JSON escape");
+ uint32_t result = 0;
+ for (int index = 0; index < 4; index++) {
+ int digit = h3_hex(json->cursor[index]);
+ if (digit < 0) return h3_json_fail(json, "invalid JSON escape");
+ result = result * 16 + (uint32_t)digit;
+ }
+ json->cursor += 4;
+ *value = result;
+ return 1;
+}
+
+static char *h3_json_string(h3_json *json) {
+ if (!h3_json_take(json, '"')) return NULL;
+ char *result = NULL;
+ size_t length = 0, capacity = 0;
+ while (json->cursor < json->end && *json->cursor != '"') {
+ unsigned char value = (unsigned char)*json->cursor++;
+ uint32_t codepoint = value;
+ if (value == '\\') {
+ if (json->cursor >= json->end) goto malformed;
+ char escape = *json->cursor++;
+ if (escape == '"' || escape == '\\' || escape == '/') codepoint = (uint32_t)escape;
+ else if (escape == 'b') codepoint = '\b';
+ else if (escape == 'f') codepoint = '\f';
+ else if (escape == 'n') codepoint = '\n';
+ else if (escape == 'r') codepoint = '\r';
+ else if (escape == 't') codepoint = '\t';
+ else if (escape == 'u') {
+ if (!h3_json_u16(json, &codepoint)) goto malformed;
+ if (codepoint >= 0xd800 && codepoint <= 0xdbff) {
+ if (json->end - json->cursor < 6 || json->cursor[0] != '\\' || json->cursor[1] != 'u') goto malformed;
+ json->cursor += 2;
+ uint32_t low;
+ if (!h3_json_u16(json, &low) || low < 0xdc00 || low > 0xdfff) goto malformed;
+ codepoint = 0x10000 + ((codepoint - 0xd800) << 10) + (low - 0xdc00);
+ }
+ } else goto malformed;
+ if (!h3_utf8_append(&result, &length, &capacity, codepoint)) goto memory;
+ } else {
+ if (value < 0x20) goto malformed;
+ if (length + 2 > capacity) {
+ size_t next = capacity ? capacity * 2 : 32;
+ char *grown = realloc(result, next);
+ if (!grown) goto memory;
+ result = grown; capacity = next;
+ }
+ result[length++] = (char)value;
+ result[length] = '\0';
+ }
+ }
+ if (json->cursor >= json->end) goto malformed;
+ json->cursor++;
+ if (!result) result = calloc(1, 1);
+ return result;
+memory:
+ h3_json_fail(json, "out of memory parsing tokenizer JSON");
+ free(result); return NULL;
+malformed:
+ h3_json_fail(json, "invalid JSON string");
+ free(result); return NULL;
+}
+
+static int h3_json_uint(h3_json *json, uint32_t *value) {
+ h3_json_space(json);
+ errno = 0;
+ char *stop = NULL;
+ unsigned long parsed = strtoul(json->cursor, &stop, 10);
+ if (stop == json->cursor || errno || parsed > UINT32_MAX || stop > json->end)
+ return h3_json_fail(json, "invalid tokenizer integer");
+ json->cursor = stop;
+ *value = (uint32_t)parsed;
+ return 1;
+}
+
+static int h3_json_literal(h3_json *json, const char *literal) {
+ h3_json_space(json);
+ size_t length = strlen(literal);
+ if ((size_t)(json->end - json->cursor) < length ||
+ memcmp(json->cursor, literal, length)) return 0;
+ json->cursor += length;
+ return 1;
+}
+
+static int h3_json_skip(h3_json *json);
+
+static int h3_json_skip_array(h3_json *json) {
+ if (!h3_json_take(json, '[')) return 0;
+ h3_json_space(json);
+ if (json->cursor < json->end && *json->cursor == ']') { json->cursor++; return 1; }
+ for (;;) {
+ if (!h3_json_skip(json)) return 0;
+ h3_json_space(json);
+ if (json->cursor < json->end && *json->cursor == ']') { json->cursor++; return 1; }
+ if (!h3_json_take(json, ',')) return 0;
+ }
+}
+
+static int h3_json_skip_object(h3_json *json) {
+ if (!h3_json_take(json, '{')) return 0;
+ h3_json_space(json);
+ if (json->cursor < json->end && *json->cursor == '}') { json->cursor++; return 1; }
+ for (;;) {
+ char *key = h3_json_string(json);
+ if (!key) return 0;
+ free(key);
+ if (!h3_json_take(json, ':') || !h3_json_skip(json)) return 0;
+ h3_json_space(json);
+ if (json->cursor < json->end && *json->cursor == '}') { json->cursor++; return 1; }
+ if (!h3_json_take(json, ',')) return 0;
+ }
+}
+
+static int h3_json_skip(h3_json *json) {
+ h3_json_space(json);
+ if (json->cursor >= json->end) return h3_json_fail(json, "truncated JSON value");
+ if (*json->cursor == '"') { char *text = h3_json_string(json); free(text); return text != NULL; }
+ if (*json->cursor == '{') return h3_json_skip_object(json);
+ if (*json->cursor == '[') return h3_json_skip_array(json);
+ if (h3_json_literal(json, "true") || h3_json_literal(json, "false") || h3_json_literal(json, "null")) return 1;
+ char *stop = NULL;
+ (void)strtod(json->cursor, &stop);
+ if (stop == json->cursor) return h3_json_fail(json, "invalid JSON value");
+ json->cursor = stop;
+ return 1;
+}
+
+static char *h3_pair_key(const char *left, const char *right) {
+ size_t a = strlen(left), b = strlen(right);
+ if (a > SIZE_MAX - b - 2) return NULL;
+ char *key = malloc(a + b + 2);
+ if (!key) return NULL;
+ memcpy(key, left, a); key[a] = '\x1f';
+ memcpy(key + a + 1, right, b + 1);
+ return key;
+}
+
+static int h3_parse_vocab(h3_json *json, h3_tokenizer *tokenizer,
+ uint32_t *maximum_id) {
+ if (!h3_json_take(json, '{')) return 0;
+ h3_json_space(json);
+ if (json->cursor < json->end && *json->cursor == '}') { json->cursor++; return 1; }
+ for (;;) {
+ char *symbol = h3_json_string(json);
+ uint32_t identifier;
+ if (!symbol || !h3_json_take(json, ':') || !h3_json_uint(json, &identifier)) {
+ free(symbol); return 0;
+ }
+ if (!h3_map_put(&tokenizer->vocab, symbol, identifier))
+ return h3_json_fail(json, "out of memory loading vocabulary");
+ if (identifier > *maximum_id) *maximum_id = identifier;
+ h3_json_space(json);
+ if (json->cursor < json->end && *json->cursor == '}') { json->cursor++; return 1; }
+ if (!h3_json_take(json, ',')) return 0;
+ }
+}
+
+static int h3_parse_merges(h3_json *json, h3_tokenizer *tokenizer) {
+ if (!h3_json_take(json, '[')) return 0;
+ h3_json_space(json);
+ if (json->cursor < json->end && *json->cursor == ']') { json->cursor++; return 1; }
+ uint32_t rank = 0;
+ for (;;) {
+ h3_json_space(json);
+ char *left = NULL, *right = NULL;
+ if (json->cursor < json->end && *json->cursor == '"') {
+ char *entry = h3_json_string(json);
+ if (!entry) return 0;
+ char *space = strchr(entry, ' ');
+ if (!space) { free(entry); return h3_json_fail(json, "invalid tokenizer merge"); }
+ *space = '\0';
+ left = strdup(entry); right = strdup(space + 1); free(entry);
+ } else if (json->cursor < json->end && *json->cursor == '[') {
+ if (!h3_json_take(json, '[')) return 0;
+ left = h3_json_string(json);
+ if (!left || !h3_json_take(json, ',')) { free(left); return 0; }
+ right = h3_json_string(json);
+ if (!right || !h3_json_take(json, ']')) { free(left); free(right); return 0; }
+ } else return h3_json_fail(json, "invalid tokenizer merge");
+ char *key = left && right ? h3_pair_key(left, right) : NULL;
+ free(left); free(right);
+ if (!key || !h3_map_put(&tokenizer->merges, key, rank++)) {
+ free(key); return h3_json_fail(json, "out of memory loading merges");
+ }
+ h3_json_space(json);
+ if (json->cursor < json->end && *json->cursor == ']') { json->cursor++; return 1; }
+ if (!h3_json_take(json, ',')) return 0;
+ }
+}
+
+static int h3_parse_model(h3_json *json, h3_tokenizer *tokenizer,
+ uint32_t *maximum_id) {
+ int type_ok = 0, vocab_ok = 0, merges_ok = 0, unk_null = 0;
+ if (!h3_json_take(json, '{')) return 0;
+ h3_json_space(json);
+ while (json->cursor < json->end && *json->cursor != '}') {
+ char *key = h3_json_string(json);
+ if (!key || !h3_json_take(json, ':')) { free(key); return 0; }
+ if (!strcmp(key, "type")) {
+ char *value = h3_json_string(json);
+ type_ok = value && !strcmp(value, "BPE"); free(value);
+ } else if (!strcmp(key, "unk_token")) {
+ unk_null = h3_json_literal(json, "null");
+ if (!unk_null) { free(key); return h3_json_fail(json, "tokenizer unk_token must be null"); }
+ } else if (!strcmp(key, "vocab")) {
+ vocab_ok = h3_parse_vocab(json, tokenizer, maximum_id);
+ if (!vocab_ok) { free(key); return 0; }
+ } else if (!strcmp(key, "merges")) {
+ merges_ok = h3_parse_merges(json, tokenizer);
+ if (!merges_ok) { free(key); return 0; }
+ } else if (!h3_json_skip(json)) { free(key); return 0; }
+ free(key);
+ h3_json_space(json);
+ if (*json->cursor == ',') { json->cursor++; h3_json_space(json); }
+ else break;
+ }
+ if (!h3_json_take(json, '}')) return 0;
+ if (!type_ok || !vocab_ok || !merges_ok || !unk_null)
+ return h3_json_fail(json, "unexpected tokenizer model specification");
+ return 1;
+}
+
+static int h3_json_bool(h3_json *json, int *value) {
+ if (h3_json_literal(json, "true")) { *value = 1; return 1; }
+ if (h3_json_literal(json, "false")) { *value = 0; return 1; }
+ return h3_json_fail(json, "invalid tokenizer boolean");
+}
+
+static int h3_parse_added_item(h3_json *json, h3_tokenizer *tokenizer,
+ uint32_t *maximum_id) {
+ char *content = NULL;
+ uint32_t identifier = 0;
+ int has_id = 0, unsupported = 0;
+ if (!h3_json_take(json, '{')) return 0;
+ h3_json_space(json);
+ while (json->cursor < json->end && *json->cursor != '}') {
+ char *key = h3_json_string(json);
+ if (!key || !h3_json_take(json, ':')) { free(key); free(content); return 0; }
+ if (!strcmp(key, "content")) {
+ free(content); content = h3_json_string(json);
+ if (!content) { free(key); return 0; }
+ } else if (!strcmp(key, "id")) {
+ has_id = h3_json_uint(json, &identifier);
+ if (!has_id) { free(key); free(content); return 0; }
+ } else if (!strcmp(key, "single_word") || !strcmp(key, "lstrip") ||
+ !strcmp(key, "rstrip") || !strcmp(key, "normalized")) {
+ int enabled;
+ if (!h3_json_bool(json, &enabled)) { free(key); free(content); return 0; }
+ unsupported |= enabled;
+ } else if (!h3_json_skip(json)) { free(key); free(content); return 0; }
+ free(key);
+ h3_json_space(json);
+ if (*json->cursor == ',') { json->cursor++; h3_json_space(json); }
+ else break;
+ }
+ if (!h3_json_take(json, '}')) { free(content); return 0; }
+ if (!content || !has_id || unsupported) {
+ free(content); return h3_json_fail(json, "unsupported added-token policy");
+ }
+ if (!h3_map_put(&tokenizer->added, content, identifier))
+ return h3_json_fail(json, "out of memory loading added tokens");
+ if (identifier > *maximum_id) *maximum_id = identifier;
+ return 1;
+}
+
+static int h3_parse_added(h3_json *json, h3_tokenizer *tokenizer,
+ uint32_t *maximum_id) {
+ if (!h3_json_take(json, '[')) return 0;
+ h3_json_space(json);
+ if (*json->cursor == ']') { json->cursor++; return 1; }
+ for (;;) {
+ if (!h3_parse_added_item(json, tokenizer, maximum_id)) return 0;
+ h3_json_space(json);
+ if (*json->cursor == ']') { json->cursor++; return 1; }
+ if (!h3_json_take(json, ',')) return 0;
+ }
+}
+
+static int h3_parse_normalizer(h3_json *json) {
+ int nfc = 0;
+ if (!h3_json_take(json, '{')) return 0;
+ h3_json_space(json);
+ while (json->cursor < json->end && *json->cursor != '}') {
+ char *key = h3_json_string(json);
+ if (!key || !h3_json_take(json, ':')) { free(key); return 0; }
+ if (!strcmp(key, "type")) {
+ char *value = h3_json_string(json);
+ nfc = value && !strcmp(value, "NFC"); free(value);
+ } else if (!h3_json_skip(json)) { free(key); return 0; }
+ free(key);
+ h3_json_space(json);
+ if (*json->cursor == ',') { json->cursor++; h3_json_space(json); }
+ else break;
+ }
+ if (!h3_json_take(json, '}')) return 0;
+ return nfc ? 1 : h3_json_fail(json, "tokenizer normalizer is not NFC");
+}
+
+static int h3_parse_root(h3_json *json, h3_tokenizer *tokenizer,
+ uint32_t *maximum_id) {
+ int model = 0, normalizer = 0;
+ if (!h3_json_take(json, '{')) return 0;
+ h3_json_space(json);
+ while (json->cursor < json->end && *json->cursor != '}') {
+ char *key = h3_json_string(json);
+ if (!key || !h3_json_take(json, ':')) { free(key); return 0; }
+ if (!strcmp(key, "model")) model = h3_parse_model(json, tokenizer, maximum_id);
+ else if (!strcmp(key, "normalizer")) normalizer = h3_parse_normalizer(json);
+ else if (!strcmp(key, "added_tokens")) {
+ if (!h3_parse_added(json, tokenizer, maximum_id)) { free(key); return 0; }
+ } else if (!h3_json_skip(json)) { free(key); return 0; }
+ free(key);
+ if (json->message[0]) return 0;
+ h3_json_space(json);
+ if (*json->cursor == ',') { json->cursor++; h3_json_space(json); }
+ else break;
+ }
+ if (!h3_json_take(json, '}')) return 0;
+ h3_json_space(json);
+ if (json->cursor != json->end) return h3_json_fail(json, "trailing tokenizer JSON data");
+ if (!model || !normalizer) return h3_json_fail(json, "incomplete tokenizer specification");
+ return 1;
+}
+
+static char *h3_read_all(const char *path, size_t *size) {
+ FILE *file = fopen(path, "rb");
+ if (!file) return NULL;
+ if (fseek(file, 0, SEEK_END) || ftell(file) < 0) { fclose(file); return NULL; }
+ long length = ftell(file);
+ if (fseek(file, 0, SEEK_SET)) { fclose(file); return NULL; }
+ char *data = malloc((size_t)length + 1);
+ if (!data) { fclose(file); return NULL; }
+ size_t got = fread(data, 1, (size_t)length, file);
+ fclose(file);
+ if (got != (size_t)length) { free(data); return NULL; }
+ data[got] = '\0'; *size = got;
+ return data;
+}
+
+static char *h3_codepoint_string(uint32_t codepoint) {
+ char *result = NULL;
+ size_t length = 0, capacity = 0;
+ if (!h3_utf8_append(&result, &length, &capacity, codepoint)) return NULL;
+ return result;
+}
+
+h3_tokenizer *h3_tokenizer_load(const char *path, char *error,
+ size_t error_size) {
+ if (error && error_size) error[0] = '\0';
+ if (!path) { h3_error(error, error_size, "tokenizer path is required"); return NULL; }
+ size_t size = 0;
+ char *data = h3_read_all(path, &size);
+ if (!data) { h3_error(error, error_size, "cannot read tokenizer JSON"); return NULL; }
+ h3_tokenizer *tokenizer = calloc(1, sizeof(*tokenizer));
+ if (!tokenizer) { free(data); h3_error(error, error_size, "out of memory"); return NULL; }
+ h3_json json = {data, data + size, {0}};
+ uint32_t maximum_id = 0;
+ if (!h3_parse_root(&json, tokenizer, &maximum_id)) {
+ h3_error(error, error_size, json.message); free(data);
+ h3_tokenizer_free(tokenizer); return NULL;
+ }
+ free(data);
+ tokenizer->inverse_count = (size_t)maximum_id + 1;
+ tokenizer->inverse_vocab = calloc(tokenizer->inverse_count, sizeof(char *));
+ tokenizer->inverse_added = calloc(tokenizer->inverse_count, sizeof(char *));
+ if (!tokenizer->inverse_vocab || !tokenizer->inverse_added) {
+ h3_error(error, error_size, "out of memory indexing vocabulary");
+ h3_tokenizer_free(tokenizer); return NULL;
+ }
+ for (size_t index = 0; index < tokenizer->vocab.capacity; index++) {
+ h3_map_item item = tokenizer->vocab.items[index];
+ if (item.key && item.value < tokenizer->inverse_count)
+ tokenizer->inverse_vocab[item.value] = item.key;
+ }
+ for (size_t index = 0; index < tokenizer->added.capacity; index++) {
+ h3_map_item item = tokenizer->added.items[index];
+ if (item.key && item.value < tokenizer->inverse_count)
+ tokenizer->inverse_added[item.value] = item.key;
+ }
+ for (size_t index = 0; index < 324; index++) tokenizer->byte_decoder[index] = -1;
+ unsigned extra = 0;
+ for (unsigned byte = 0; byte < 256; byte++) {
+ int visible = (byte >= '!' && byte <= '~') ||
+ (byte >= 0xa1 && byte <= 0xac) ||
+ (byte >= 0xae && byte <= 0xff);
+ uint32_t codepoint = visible ? byte : 256 + extra++;
+ tokenizer->byte_encoder[byte] = h3_codepoint_string(codepoint);
+ if (!tokenizer->byte_encoder[byte]) {
+ h3_error(error, error_size, "out of memory building byte codec");
+ h3_tokenizer_free(tokenizer); return NULL;
+ }
+ tokenizer->byte_decoder[codepoint] = (int16_t)byte;
+ }
+ return tokenizer;
+}
+
+void h3_tokenizer_free(h3_tokenizer *tokenizer) {
+ if (!tokenizer) return;
+ for (size_t index = 0; index < 256; index++) free(tokenizer->byte_encoder[index]);
+ free(tokenizer->inverse_vocab); free(tokenizer->inverse_added);
+ h3_map_free(&tokenizer->vocab); h3_map_free(&tokenizer->merges);
+ h3_map_free(&tokenizer->added); free(tokenizer);
+}
+
+static char *h3_nfc(const char *utf8) {
+ UErrorCode status = U_ZERO_ERROR;
+ int32_t utf16_length = 0;
+ u_strFromUTF8(NULL, 0, &utf16_length, utf8, -1, &status);
+ if (status != U_BUFFER_OVERFLOW_ERROR && U_FAILURE(status)) return NULL;
+ status = U_ZERO_ERROR;
+ UChar *utf16 = malloc(((size_t)utf16_length + 1) * sizeof(*utf16));
+ if (!utf16) return NULL;
+ u_strFromUTF8(utf16, utf16_length + 1, NULL, utf8, -1, &status);
+ const UNormalizer2 *nfc = unorm2_getNFCInstance(&status);
+ int32_t normalized_length = unorm2_normalize(nfc, utf16, utf16_length,
+ NULL, 0, &status);
+ if (status != U_BUFFER_OVERFLOW_ERROR && U_FAILURE(status)) { free(utf16); return NULL; }
+ status = U_ZERO_ERROR;
+ UChar *normalized = malloc(((size_t)normalized_length + 1) * sizeof(*normalized));
+ if (!normalized) { free(utf16); return NULL; }
+ unorm2_normalize(nfc, utf16, utf16_length, normalized,
+ normalized_length + 1, &status);
+ free(utf16);
+ if (U_FAILURE(status)) { free(normalized); return NULL; }
+ int32_t output_length = 0;
+ status = U_ZERO_ERROR;
+ u_strToUTF8(NULL, 0, &output_length, normalized, normalized_length, &status);
+ if (status != U_BUFFER_OVERFLOW_ERROR && U_FAILURE(status)) { free(normalized); return NULL; }
+ status = U_ZERO_ERROR;
+ char *output = malloc((size_t)output_length + 1);
+ if (!output) { free(normalized); return NULL; }
+ u_strToUTF8(output, output_length + 1, NULL, normalized, normalized_length, &status);
+ free(normalized);
+ if (U_FAILURE(status)) { free(output); return NULL; }
+ return output;
+}
+
+static int h3_strings_push(h3_strings *strings, char *value) {
+ if (strings->count == strings->capacity) {
+ size_t capacity = strings->capacity ? strings->capacity * 2 : 16;
+ char **values = realloc(strings->values, capacity * sizeof(*values));
+ if (!values) return 0;
+ strings->values = values; strings->capacity = capacity;
+ }
+ strings->values[strings->count++] = value;
+ return 1;
+}
+
+static void h3_strings_free(h3_strings *strings) {
+ for (size_t index = 0; index < strings->count; index++) free(strings->values[index]);
+ free(strings->values); memset(strings, 0, sizeof(*strings));
+}
+
+static int h3_ids_push(h3_ids *ids, uint32_t value) {
+ if (ids->count == ids->capacity) {
+ size_t capacity = ids->capacity ? ids->capacity * 2 : 32;
+ uint32_t *values = realloc(ids->values, capacity * sizeof(*values));
+ if (!values) return 0;
+ ids->values = values; ids->capacity = capacity;
+ }
+ ids->values[ids->count++] = value;
+ return 1;
+}
+
+static int h3_codepoints(const char *text, h3_codepoint **output,
+ size_t *count) {
+ size_t bytes = strlen(text), used = 0;
+ h3_codepoint *points = malloc((bytes ? bytes : 1) * sizeof(*points));
+ if (!points) return 0;
+ int32_t index = 0;
+ while ((size_t)index < bytes) {
+ int32_t start = index;
+ UChar32 value;
+ U8_NEXT((const uint8_t *)text, index, (int32_t)bytes, value);
+ if (value < 0) { free(points); return 0; }
+ points[used++] = (h3_codepoint){(uint32_t)value, (size_t)start,
+ (size_t)(index - start)};
+ }
+ *output = points; *count = used; return 1;
+}
+
+static int h3_letter(uint32_t value) {
+ int8_t category = u_charType((UChar32)value);
+ return category == U_UPPERCASE_LETTER || category == U_LOWERCASE_LETTER ||
+ category == U_TITLECASE_LETTER || category == U_MODIFIER_LETTER ||
+ category == U_OTHER_LETTER;
+}
+
+static int h3_number(uint32_t value) {
+ int8_t category = u_charType((UChar32)value);
+ return category == U_DECIMAL_DIGIT_NUMBER || category == U_LETTER_NUMBER ||
+ category == U_OTHER_NUMBER;
+}
+
+static int h3_space(uint32_t value) {
+ return u_isUWhiteSpace((UChar32)value) || (value >= 0x1c && value <= 0x1f);
+}
+
+static char *h3_slice(const char *text, const h3_codepoint *points,
+ size_t start, size_t stop) {
+ size_t offset = points[start].offset;
+ size_t end = points[stop - 1].offset + points[stop - 1].length;
+ char *result = malloc(end - offset + 1);
+ if (!result) return NULL;
+ memcpy(result, text + offset, end - offset); result[end - offset] = '\0';
+ return result;
+}
+
+static size_t h3_contraction(const h3_codepoint *points, size_t count,
+ size_t index) {
+ static const char *values[] = {"'s", "'t", "'re", "'ve", "'m", "'ll", "'d"};
+ if (points[index].value != '\'') return 0;
+ for (size_t item = 0; item < sizeof(values) / sizeof(values[0]); item++) {
+ size_t length = strlen(values[item]);
+ if (index + length > count) continue;
+ int matches = 1;
+ for (size_t offset = 1; offset < length; offset++) {
+ uint32_t got = points[index + offset].value;
+ if (got >= 'A' && got <= 'Z') got += 'a' - 'A';
+ if (got != (unsigned char)values[item][offset]) matches = 0;
+ }
+ if (matches) return length;
+ }
+ return 0;
+}
+
+static int h3_pretokenize(const char *input, h3_strings *pieces) {
+ char *text = h3_nfc(input);
+ if (!text) return 0;
+ h3_codepoint *points = NULL;
+ size_t count = 0;
+ if (!h3_codepoints(text, &points, &count)) { free(text); return 0; }
+ size_t index = 0;
+ while (index < count) {
+ size_t contraction = h3_contraction(points, count, index);
+ size_t stop = index;
+ if (contraction) stop = index + contraction;
+ else {
+ uint32_t value = points[index].value;
+ ptrdiff_t letter_start = (ptrdiff_t)index;
+ if (!h3_letter(value)) {
+ if (value != '\r' && value != '\n' && !h3_number(value) &&
+ index + 1 < count && h3_letter(points[index + 1].value))
+ letter_start++;
+ else letter_start = -1;
+ }
+ if (letter_start >= 0) {
+ stop = (size_t)letter_start;
+ while (stop < count && h3_letter(points[stop].value)) stop++;
+ } else if (h3_number(value)) stop = index + 1;
+ else {
+ size_t punct_start = index +
+ (value == ' ' && index + 1 < count &&
+ !h3_space(points[index + 1].value) &&
+ !h3_letter(points[index + 1].value) &&
+ !h3_number(points[index + 1].value));
+ stop = punct_start;
+ while (stop < count && !h3_space(points[stop].value) &&
+ !h3_letter(points[stop].value) &&
+ !h3_number(points[stop].value)) stop++;
+ if (stop > punct_start) {
+ while (stop < count && (points[stop].value == '\r' ||
+ points[stop].value == '\n')) stop++;
+ } else if (h3_space(value)) {
+ size_t whitespace_end = index + 1;
+ while (whitespace_end < count && h3_space(points[whitespace_end].value)) whitespace_end++;
+ ptrdiff_t newline_end = -1;
+ for (size_t cursor = index; cursor < whitespace_end; cursor++)
+ if (points[cursor].value == '\r' || points[cursor].value == '\n')
+ newline_end = (ptrdiff_t)cursor + 1;
+ if (newline_end >= 0) stop = (size_t)newline_end;
+ else if (whitespace_end == count) stop = whitespace_end;
+ else if (whitespace_end - index > 1) stop = whitespace_end - 1;
+ else stop = index + 1;
+ } else { free(points); free(text); return 0; }
+ }
+ }
+ char *piece = h3_slice(text, points, index, stop);
+ if (!piece || !h3_strings_push(pieces, piece)) {
+ free(piece); free(points); free(text); return 0;
+ }
+ index = stop;
+ }
+ free(points); free(text); return 1;
+}
+
+static int h3_bpe(const h3_tokenizer *tokenizer, const char *piece,
+ h3_ids *output) {
+ h3_strings symbols = {0};
+ for (const unsigned char *byte = (const unsigned char *)piece; *byte; byte++) {
+ char *symbol = strdup(tokenizer->byte_encoder[*byte]);
+ if (!symbol || !h3_strings_push(&symbols, symbol)) {
+ free(symbol); h3_strings_free(&symbols); return 0;
+ }
+ }
+ while (symbols.count > 1) {
+ uint32_t best_rank = UINT32_MAX;
+ size_t best = SIZE_MAX;
+ for (size_t index = 0; index + 1 < symbols.count; index++) {
+ char *key = h3_pair_key(symbols.values[index], symbols.values[index + 1]);
+ uint32_t rank;
+ int found = key && h3_map_get(&tokenizer->merges, key, &rank);
+ free(key);
+ if (found && rank < best_rank) { best_rank = rank; best = index; }
+ }
+ if (best == SIZE_MAX) break;
+ const char *left = symbols.values[best], *right = symbols.values[best + 1];
+ h3_strings merged = {0};
+ for (size_t index = 0; index < symbols.count;) {
+ if (index + 1 < symbols.count && !strcmp(symbols.values[index], left) &&
+ !strcmp(symbols.values[index + 1], right)) {
+ size_t a = strlen(left), b = strlen(right);
+ char *value = malloc(a + b + 1);
+ if (value) { memcpy(value, left, a); memcpy(value + a, right, b + 1); }
+ if (!value || !h3_strings_push(&merged, value)) {
+ free(value); h3_strings_free(&merged); h3_strings_free(&symbols); return 0;
+ }
+ index += 2;
+ } else {
+ char *value = strdup(symbols.values[index++]);
+ if (!value || !h3_strings_push(&merged, value)) {
+ free(value); h3_strings_free(&merged); h3_strings_free(&symbols); return 0;
+ }
+ }
+ }
+ h3_strings_free(&symbols); symbols = merged;
+ }
+ for (size_t index = 0; index < symbols.count; index++) {
+ uint32_t identifier;
+ if (!h3_map_get(&tokenizer->vocab, symbols.values[index], &identifier) ||
+ !h3_ids_push(output, identifier)) {
+ h3_strings_free(&symbols); return 0;
+ }
+ }
+ h3_strings_free(&symbols); return 1;
+}
+
+static int h3_encode_plain(const h3_tokenizer *tokenizer, const char *text,
+ h3_ids *output) {
+ h3_strings pieces = {0};
+ if (!h3_pretokenize(text, &pieces)) return 0;
+ for (size_t index = 0; index < pieces.count; index++)
+ if (!h3_bpe(tokenizer, pieces.values[index], output)) {
+ h3_strings_free(&pieces); return 0;
+ }
+ h3_strings_free(&pieces); return 1;
+}
+
+static int h3_added_match(const h3_tokenizer *tokenizer, const char *text,
+ size_t start, size_t *offset, size_t *length,
+ uint32_t *identifier) {
+ int found = 0;
+ for (size_t index = 0; index < tokenizer->added.capacity; index++) {
+ h3_map_item item = tokenizer->added.items[index];
+ if (!item.key) continue;
+ const char *match = strstr(text + start, item.key);
+ if (!match) continue;
+ size_t at = (size_t)(match - text), size = strlen(item.key);
+ if (!found || at < *offset || (at == *offset && size > *length)) {
+ found = 1; *offset = at; *length = size; *identifier = item.value;
+ }
+ }
+ return found;
+}
+
+int h3_tokenizer_encode(const h3_tokenizer *tokenizer, const char *utf8,
+ int pad_empty, uint32_t **ids, size_t *count,
+ char *error, size_t error_size) {
+ if (error && error_size) error[0] = '\0';
+ if (!tokenizer || !utf8 || !ids || !count) return 0;
+ *ids = NULL; *count = 0;
+ h3_codepoint *validation = NULL; size_t validation_count = 0;
+ if (!h3_codepoints(utf8, &validation, &validation_count)) {
+ h3_error(error, error_size, "prompt is not valid UTF-8"); return 0;
+ }
+ free(validation);
+ h3_ids output = {0};
+ size_t start = 0, text_length = strlen(utf8);
+ while (start < text_length) {
+ size_t offset = 0, length = 0; uint32_t identifier = 0;
+ if (!h3_added_match(tokenizer, utf8, start, &offset, &length, &identifier)) break;
+ if (offset > start) {
+ char *plain = strndup(utf8 + start, offset - start);
+ int ok = plain && h3_encode_plain(tokenizer, plain, &output);
+ free(plain);
+ if (!ok) goto failure;
+ }
+ if (!h3_ids_push(&output, identifier)) goto failure;
+ start = offset + length;
+ }
+ if (start < text_length && !h3_encode_plain(tokenizer, utf8 + start, &output)) goto failure;
+ if (!output.count && pad_empty && !h3_ids_push(&output, H3_PAD_TOKEN_ID)) goto failure;
+ *ids = output.values; *count = output.count; return 1;
+failure:
+ free(output.values); h3_error(error, error_size, "unable to encode prompt"); return 0;
+}
+
+void h3_tokenizer_ids_free(uint32_t *ids) { free(ids); }
+
+static int h3_bytes_append(char **output, size_t *length, size_t *capacity,
+ const void *data, size_t bytes) {
+ if (*length + bytes + 1 > *capacity) {
+ size_t next = *capacity ? *capacity * 2 : 64;
+ while (next < *length + bytes + 1) next *= 2;
+ char *grown = realloc(*output, next);
+ if (!grown) return 0;
+ *output = grown; *capacity = next;
+ }
+ memcpy(*output + *length, data, bytes); *length += bytes;
+ (*output)[*length] = '\0'; return 1;
+}
+
+char *h3_tokenizer_decode(const h3_tokenizer *tokenizer,
+ const uint32_t *ids, size_t count,
+ char *error, size_t error_size) {
+ if (error && error_size) error[0] = '\0';
+ if (!tokenizer || (!ids && count)) return NULL;
+ char *result = NULL;
+ size_t length = 0, capacity = 0;
+ for (size_t index = 0; index < count; index++) {
+ uint32_t identifier = ids[index];
+ if (identifier >= tokenizer->inverse_count) {
+ h3_error(error, error_size, "token ID is out of range"); free(result); return NULL;
+ }
+ const char *added = tokenizer->inverse_added[identifier];
+ if (added) {
+ if (!h3_bytes_append(&result, &length, &capacity, added, strlen(added))) goto memory;
+ continue;
+ }
+ const char *symbol = tokenizer->inverse_vocab[identifier];
+ if (!symbol) {
+ h3_error(error, error_size, "unknown token ID"); free(result); return NULL;
+ }
+ int32_t offset = 0, symbol_length = (int32_t)strlen(symbol);
+ while (offset < symbol_length) {
+ UChar32 codepoint;
+ U8_NEXT((const uint8_t *)symbol, offset, symbol_length, codepoint);
+ if (codepoint < 0 || codepoint >= 324 ||
+ tokenizer->byte_decoder[codepoint] < 0) {
+ h3_error(error, error_size, "invalid byte-level token");
+ free(result); return NULL;
+ }
+ unsigned char byte = (unsigned char)tokenizer->byte_decoder[codepoint];
+ if (!h3_bytes_append(&result, &length, &capacity, &byte, 1)) goto memory;
+ }
+ }
+ if (!result) result = calloc(1, 1);
+ if (!result) goto memory;
+ h3_codepoint *validation = NULL; size_t validation_count = 0;
+ if (!h3_codepoints(result, &validation, &validation_count)) {
+ free(result); result = strdup("\xef\xbf\xbd");
+ }
+ free(validation);
+ return result;
+memory:
+ h3_error(error, error_size, "out of memory decoding tokens");
+ free(result); return NULL;
+}
diff --git a/install.sh b/install.sh
new file mode 100755
index 00000000..d8f85105
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,308 @@
+#!/usr/bin/env bash
+# install.sh — prepares a machine to run h3c studio.
+#
+# It checks the prerequisites, puts the repository in place and writes a .env.
+# By default it downloads nothing: the 465 GB checkpoint and the optional
+# face-swapping runtime are asked for, one at a time, and can be declined.
+#
+# Read this file before running it. It is deliberately not written to be piped
+# from a URL into a shell: fetch it, read it, then run it.
+#
+# ./install.sh --dir ~/h3 # repository and .env only
+# ./install.sh --dir ~/h3 --with-model
+# ./install.sh --yes # no questions, defaults, no downloads
+#
+# MIT licensed, like the rest of this repository.
+
+set -euo pipefail
+
+REPO_URL="https://github.com/matrixfede/h3.c.git"
+REPO_BRANCH=""
+MODEL_REPO="MiniMaxAI/MiniMax-H3"
+# The optional face-swapping runtime. Its models are its own business: it
+# fetches them itself, on first use, from its own sources.
+FACEFUSION_URL="https://github.com/facefusion/facefusion.git"
+# The checkpoint is about 465 GB; leave room for the images and a few videos.
+MODEL_GIB=480
+WORK_GIB=20
+
+DEST=""
+WANT_MODEL=""
+WANT_FACESWAP=""
+ASSUME_YES=0
+
+say() { printf '\n\033[1m%s\033[0m\n' "$*"; }
+info() { printf ' %s\n' "$*"; }
+warn() { printf ' ! %s\n' "$*" >&2; }
+die() { printf '\n! %s\n' "$*" >&2; exit 1; }
+
+usage() {
+ sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'
+ cat <<'EOF'
+
+Options:
+ --dir DIR where the repository goes (default: the current checkout,
+ otherwise ./h3.c)
+ --repo URL repository to clone from (default: the one above)
+ --branch NAME branch to clone (default: whatever the remote's is)
+ --with-model download the MiniMax-H3 checkpoint (about 465 GB)
+ --without-model do not download it, and do not ask
+ --with-faceswap install the optional face-swapping runtime
+ --without-faceswap
+ do not install it, and do not ask
+ --yes never ask; anything not requested with a flag is skipped
+ -h, --help this text
+EOF
+}
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --dir) DEST="${2:-}"; shift 2 || die "--dir needs a directory";;
+ --dir=*) DEST="${1#*=}"; shift;;
+ --repo) REPO_URL="${2:-}"; shift 2 || die "--repo needs a URL";;
+ --repo=*) REPO_URL="${1#*=}"; shift;;
+ --branch) REPO_BRANCH="${2:-}"; shift 2 || die "--branch needs a name";;
+ --branch=*) REPO_BRANCH="${1#*=}"; shift;;
+ --with-model) WANT_MODEL=yes; shift;;
+ --without-model) WANT_MODEL=no; shift;;
+ --with-faceswap) WANT_FACESWAP=yes; shift;;
+ --without-faceswap) WANT_FACESWAP=no; shift;;
+ --yes|-y) ASSUME_YES=1; shift;;
+ -h|--help) usage; exit 0;;
+ *) usage >&2; die "unknown option: $1";;
+ esac
+done
+
+# A question only where there is someone to answer it. Everywhere else the
+# answer is no, because the expensive choices are the optional ones.
+ask() {
+ local question="$1"
+ if [ "$ASSUME_YES" = 1 ] || [ ! -t 0 ]; then return 1; fi
+ local reply=""
+ read -r -p " $question [y/N] " reply || return 1
+ case "$reply" in [yY]|[yY][eE][sS]) return 0;; *) return 1;; esac
+}
+
+have() { command -v "$1" >/dev/null 2>&1; }
+
+free_gib() {
+ # Space on the filesystem that will hold this path, existing or not.
+ local path="$1"
+ while [ ! -d "$path" ] && [ "$path" != "/" ]; do path=$(dirname "$path"); done
+ df -PB1G "$path" | awk 'NR == 2 { print $4 }'
+}
+
+check_prerequisites() {
+ say "Checking what this machine already has"
+ local missing=()
+ have git || missing+=("git — to fetch the repository")
+ # Writing the .env below is a python3 heredoc: needed on every path.
+ have python3 || missing+=("python3 — to write the .env")
+
+ # Two ways to build and run: the containers, or the compiler on the host.
+ if have docker; then
+ info "docker: yes"
+ # ffmpeg and ICU live in the images; the host does not need them.
+ info "ffmpeg/ICU: not required on the host — the images carry them"
+ elif have nvcc && have make; then
+ info "docker: no, but nvcc and make are here — the local path works"
+ have ffmpeg || missing+=("ffmpeg — h3 muxes video and audio with it")
+ { have pkg-config && pkg-config --modversion icu-uc >/dev/null 2>&1; } ||
+ missing+=("pkg-config + libicu-dev — h3 links ICU (README: 72+)")
+ else
+ missing+=("docker, or nvcc and make — one of the two ways to build h3")
+ fi
+ have git && info "git: yes"
+ have python3 && info "python3: yes"
+
+ if [ ${#missing[@]} -gt 0 ]; then
+ printf '\n' >&2
+ for item in "${missing[@]}"; do warn "missing: $item"; done
+ die "install what is missing above, then run this again."
+ fi
+}
+
+require_space() {
+ local path="$1" needed="$2" what="$3" free
+ free=$(free_gib "$path")
+ info "free space for $what: ${free} GiB (about ${needed} GiB needed)"
+ [ "$free" -ge "$needed" ] ||
+ die "not enough room for $what: ${free} GiB free, about ${needed} GiB needed."
+}
+
+# Where the repository is, or is going to be. Running this from inside a
+# checkout uses that checkout: no second copy of the same thing.
+resolve_destination() {
+ local here=""
+ here=$(cd -- "$(dirname -- "$0")" && pwd)
+ if [ -z "$DEST" ] && [ -f "$here/docker-compose.yml" ] && [ -f "$here/h3.c" ]; then
+ DEST="$here"
+ fi
+ DEST="${DEST:-$PWD/h3.c}"
+ mkdir -p -- "$(dirname -- "$DEST")"
+}
+
+fetch_repository() {
+ if [ -f "$DEST/docker-compose.yml" ] && [ -f "$DEST/h3.c" ]; then
+ say "Repository"
+ info "already at $DEST — left as it is"
+ return
+ fi
+ [ ! -e "$DEST" ] || [ -z "$(ls -A -- "$DEST" 2>/dev/null)" ] ||
+ die "$DEST exists and is not an h3.c checkout. Pick another --dir."
+ say "Fetching the repository into $DEST"
+ if [ -n "$REPO_BRANCH" ]; then
+ git clone --depth 1 --branch "$REPO_BRANCH" "$REPO_URL" "$DEST"
+ else
+ git clone --depth 1 "$REPO_URL" "$DEST"
+ fi
+}
+
+# A clone of the wrong branch is the likeliest way to end up here with a
+# directory that looks right and has no Studio in it. Say so, instead of
+# failing later on a missing file.
+verify_checkout() {
+ local missing=""
+ for needed in .env.example docker-compose.yml webui/backend/app/main.py; do
+ [ -e "$DEST/$needed" ] || missing="$missing $needed"
+ done
+ [ -z "$missing" ] || die "$DEST has no web UI in it (missing:$missing).
+ That branch of the repository does not carry it. Clone one that does:
+ $0 --dir $DEST --branch "
+}
+
+# The checkpoint. Around 465 GB, hours of download, and the one thing without
+# which nothing can be generated — so it is asked for explicitly and can be
+# declined. The commands are the ones the README documents, unchanged.
+model_present() {
+ [ -f "$1/model_index.json" ] && [ -d "$1/FL2VA" ] && [ -d "$1/Ref2VA" ]
+}
+
+download_model() {
+ local dir="$1"
+ if model_present "$dir"; then
+ info "already complete in $dir — not downloading it again"
+ return
+ fi
+ have hf || die "the Hugging Face CLI is missing. Install it with:
+ pip install -U \"huggingface_hub[cli]\"
+ then run this again, or download the checkpoint yourself into $dir."
+ require_space "$dir" "$MODEL_GIB" "the checkpoint"
+ info "about 465 GB from $MODEL_REPO — this takes hours and resumes if cut"
+ info "a gated or private repository needs 'hf auth login' first"
+ hf download "$MODEL_REPO" --local-dir "$dir"
+ info "checking that every file arrived"
+ hf cache verify "$MODEL_REPO" --local-dir "$dir" --fail-on-missing-files
+}
+
+# Writes or replaces one line of the .env, leaving everything else alone.
+set_env_var() {
+ python3 - "$DEST/.env" "$1" "$2" <<'ENVPY'
+import sys
+path, key, value = sys.argv[1], sys.argv[2], sys.argv[3]
+lines = open(path).read().splitlines(True)
+for index, line in enumerate(lines):
+ if line.startswith(f"{key}="):
+ lines[index] = f"{key}={value}\n"
+ break
+else:
+ lines.append(f"{key}={value}\n")
+open(path, "w").writelines(lines)
+ENVPY
+}
+
+# The optional face-swapping runtime. Off unless asked for: nothing is
+# installed by a default answer, and the licence and consent notice is printed
+# before anything is fetched.
+faceswap_notice() {
+ info "FaceFusion is a separate project, with its own licence, and it"
+ info "downloads its own models the first time it runs. The face-swapping"
+ info "models known to us are licensed for non-commercial or research use"
+ info "only: checking what you install is your responsibility."
+ info "Never use face replacement on a real person who has not agreed to it."
+}
+
+install_faceswap() {
+ local dir="$DEST/vendor/facefusion"
+ if [ -f "$dir/facefusion.py" ]; then
+ info "already installed in $dir — left as it is"
+ else
+ say "Fetching FaceFusion into $dir"
+ mkdir -p -- "$(dirname -- "$dir")"
+ git clone --depth 1 "$FACEFUSION_URL" "$dir"
+ info "running its own installer — this pulls its Python dependencies"
+ ( cd "$dir" && python3 install.py --onnxruntime default --skip-conda )
+ fi
+ set_env_var H3_FACEFUSION_DIR "$dir"
+ set_env_var H3_FACESWAP_CMD "$DEST/scripts/faceswap-facefusion.sh"
+ info "wrote H3_FACESWAP_CMD into $DEST/.env"
+ info "one thing is still yours to set: H3_FACESWAP_SOURCE, the image of"
+ info "the face to use. Until it is set, the stage refuses to run."
+}
+
+# The .env is the user's file: written once, never overwritten.
+write_env() {
+ local env="$DEST/.env" model_dir="$1"
+ say "Configuration"
+ if [ -f "$env" ]; then
+ info ".env already exists — left as it is"
+ return
+ fi
+ cp -- "$DEST/.env.example" "$env"
+ # The value can contain slashes, so the substitution is not a s|..| one.
+ python3 - "$env" "$model_dir" <<'PY'
+import sys
+path, model = sys.argv[1], sys.argv[2]
+lines = []
+for line in open(path).read().splitlines(True):
+ if line.startswith("H3_MODEL_DIR="):
+ line = f"H3_MODEL_DIR={model}\n"
+ lines.append(line)
+open(path, "w").writelines(lines)
+PY
+ info "wrote $env with H3_MODEL_DIR=$model_dir"
+}
+
+main() {
+ resolve_destination
+ check_prerequisites
+ require_space "$DEST" "$WORK_GIB" "the build and the videos"
+ fetch_repository
+ verify_checkout
+
+ local model_dir="$DEST/MiniMax-H3"
+ write_env "$model_dir"
+
+ say "The MiniMax-H3 checkpoint"
+ if [ -z "$WANT_MODEL" ]; then
+ info "about 465 GB. Nothing can be generated without it, but it can"
+ info "be fetched later: ./install.sh --dir $DEST --with-model"
+ if ask "Download it now?"; then WANT_MODEL=yes; else WANT_MODEL=no; fi
+ fi
+ if [ "$WANT_MODEL" = yes ]; then
+ download_model "$model_dir"
+ else
+ info "skipped — fetch it later with: ./install.sh --dir $DEST --with-model"
+ fi
+
+ say "Optional: replacing faces in the finished video"
+ faceswap_notice
+ if [ -z "$WANT_FACESWAP" ]; then
+ if ask "Install FaceFusion now?"; then
+ WANT_FACESWAP=yes
+ else
+ WANT_FACESWAP=no
+ fi
+ fi
+ if [ "$WANT_FACESWAP" = yes ]; then
+ install_faceswap
+ else
+ info "face replacement: skipped, and nothing was downloaded"
+ fi
+
+ say "Done"
+ info "next: cd $DEST && docker compose up --build"
+ info "then open http://127.0.0.1:8080"
+}
+
+main "$@"
diff --git a/main.c b/main.c
index 7f11e470..da07287e 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 a PPM preview after every denoising step\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"
@@ -130,9 +131,11 @@ static void print_info(const h3_ctx *ctx) {
printf("Device: %s (%s)\n", device->name, device->architecture);
printf(" physical memory %.1f GiB\n", gib(device->physical_memory));
printf(" recommended GPU set %.1f GiB\n", gib(device->recommended_working_set));
- printf(" max Metal buffer %.1f GiB\n", gib(device->max_buffer_length));
- printf(" Apple GPU family %d\n", device->apple_gpu_family);
- printf(" Metal 4 %s\n", device->metal4 ? "yes" : "no");
+ printf(" max GPU buffer %.1f GiB\n", gib(device->max_buffer_length));
+ if (device->apple_gpu_family > 0) {
+ printf(" Apple GPU family %d\n", device->apple_gpu_family);
+ printf(" Metal 4 %s\n", device->metal4 ? "yes" : "no");
+ }
printf(" unified memory %s\n", device->unified_memory ? "yes" : "no");
printf("Native checkpoint inventory (header-only):\n");
print_component("Qwen3-VL encoder", &model->text_encoder);
@@ -151,8 +154,25 @@ typedef struct {
int display_failed;
const char *frames_dir;
int frame_write_failed;
+ const char *preview_dir;
+ int preview_write_failed;
} cli_state;
+/* Write one RGB24 frame as a binary PPM. */
+static int cli_write_ppm(const char *path, const h3_frame *frame) {
+ FILE *output = fopen(path, "wb");
+ if (!output) return 0;
+ int ok = fprintf(output, "P6\n%d %d\n255\n", frame->width,
+ frame->height) >= 0;
+ size_t row_bytes = (size_t)frame->width * 3;
+ for (int row = 0; ok && row < frame->height; row++) {
+ if (fwrite(frame->rgb + (size_t)row * frame->stride, 1, row_bytes,
+ output) != row_bytes) ok = 0;
+ }
+ if (fclose(output) != 0) ok = 0;
+ return ok;
+}
+
static int cli_progress(const char *phase, int completed, int total,
void *opaque) {
cli_state *state = opaque;
@@ -174,6 +194,23 @@ static int cli_progress(const char *phase, int completed, int total,
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->preview_write_failed) {
+ /* Write to a scratch name and rename, so a reader never sees a
+ * partial preview. */
+ char staging[1024];
+ char path[1024];
+ int staged = snprintf(staging, sizeof(staging), "%s/.step.ppm",
+ state->preview_dir);
+ int length = snprintf(path, sizeof(path), "%s/step-%04d.ppm",
+ state->preview_dir, frame->denoise_step);
+ if (staged <= 0 || (size_t)staged >= sizeof(staging) ||
+ length <= 0 || (size_t)length >= sizeof(path) ||
+ !cli_write_ppm(staging, frame) || rename(staging, path) != 0) {
+ fprintf(stderr, "h3: cannot write preview %d to %s\n",
+ frame->denoise_step, state->preview_dir);
+ state->preview_write_failed = 1;
+ }
+ }
if (!preview && state->frames_dir && !state->frame_write_failed) {
char path[1024];
int length = snprintf(path, sizeof(path), "%s/frame-%04d.ppm",
@@ -250,7 +287,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 +337,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 +351,7 @@ 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 = {{0}, 0, -1, -1, H3_TERM_NONE, 0, NULL, 0, NULL, 0};
int show = 0;
int profile = 0;
int info = 0;
@@ -452,6 +490,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 +525,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) {
@@ -498,6 +543,10 @@ int main(int argc, char **argv) {
params.on_progress = cli_progress;
params.callback_opaque = &cli;
if (cli.frames_dir) params.on_frame = cli_frame;
+ if (cli.preview_dir) {
+ params.on_frame = cli_frame;
+ params.preview_denoise = 1;
+ }
if (show) {
cli.terminal = h3_terminal_detect();
if (cli.terminal == H3_TERM_NONE) {
@@ -521,6 +570,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/package-lock.json b/package-lock.json
new file mode 100644
index 00000000..d8970cdb
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,2113 @@
+{
+ "name": "h3c-dev-tools",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "h3c-dev-tools",
+ "devDependencies": {
+ "eslint": "^9.39.0",
+ "eslint-formatter-compact": "^9.0.1",
+ "eslint-plugin-react-hooks": "^7.1.1",
+ "playwright": "^1.57.0",
+ "typescript": "^6.0.3",
+ "typescript-eslint": "^8.68.0"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
+ "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@babel/types": "^7.29.8",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
+ "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.8"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
+ "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.8",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.8",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.8",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.10.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
+ "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.21.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz",
+ "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^2.1.7",
+ "debug": "^4.3.1",
+ "minimatch": "^3.1.5"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
+ "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
+ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "3.3.6",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz",
+ "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.14.0",
+ "debug": "^4.3.2",
+ "espree": "^10.0.1",
+ "globals": "^14.0.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.3.0",
+ "minimatch": "^3.1.5",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "9.39.5",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz",
+ "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
+ "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
+ "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
+ "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/types": "^0.15.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.8",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
+ "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.2",
+ "@humanfs/types": "^0.15.0",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/types": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
+ "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz",
+ "integrity": "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.68.0",
+ "@typescript-eslint/type-utils": "8.68.0",
+ "@typescript-eslint/utils": "8.68.0",
+ "@typescript-eslint/visitor-keys": "8.68.0",
+ "ignore": "^7.0.5",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.68.0",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz",
+ "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.68.0.tgz",
+ "integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.68.0",
+ "@typescript-eslint/types": "8.68.0",
+ "@typescript-eslint/typescript-estree": "8.68.0",
+ "@typescript-eslint/visitor-keys": "8.68.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.68.0.tgz",
+ "integrity": "sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.68.0",
+ "@typescript-eslint/types": "^8.68.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.68.0.tgz",
+ "integrity": "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.68.0",
+ "@typescript-eslint/visitor-keys": "8.68.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.68.0.tgz",
+ "integrity": "sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.68.0.tgz",
+ "integrity": "sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.68.0",
+ "@typescript-eslint/typescript-estree": "8.68.0",
+ "@typescript-eslint/utils": "8.68.0",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.68.0.tgz",
+ "integrity": "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.68.0.tgz",
+ "integrity": "sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.68.0",
+ "@typescript-eslint/tsconfig-utils": "8.68.0",
+ "@typescript-eslint/types": "8.68.0",
+ "@typescript-eslint/visitor-keys": "8.68.0",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
+ "version": "10.2.6",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
+ "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.8"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.68.0.tgz",
+ "integrity": "sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.68.0",
+ "@typescript-eslint/types": "8.68.0",
+ "@typescript-eslint/typescript-estree": "8.68.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.68.0.tgz",
+ "integrity": "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.68.0",
+ "eslint-visitor-keys": "^5.0.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
+ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.11.19",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.19.tgz",
+ "integrity": "sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.8",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
+ "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.11.12",
+ "caniuse-lite": "^1.0.30001809",
+ "electron-to-chromium": "^1.5.402",
+ "node-releases": "^2.0.53",
+ "update-browserslist-db": "^1.3.0"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001810",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
+ "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.414",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.414.tgz",
+ "integrity": "sha512-aYlviXiaXBbzvKgyALpcMmqa3Np3sDr0XnZbEG62n2UpZFbEcjQ4EEMOLGzVPhwVnwTz0lvKY+GcARbunuHekw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "9.39.5",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz",
+ "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==",
+ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.1",
+ "@eslint/config-array": "^0.21.2",
+ "@eslint/config-helpers": "^0.4.2",
+ "@eslint/core": "^0.17.0",
+ "@eslint/eslintrc": "^3.3.6",
+ "@eslint/js": "9.39.5",
+ "@eslint/plugin-kit": "^0.4.1",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.14.0",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^8.4.0",
+ "eslint-visitor-keys": "^4.2.1",
+ "espree": "^10.4.0",
+ "esquery": "^1.5.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.5",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-formatter-compact": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-formatter-compact/-/eslint-formatter-compact-9.0.1.tgz",
+ "integrity": "sha512-mBAti2tb403dQGMyilQTYHU80stem3N7jdtKW+tmn5gj3JNF7ki0rgCZtJFw4iMayTH862FTUIqCdp70ug0S0Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/eslint-plugin-react-hooks": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
+ "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.24.4",
+ "@babel/parser": "^7.24.4",
+ "hermes-parser": "^0.25.1",
+ "zod": "^3.25.0 || ^4.0.0",
+ "zod-validation-error": "^3.5.0 || ^4.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
+ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
+ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.15.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.4.4",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
+ "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/globals": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/hermes-estree": {
+ "version": "0.25.1",
+ "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
+ "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/hermes-parser": {
+ "version": "0.25.1",
+ "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
+ "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hermes-estree": "0.25.1"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
+ "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.53",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
+ "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
+ "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.62.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
+ "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
+ "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/typescript-eslint": {
+ "version": "8.68.0",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.68.0.tgz",
+ "integrity": "sha512-MHy0Y0ynqeEbx/S45+i/bBssdy3X6KNBfmJAP35GrgtNxu2TQ5K5xsFDhAnmsq1jvpdoZOPG1LGtJo0HWqYCrQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.68.0",
+ "@typescript-eslint/parser": "8.68.0",
+ "@typescript-eslint/typescript-estree": "8.68.0",
+ "@typescript-eslint/utils": "8.68.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz",
+ "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zod-validation-error": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
+ "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.0 || ^4.0.0"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 00000000..7777f9a3
--- /dev/null
+++ b/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "h3c-dev-tools",
+ "private": true,
+ "type": "module",
+ "description": "Repo-level dev tooling: Playwright for scripts/snapshot_ui.mjs and ESLint for the verify.sh static gate.",
+ "devDependencies": {
+ "eslint": "^9.39.0",
+ "eslint-formatter-compact": "^9.0.1",
+ "eslint-plugin-react-hooks": "^7.1.1",
+ "playwright": "^1.57.0",
+ "typescript": "^6.0.3",
+ "typescript-eslint": "^8.68.0"
+ }
+}
diff --git a/scripts/benchmark_sdpa.py b/scripts/benchmark_sdpa.py
new file mode 100644
index 00000000..33a0bee1
--- /dev/null
+++ b/scripts/benchmark_sdpa.py
@@ -0,0 +1,84 @@
+#!/usr/bin/env python3
+"""Benchmark CUDA SDPA backends on the MiniMax-H3 attention shape."""
+
+import argparse
+import statistics
+
+import torch
+from torch.nn.attention import SDPBackend, sdpa_kernel
+
+
+BACKENDS = (
+ ("flash", SDPBackend.FLASH_ATTENTION),
+ ("cudnn", SDPBackend.CUDNN_ATTENTION),
+ ("efficient", SDPBackend.EFFICIENT_ATTENTION),
+)
+
+
+def run_backend(name, backend, query, key, value, runs):
+ torch.cuda.reset_peak_memory_stats()
+ with sdpa_kernel(backend):
+ output = torch.nn.functional.scaled_dot_product_attention(
+ query, key, value
+ )
+ torch.cuda.synchronize()
+ times = []
+ for _ in range(runs):
+ begin = torch.cuda.Event(enable_timing=True)
+ end = torch.cuda.Event(enable_timing=True)
+ begin.record()
+ output = torch.nn.functional.scaled_dot_product_attention(
+ query, key, value
+ )
+ end.record()
+ end.synchronize()
+ times.append(begin.elapsed_time(end) / 1000.0)
+ peak = torch.cuda.max_memory_allocated() / (1024.0 ** 3)
+ print(
+ f"backend={name} median_seconds={statistics.median(times):.6f} "
+ f"runs={','.join(f'{value:.6f}' for value in times)} "
+ f"peak_gib={peak:.3f}"
+ )
+ return output
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--sequence", type=int, default=18816)
+ parser.add_argument("--heads", type=int, default=56)
+ parser.add_argument("--head-dim", type=int, default=128)
+ parser.add_argument("--runs", type=int, default=3)
+ args = parser.parse_args()
+
+ torch.manual_seed(42)
+ shape = (1, args.heads, args.sequence, args.head_dim)
+ query = torch.randn(shape, device="cuda", dtype=torch.bfloat16) * 0.1
+ key = torch.randn_like(query) * 0.1
+ value = torch.randn_like(query) * 0.1
+ print(
+ f"torch={torch.__version__} cuda={torch.version.cuda} "
+ f"device={torch.cuda.get_device_name()} capability="
+ f"{torch.cuda.get_device_capability()} shape={shape} dtype=bf16"
+ )
+
+ outputs = {}
+ for name, backend in BACKENDS:
+ try:
+ outputs[name] = run_backend(
+ name, backend, query, key, value, args.runs
+ )
+ except RuntimeError as error:
+ print(f"backend={name} unavailable={error}")
+
+ if "flash" in outputs and "cudnn" in outputs:
+ difference = (
+ outputs["flash"].float() - outputs["cudnn"].float()
+ ).abs()
+ print(
+ f"flash_vs_cudnn max_abs={difference.max().item():.9g} "
+ f"mean_abs={difference.mean().item():.9g}"
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/faceswap-facefusion.sh b/scripts/faceswap-facefusion.sh
new file mode 100755
index 00000000..0ced99f8
--- /dev/null
+++ b/scripts/faceswap-facefusion.sh
@@ -0,0 +1,44 @@
+#!/usr/bin/env bash
+# Adapts FaceFusion to the post-processing contract in docs/POSTPROCESSING.md:
+#
+# faceswap-facefusion.sh --input IN.mp4 --output OUT.mp4
+#
+# The contract carries only the video, so the face to put in comes from the
+# environment. Both variables are written into .env by install.sh, except the
+# source face, which only you can choose:
+#
+# H3_FACEFUSION_DIR the FaceFusion checkout
+# H3_FACESWAP_SOURCE an image of the face to use
+# H3_FACEFUSION_PYTHON optional, the interpreter of its environment
+#
+# Do not run this on a real person who has not agreed to it.
+#
+# FaceFusion's own command line is its own, and it changes between releases:
+# this wrapper is written against the 3.x `headless-run`. If your version
+# names things differently, this file is the one place to adjust.
+
+set -euo pipefail
+
+input=""
+output=""
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --input) input="${2:-}"; shift 2;;
+ --output) output="${2:-}"; shift 2;;
+ *) printf 'faceswap: unknown argument: %s\n' "$1" >&2; exit 2;;
+ esac
+done
+
+[ -n "$input" ] && [ -n "$output" ] ||
+ { printf 'faceswap: --input and --output are both required\n' >&2; exit 2; }
+: "${H3_FACEFUSION_DIR:?set it to the FaceFusion checkout}"
+: "${H3_FACESWAP_SOURCE:?set it to an image of the face to use}"
+[ -f "$H3_FACESWAP_SOURCE" ] ||
+ { printf 'faceswap: no such source image: %s\n' "$H3_FACESWAP_SOURCE" >&2; exit 2; }
+
+python="${H3_FACEFUSION_PYTHON:-python3}"
+exec "$python" "$H3_FACEFUSION_DIR/facefusion.py" headless-run \
+ --processors face_swapper \
+ --source-paths "$H3_FACESWAP_SOURCE" \
+ --target-path "$input" \
+ --output-path "$output"
diff --git a/scripts/snapshot_ui.mjs b/scripts/snapshot_ui.mjs
new file mode 100755
index 00000000..b728ad34
--- /dev/null
+++ b/scripts/snapshot_ui.mjs
@@ -0,0 +1,53 @@
+#!/usr/bin/env node
+// snapshot_ui.mjs — screenshot multi-viewport + cattura errori console.
+// Uso: node scripts/snapshot_ui.mjs [url] [nome]
+// Prerequisito: npm i -D playwright && npx playwright install chromium
+// Esito: ultima riga "UI: PASS" oppure "UI: FAIL" + exit code.
+//
+// Lo screenshot cattura rotture invisibili nei log (layout, overflow, contrasto).
+// Gli errori console catturano rotture invisibili nello screenshot.
+// Servono entrambi: nessuno dei due da solo è una verifica sufficiente.
+
+import { chromium } from "playwright";
+import { mkdirSync } from "node:fs";
+
+const [url = "http://localhost:5173", name = "snapshot"] = process.argv.slice(2);
+const OUT = "logs/agent";
+mkdirSync(OUT, { recursive: true });
+
+const VIEWPORTS = {
+ desktop: { width: 1440, height: 900 },
+ mobile: { width: 390, height: 844 },
+};
+
+const problems = [];
+const browser = await chromium.launch();
+
+for (const [label, viewport] of Object.entries(VIEWPORTS)) {
+ const page = await browser.newPage({ viewport });
+ page.on("console", (m) => {
+ if (m.type() === "error") problems.push(`[${label}] CONSOLE ${m.text()}`);
+ });
+ page.on("pageerror", (e) => problems.push(`[${label}] PAGEERROR ${e.message}`));
+ page.on("response", (r) => {
+ if (r.status() >= 400) problems.push(`[${label}] HTTP ${r.status()} ${r.url()}`);
+ });
+
+ try {
+ await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 });
+ await page.screenshot({ path: `${OUT}/${name}_${label}.png`, fullPage: true });
+ console.log(` screenshot: ${OUT}/${name}_${label}.png`);
+ } catch (e) {
+ problems.push(`[${label}] NAVIGATION ${e.message}`);
+ }
+ await page.close();
+}
+
+await browser.close();
+
+if (problems.length) {
+ console.log(problems.slice(0, 15).join("\n"));
+ console.log("UI: FAIL");
+ process.exit(1);
+}
+console.log("UI: PASS");
diff --git a/scripts/verify.sh b/scripts/verify.sh
new file mode 100755
index 00000000..87f20355
--- /dev/null
+++ b/scripts/verify.sh
@@ -0,0 +1,111 @@
+#!/usr/bin/env bash
+# verify.sh — gate unico di validazione per agenti di codice.
+# Uso: ./scripts/verify.sh [all|static|unit|integration]
+# Esito: ultima riga "VERIFY: PASS" oppure "VERIFY: FAIL" + exit code.
+#
+# Rileva automaticamente lo stack presente. Output volutamente compatto:
+# il consumatore è un agente, non un umano — ogni riga superflua è token bruciati.
+
+set -uo pipefail
+cd "$(dirname "$0")/.." || exit 1
+
+MODE="${1:-all}"
+FAIL=0
+mkdir -p logs/agent
+
+step() { printf '\n── %s\n' "$1"; }
+has() { command -v "$1" >/dev/null 2>&1; }
+run() { echo " \$ $*"; "$@" || FAIL=1; }
+
+# Web UI: stack Python isolato nel proprio venv, non su PATH.
+WEBUI_VENV="webui/backend/.venv/bin"
+[[ -x "$WEBUI_VENV/pytest" ]] && WEBUI=1 || WEBUI=0
+
+# Rilevamento stack
+PY=0; JS=0
+[[ -f pyproject.toml || -f setup.py || -f requirements.txt || -n "$(ls -1 ./*.py 2>/dev/null)" ]] && PY=1
+[[ -f package.json ]] && JS=1
+
+# ─────────────────────────────── ANALISI STATICA ───────────────────────────────
+if [[ "$MODE" == "static" || "$MODE" == "all" ]]; then
+ step "Analisi statica"
+ if [[ $PY -eq 1 ]]; then
+ has ruff && run ruff check . --output-format=concise
+ has mypy && run mypy . --no-error-summary --pretty=False
+ fi
+ if [[ $JS -eq 1 ]]; then
+ has npx && [[ -f tsconfig.json ]] && run npx --no-install tsc --noEmit --pretty false
+ has npx && run npx --no-install eslint . --format=compact
+ fi
+ if [[ $WEBUI -eq 1 ]]; then
+ run "$WEBUI_VENV/ruff" check webui
+ fi
+ # Debug print dimenticati.
+ # Il pattern è spezzato ("AGENT""DBG") così questo script non matcha se stesso;
+ # esclusi anche .md e scripts/ per non segnalare la documentazione del pack.
+ DBG=$(grep -rn --exclude-dir={.git,node_modules,logs,.venv,scripts,MiniMax-H3} \
+ --exclude="*.md" "AGENT""DBG|" . 2>/dev/null | head -5)
+ if [[ -n "$DBG" ]]; then
+ echo " ! debug print temporanei ancora presenti — rimuovere prima di chiudere il task"
+ echo "$DBG" | sed 's/^/ /'
+ FAIL=1
+ fi
+fi
+
+# ──────────────────────────────── TEST UNITARI ────────────────────────────────
+if [[ "$MODE" == "unit" || "$MODE" == "all" ]]; then
+ step "Test unitari"
+ if [[ "$(uname -s)" == "Linux" && -f Makefile ]] &&
+ grep -q '^cuda-runtime-test:' Makefile; then
+ run make PLATFORM=Linux host-portable-test
+ run make PLATFORM=Linux tokenizer-portable-test
+ if [[ -f MiniMax-H3/FL2VA/audio_vae/model.safetensors &&
+ -f MiniMax-H3/Ref2VA/video_vae/source/model.safetensors ]]; then
+ run make PLATFORM=Linux checkpoint-schema-test
+ else
+ echo " · checkpoint MiniMax-H3 assente: smoke schema non applicabile"
+ fi
+ if has nvcc; then
+ run make PLATFORM=Linux cuda-runtime-test
+ run make PLATFORM=Linux cuda-primitives-test
+ run make PLATFORM=Linux cuda-rope-tokens-test
+ run make PLATFORM=Linux cuda-linear-test
+ run make PLATFORM=Linux cuda-attention-test
+ run make PLATFORM=Linux cuda-ops-test
+ run make PLATFORM=Linux test
+ else
+ echo " ! nvcc assente: impossibile eseguire il gate CUDA"
+ FAIL=1
+ fi
+ fi
+ if [[ $WEBUI -eq 1 ]]; then
+ run "$WEBUI_VENV/pytest" -q -x --tb=short -m "not integration" webui/backend/tests
+ fi
+ if [[ $PY -eq 1 ]] && has pytest; then
+ run pytest -q -x --tb=short -m "not integration"
+ fi
+ if [[ $JS -eq 1 ]] && has npx; then
+ if grep -q '"vitest"' package.json 2>/dev/null; then
+ run npx --no-install vitest run --reporter=dot
+ elif grep -q '"jest"' package.json 2>/dev/null; then
+ run npx --no-install jest --silent
+ fi
+ fi
+fi
+
+# ───────────────────────────── TEST DI INTEGRAZIONE ────────────────────────────
+if [[ "$MODE" == "integration" || "$MODE" == "all" ]]; then
+ step "Test di integrazione"
+ if [[ $PY -eq 1 ]] && has pytest; then
+ run pytest -q --tb=short -m integration || true # nessun test marcato = non è un errore
+ fi
+fi
+
+# ──────────────────────────────────── ESITO ───────────────────────────────────
+if [[ $FAIL -eq 0 ]]; then
+ RESULT="VERIFY: PASS"
+else
+ RESULT="VERIFY: FAIL"
+fi
+echo "$RESULT ($(date +%H:%M:%S), mode=$MODE)" | tee logs/agent/last_verify.txt
+exit $FAIL
diff --git a/tests/bench_dit.c b/tests/bench_dit.c
index 7a1d4440..6cd19859 100644
--- a/tests/bench_dit.c
+++ b/tests/bench_dit.c
@@ -14,16 +14,22 @@
#ifndef H3_BENCH_LATENT_W
#define H3_BENCH_LATENT_W 32
#endif
+#ifndef H3_BENCH_LATENT_T
+#define H3_BENCH_LATENT_T 7
+#endif
+#ifndef H3_BENCH_AUDIO_T
+#define H3_BENCH_AUDIO_T 37
+#endif
enum {
TEXT_ROWS = 6,
TEXT_WIDTH = 5120,
- LATENT_T = 7,
+ LATENT_T = H3_BENCH_LATENT_T,
LATENT_H = H3_BENCH_LATENT_H,
LATENT_W = H3_BENCH_LATENT_W,
CANVAS_H = LATENT_H * 16,
CANVAS_W = LATENT_W * 16,
- AUDIO_T = 37,
+ AUDIO_T = H3_BENCH_AUDIO_T,
VIDEO_ELEMENTS = 24 * LATENT_T * LATENT_H * LATENT_W,
AUDIO_ELEMENTS = 32 * 2 * AUDIO_T
};
@@ -1514,7 +1520,9 @@ int main(int argc, char **argv) {
const char *model_root = argc > 1 ? argv[1] : "MiniMax-H3";
const char *prompt_fixture = argc > 2 ? argv[2] :
"misc/fixtures/h3_real_prompt_bf16.safetensors";
- uint16_t *text_values = load_text(prompt_fixture);
+ uint16_t *text_values = getenv("H3_BENCH_SYNTHETIC_TEXT") ?
+ calloc(TEXT_ROWS * TEXT_WIDTH, sizeof(*text_values)) :
+ load_text(prompt_fixture);
float *video = calloc(VIDEO_ELEMENTS, sizeof(*video));
float *audio = calloc(AUDIO_ELEMENTS, sizeof(*audio));
float *video_velocity = malloc(VIDEO_ELEMENTS * sizeof(*video_velocity));
diff --git a/tests/bench_video_vae.c b/tests/bench_video_vae.c
new file mode 100644
index 00000000..968d3918
--- /dev/null
+++ b/tests/bench_video_vae.c
@@ -0,0 +1,95 @@
+#include "h3_video_vae.h"
+
+#include
+#include
+#include
+#include
+#include
+
+/* Quality preset latent shape: 1024x576 canvas, 107 frames.
+ * latent_time 32 -> 6 temporal chunks -> 107 decoded frames. */
+#ifndef H3_BENCH_VAE_LATENT_T
+#define H3_BENCH_VAE_LATENT_T 32
+#endif
+#ifndef H3_BENCH_VAE_LATENT_H
+#define H3_BENCH_VAE_LATENT_H 36
+#endif
+#ifndef H3_BENCH_VAE_LATENT_W
+#define H3_BENCH_VAE_LATENT_W 64
+#endif
+
+enum {
+ LATENT_CHANNELS = 24,
+ LATENT_TIME = H3_BENCH_VAE_LATENT_T,
+ LATENT_H = H3_BENCH_VAE_LATENT_H,
+ LATENT_W = H3_BENCH_VAE_LATENT_W,
+ EXPECTED_FRAMES = (LATENT_TIME - 2) / 5 * 17 + 5,
+ MAX_RUNS = 16
+};
+
+static void die(const char *message) {
+ fprintf(stderr, "h3_vae_bench: %s\n", message);
+ exit(1);
+}
+
+static double seconds(void) {
+ struct timespec value;
+ if (clock_gettime(CLOCK_MONOTONIC, &value) != 0) return 0.0;
+ return (double)value.tv_sec + (double)value.tv_nsec * 1e-9;
+}
+
+static int compare_double(const void *left, const void *right) {
+ double a = *(const double *)left, b = *(const double *)right;
+ return (a > b) - (a < b);
+}
+
+int main(int argc, char **argv) {
+ const char *model_dir = argc > 1 ? argv[1] : "./MiniMax-H3";
+ int runs = argc > 2 ? atoi(argv[2]) : 3;
+ if (runs < 1) runs = 1;
+ if (runs > MAX_RUNS) runs = MAX_RUNS;
+ char weights[4096];
+ snprintf(weights, sizeof(weights), "%s/FL2VA/video_vae/source", model_dir);
+ size_t latent_elements =
+ (size_t)LATENT_TIME * LATENT_H * LATENT_W * LATENT_CHANNELS;
+ float *latent = malloc(latent_elements * sizeof(*latent));
+ if (!latent) die("out of memory for latents");
+ /* Deterministic pseudo-random latents: decode timing is value
+ * independent, so synthetic latents profile the real compute path. */
+ uint64_t state = UINT64_C(0x9E3779B97F4A7C15);
+ for (size_t index = 0; index < latent_elements; index++) {
+ state = state * UINT64_C(6364136223846793005) +
+ UINT64_C(1442695040888963407);
+ latent[index] = (float)((double)(state >> 40) / 8388608.0 - 1.0);
+ }
+ double wall[MAX_RUNS], gpu[MAX_RUNS];
+ char error[512];
+ for (int run = 0; run < runs; run++) {
+ h3_video_frames frames;
+ double start = seconds();
+ int ok = h3_video_vae_decode(weights, "h3_shaders.metal", latent,
+ LATENT_TIME, LATENT_H, LATENT_W, NULL, NULL, &frames, error,
+ sizeof(error));
+ double elapsed = seconds() - start;
+ if (!ok) die(error);
+ if (frames.frames != EXPECTED_FRAMES || frames.height != LATENT_H * 16 ||
+ frames.width != LATENT_W * 16) {
+ h3_video_frames_free(&frames);
+ die("decoded frame shape does not match the quality preset");
+ }
+ wall[run] = elapsed;
+ gpu[run] = frames.gpu_stats.gpu_seconds;
+ printf("run %d: wall %.3fs, gpu %.3fs, submissions %llu, peak %.3f GB\n",
+ run + 1, elapsed, frames.gpu_stats.gpu_seconds,
+ (unsigned long long)frames.gpu_stats.submissions,
+ (double)frames.gpu_stats.peak_live_bytes / 1e9);
+ fflush(stdout);
+ h3_video_frames_free(&frames);
+ }
+ free(latent);
+ qsort(wall, (size_t)runs, sizeof(*wall), compare_double);
+ qsort(gpu, (size_t)runs, sizeof(*gpu), compare_double);
+ printf("median: wall %.3fs, gpu %.3fs over %d run(s)\n",
+ wall[runs / 2], gpu[runs / 2], runs);
+ return 0;
+}
diff --git a/tests/test_av_mux.c b/tests/test_av_mux.c
index eefc1f70..6f8a5071 100644
--- a/tests/test_av_mux.c
+++ b/tests/test_av_mux.c
@@ -29,7 +29,7 @@ int main(int argc, char **argv) {
for (int channel = 0; channel < 2; channel++)
for (int sample = 0; sample < SAMPLES; sample++)
pcm[(size_t)channel * SAMPLES + (size_t)sample] =
- 0.05f * sinf(2.0f * 3.14159265358979323846f *
+ 1.5f * sinf(2.0f * 3.14159265358979323846f *
(float)(220 + channel * 110) * (float)sample /
32000.0f);
char error[512];
@@ -89,6 +89,7 @@ int main(int argc, char **argv) {
if (decoded_samples != SAMPLES)
die("FFmpeg audio input returned an unexpected sample count");
double left_energy = 0.0, right_energy = 0.0;
+ float peak = 0.0f;
for (int sample = 0; sample < decoded_samples; sample++) {
float left = decoded_pcm[sample];
float right = decoded_pcm[decoded_samples + sample];
@@ -96,9 +97,12 @@ int main(int argc, char **argv) {
die("decoded FFmpeg audio contains non-finite PCM");
left_energy += (double)left * left;
right_energy += (double)right * right;
+ peak = fmaxf(peak, fmaxf(fabsf(left), fabsf(right)));
}
if (left_energy < 1.0 || right_energy < 1.0)
die("decoded FFmpeg audio has no stereo signal");
+ if (peak > 1.0f)
+ die("decoded FFmpeg audio exceeds the anti-clipping ceiling");
free(decoded_pcm);
printf("ok: concurrent FFmpeg video/PCM pipes created %s (%lld bytes)\n",
path, (long long)status.st_size);
diff --git a/tests/test_checkpoint_schema.c b/tests/test_checkpoint_schema.c
new file mode 100644
index 00000000..caad620c
--- /dev/null
+++ b/tests/test_checkpoint_schema.c
@@ -0,0 +1,102 @@
+#include "h3_safetensors.h"
+#include "h3_weights.h"
+
+#include
+#include
+#include
+#include
+
+#define CHECK(condition) do { if (!(condition)) { \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #condition); \
+ return 1; \
+} } while (0)
+
+static const h3_st_tensor *require_tensor(const h3_st_header *header,
+ const char *name, h3_dtype dtype, int dimensions,
+ const uint64_t *shape) {
+ const h3_st_tensor *tensor = h3_st_find(header, name);
+ if (!tensor || tensor->dtype != dtype || tensor->ndim != dimensions)
+ return NULL;
+ for (int dimension = 0; dimension < dimensions; dimension++)
+ if (tensor->shape[dimension] != shape[dimension]) return NULL;
+ return tensor;
+}
+
+static int check_partition(const char *root, const char *partition, h3_gpu *gpu) {
+ char audio_path[512];
+ char audio_directory[512];
+ char video_path[512];
+ CHECK(snprintf(audio_path, sizeof(audio_path), "%s/%s/audio_vae/model.safetensors",
+ root, partition) > 0);
+ CHECK(snprintf(audio_directory, sizeof(audio_directory), "%s/%s/audio_vae",
+ root, partition) > 0);
+ CHECK(snprintf(video_path, sizeof(video_path),
+ "%s/%s/video_vae/source/model.safetensors",
+ root, partition) > 0);
+ char error[512];
+ h3_st_header audio;
+ h3_st_header video;
+ CHECK(h3_st_read_header(audio_path, &audio, error, sizeof(error)));
+ CHECK(h3_st_read_header(video_path, &video, error, sizeof(error)));
+
+ const uint64_t audio_bias_shape[] = {2048};
+ const uint64_t filter_shape[] = {1, 1, 12};
+ const uint64_t video_post_shape[] = {24, 24, 1, 1, 1};
+ const uint64_t video_norm_shape[] = {2048};
+ const h3_st_tensor *audio_bias = require_tensor(
+ &audio, "dec_in_proj.bias", H3_DTYPE_F32, 1, audio_bias_shape);
+ const h3_st_tensor *filter = require_tensor(
+ &audio, "decoder.activation_post.downsample.lowpass.filter",
+ H3_DTYPE_F32, 3, filter_shape);
+ const h3_st_tensor *video_post = require_tensor(
+ &video, "post_quant_conv.weight", H3_DTYPE_F32, 5,
+ video_post_shape);
+ const h3_st_tensor *video_norm = require_tensor(
+ &video, "decoder.norm_out.weight", H3_DTYPE_F32, 1,
+ video_norm_shape);
+ CHECK(audio_bias && filter && video_post && video_norm);
+
+ float filter_values[12];
+ float bias_values[2048];
+ CHECK(h3_st_read_data(&audio, filter, filter_values,
+ sizeof(filter_values), error, sizeof(error)));
+ CHECK(h3_st_read_data(&audio, audio_bias, bias_values,
+ sizeof(bias_values), error, sizeof(error)));
+ CHECK(isfinite(bias_values[0]));
+ float magnitude = 0.0f;
+ for (size_t index = 0; index < 12; index++) {
+ CHECK(isfinite(filter_values[index]));
+ magnitude += fabsf(filter_values[index]);
+ }
+ CHECK(magnitude > 0.0f);
+ CHECK(video_post->data_end > video_post->data_begin);
+
+ h3_weight_store *store = h3_weight_store_open(
+ audio_directory, error, sizeof(error));
+ CHECK(store != NULL && h3_weight_store_shards(store) == 1);
+ h3_gpu_tensor *loaded_filter = h3_weight_load_f32(
+ store, gpu, "decoder.activation_post.downsample.lowpass.filter",
+ 3, filter_shape, error, sizeof(error));
+ CHECK(loaded_filter != NULL);
+ float loaded_values[12];
+ CHECK(h3_gpu_tensor_read_f32(loaded_filter, loaded_values, 12));
+ CHECK(memcmp(filter_values, loaded_values, sizeof(filter_values)) == 0);
+ h3_gpu_tensor_free(loaded_filter);
+ h3_weight_store_free(store);
+
+ h3_st_free_header(&video);
+ h3_st_free_header(&audio);
+ return 0;
+}
+
+int main(int argc, char **argv) {
+ const char *root = argc > 1 ? argv[1] : "MiniMax-H3";
+ char error[512];
+ h3_gpu *gpu = h3_gpu_create(NULL, error, sizeof(error));
+ CHECK(gpu != NULL);
+ CHECK(check_partition(root, "FL2VA", gpu) == 0);
+ CHECK(check_partition(root, "Ref2VA", gpu) == 0);
+ h3_gpu_free(gpu);
+ puts("ok: official FL2VA/Ref2VA audio and video VAE schemas/payloads");
+ return 0;
+}
diff --git a/tests/test_cuda_attention.c b/tests/test_cuda_attention.c
new file mode 100644
index 00000000..4d243ac3
--- /dev/null
+++ b/tests/test_cuda_attention.c
@@ -0,0 +1,352 @@
+#include "h3_gpu.h"
+
+#include
+#include
+#include
+#include
+
+#define CHECK(condition) do { if (!(condition)) { \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #condition); \
+ return 1; \
+} } while (0)
+
+static int close_values(const float *actual, const float *expected,
+ size_t count, float tolerance) {
+ for (size_t index = 0; index < count; index++)
+ if (fabsf(actual[index] - expected[index]) > tolerance) return 0;
+ return 1;
+}
+
+static double seconds_now(void) {
+ struct timespec time;
+ clock_gettime(CLOCK_MONOTONIC, &time);
+ return (double)time.tv_sec + (double)time.tv_nsec * 1e-9;
+}
+
+static int benchmark_attention(uint32_t sequence) {
+ enum { HEADS = 56, HEAD_DIM = 128, RUNS = 3 };
+ size_t elements = (size_t)sequence * HEADS * HEAD_DIM;
+ if (!sequence || sequence > 20000 || elements > SIZE_MAX / sizeof(float))
+ return 1;
+ float *zeros = calloc(elements, sizeof(*zeros));
+ char error[256];
+ h3_gpu *gpu = h3_gpu_create(NULL, error, sizeof(error));
+ h3_gpu_tensor *query = h3_gpu_tensor_new_bf16(gpu, elements);
+ h3_gpu_tensor *key = h3_gpu_tensor_new_bf16(gpu, elements);
+ h3_gpu_tensor *value = h3_gpu_tensor_new_bf16(gpu, elements);
+ h3_gpu_tensor *output = h3_gpu_tensor_new_bf16(gpu, elements);
+ CHECK(zeros && gpu && query && key && value && output);
+ CHECK(h3_gpu_tensor_write_f32(query, zeros, elements));
+ CHECK(h3_gpu_tensor_write_f32(key, zeros, elements));
+ CHECK(h3_gpu_tensor_write_f32(value, zeros, elements));
+ for (int run = -1; run < RUNS; run++) {
+ double started = seconds_now();
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_sdpa_bf16(gpu, output, query, key, value, sequence,
+ HEADS, HEAD_DIM, 1.0f / sqrtf(HEAD_DIM)));
+ CHECK(h3_gpu_submit(gpu));
+ double elapsed = seconds_now() - started;
+ if (run >= 0)
+ printf("attention sequence=%u run=%d seconds=%.6f\n",
+ sequence, run + 1, elapsed);
+ }
+ h3_gpu_tensor_free(output); h3_gpu_tensor_free(value);
+ h3_gpu_tensor_free(key); h3_gpu_tensor_free(query);
+ h3_gpu_free(gpu); free(zeros);
+ return 0;
+}
+
+int main(int argc, char **argv) {
+ if (argc == 2) return benchmark_attention((uint32_t)strtoul(argv[1], NULL, 10));
+ char error[256];
+ h3_gpu *gpu = h3_gpu_create(NULL, error, sizeof(error));
+ CHECK(gpu);
+ const float zeros[] = {0,0,0,0};
+ const float values[] = {1,2,3,4};
+ const float noncausal_expected[] = {2,3,2,3};
+ const float causal_expected[] = {1,2,2,3};
+ float actual[16];
+ h3_gpu_tensor *query = h3_gpu_tensor_from_f32(gpu, zeros, 4);
+ h3_gpu_tensor *key = h3_gpu_tensor_from_f32(gpu, zeros, 4);
+ h3_gpu_tensor *value = h3_gpu_tensor_from_f32(gpu, values, 4);
+ h3_gpu_tensor *output = h3_gpu_tensor_new_f32(gpu, 4);
+ CHECK(query && key && value && output);
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_sdpa_f32(gpu, output, query, key, value, 2, 1, 2, 1.0f));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(output, actual, 4));
+ CHECK(close_values(actual, noncausal_expected, 4, 1e-6f));
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_sdpa_causal_f32(gpu, output, query, key, value,
+ 1, 2, 1, 2, 1.0f));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(output, actual, 4));
+ CHECK(close_values(actual, causal_expected, 4, 1e-6f));
+
+ float wide_values[1024] = {0};
+ float wide_actual[1024];
+ for (size_t index = 0; index < 1024; index++)
+ wide_values[index] = (float)index;
+ h3_gpu_tensor *wide_query = h3_gpu_tensor_new_f32(gpu, 1024);
+ h3_gpu_tensor *wide_key = h3_gpu_tensor_new_f32(gpu, 1024);
+ h3_gpu_tensor *wide_value = h3_gpu_tensor_from_f32(gpu, wide_values, 1024);
+ h3_gpu_tensor *wide_output = h3_gpu_tensor_new_f32(gpu, 1024);
+ CHECK(wide_query && wide_key && wide_value && wide_output);
+ CHECK(h3_gpu_tensor_write_f32(wide_query, (const float[1024]){0}, 1024));
+ CHECK(h3_gpu_tensor_write_f32(wide_key, (const float[1024]){0}, 1024));
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_sdpa_causal_f32(gpu, wide_output, wide_query, wide_key,
+ wide_value, 2, 2, 1, 256, 0.0625f));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(wide_output, wide_actual, 1024));
+ CHECK(close_values(wide_actual, wide_values, 256, 1e-6f));
+ CHECK(close_values(wide_actual + 512, wide_values + 512, 256, 1e-6f));
+ for (size_t index = 256; index < 512; index++)
+ CHECK(fabsf(wide_actual[index] -
+ 0.5f * (wide_values[index - 256] + wide_values[index])) <
+ 1e-6f);
+ for (size_t index = 768; index < 1024; index++)
+ CHECK(fabsf(wide_actual[index] -
+ 0.5f * (wide_values[index - 256] + wide_values[index])) <
+ 1e-6f);
+
+ const float two_head_values[] = {1,2,3,4, 10,20,30,40};
+ const float row_major_expected[] = {2,3,20,30, 2,3,20,30};
+ const float head_major_expected[] = {2,3,2,3, 20,30,20,30};
+ h3_gpu_tensor *bquery = h3_gpu_tensor_new_bf16(gpu, 8);
+ h3_gpu_tensor *bkey = h3_gpu_tensor_new_bf16(gpu, 8);
+ h3_gpu_tensor *bvalue = h3_gpu_tensor_new_bf16(gpu, 8);
+ h3_gpu_tensor *boutput = h3_gpu_tensor_new_bf16(gpu, 8);
+ CHECK(bquery && bkey && bvalue && boutput);
+ CHECK(h3_gpu_tensor_write_f32(bquery, two_head_values, 8));
+ CHECK(h3_gpu_tensor_write_f32(bkey, (const float[8]){0}, 8));
+ CHECK(h3_gpu_tensor_write_f32(bvalue, two_head_values, 8));
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_sdpa_bf16(gpu, boutput, bquery, bkey, bvalue,
+ 2, 2, 2, 0.5f));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(boutput, actual, 8));
+ CHECK(close_values(actual, row_major_expected, 8, 0.02f));
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_sdpa_bf16_head_major_output(
+ gpu, boutput, bquery, bkey, bvalue, 2, 2, 2, 0.5f));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(boutput, actual, 8));
+ CHECK(close_values(actual, head_major_expected, 8, 0.02f));
+
+ enum { TSEQ = 3, THEADS = 2, TDIM = 128, TELEMS = TSEQ * THEADS * TDIM };
+ float tiled_query[TELEMS], tiled_key[TELEMS], tiled_value[TELEMS];
+ float tiled_expected[TELEMS], tiled_head_expected[TELEMS];
+ float tiled_actual[TELEMS];
+ for (int index = 0; index < TELEMS; index++) {
+ tiled_query[index] = (float)(index % 17 - 8) * 0.01f;
+ tiled_key[index] = (float)(index % 13 - 6) * 0.0125f;
+ tiled_value[index] = (float)(index % 29 - 14) * 0.025f;
+ }
+ for (int head = 0; head < THEADS; head++)
+ for (int row = 0; row < TSEQ; row++) {
+ float scores[TSEQ], maximum = -INFINITY, denominator = 0.0f;
+ for (int key_row = 0; key_row < TSEQ; key_row++) {
+ float score = 0.0f;
+ for (int dimension = 0; dimension < TDIM; dimension++)
+ score += tiled_query[(head * TSEQ + row) * TDIM + dimension] *
+ tiled_key[(head * TSEQ + key_row) * TDIM + dimension];
+ scores[key_row] = score / sqrtf((float)TDIM);
+ maximum = fmaxf(maximum, scores[key_row]);
+ }
+ for (int key_row = 0; key_row < TSEQ; key_row++)
+ denominator += expf(scores[key_row] - maximum);
+ for (int dimension = 0; dimension < TDIM; dimension++) {
+ float sum = 0.0f;
+ for (int key_row = 0; key_row < TSEQ; key_row++)
+ sum += expf(scores[key_row] - maximum) *
+ tiled_value[(head * TSEQ + key_row) * TDIM + dimension];
+ tiled_expected[(row * THEADS + head) * TDIM + dimension] =
+ sum / denominator;
+ tiled_head_expected[(head * TSEQ + row) * TDIM + dimension] =
+ sum / denominator;
+ }
+ }
+ h3_gpu_tensor *tiled_q = h3_gpu_tensor_new_bf16(gpu, TELEMS);
+ h3_gpu_tensor *tiled_k = h3_gpu_tensor_new_bf16(gpu, TELEMS);
+ h3_gpu_tensor *tiled_v = h3_gpu_tensor_new_bf16(gpu, TELEMS);
+ h3_gpu_tensor *tiled_o = h3_gpu_tensor_new_bf16(gpu, TELEMS);
+ CHECK(tiled_q && tiled_k && tiled_v && tiled_o);
+ CHECK(h3_gpu_tensor_write_f32(tiled_q, tiled_query, TELEMS));
+ CHECK(h3_gpu_tensor_write_f32(tiled_k, tiled_key, TELEMS));
+ CHECK(h3_gpu_tensor_write_f32(tiled_v, tiled_value, TELEMS));
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_sdpa_bf16(gpu, tiled_o, tiled_q, tiled_k, tiled_v,
+ TSEQ, THEADS, TDIM, 1.0f / sqrtf((float)TDIM)));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(tiled_o, tiled_actual, TELEMS));
+ CHECK(close_values(tiled_actual, tiled_expected, TELEMS, 0.002f));
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_sdpa_bf16_head_major_output(
+ gpu, tiled_o, tiled_q, tiled_k, tiled_v, TSEQ, THEADS, TDIM,
+ 1.0f / sqrtf((float)TDIM)));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(tiled_o, tiled_actual, TELEMS));
+ CHECK(close_values(tiled_actual, tiled_head_expected, TELEMS, 0.002f));
+
+ enum { FSEQ = 13, FHEADS = 2, FDIM = 64, FELEMS = FSEQ * FHEADS * FDIM };
+ float tiled_f32_query[FELEMS], tiled_f32_key[FELEMS];
+ float tiled_f32_value[FELEMS], tiled_f32_expected[FELEMS];
+ float tiled_f32_actual[FELEMS], tiled_f32_scalar[FELEMS];
+ for (int index = 0; index < FELEMS; index++) {
+ tiled_f32_query[index] = (float)(index % 17 - 8) * 0.01f;
+ tiled_f32_key[index] = (float)(index % 13 - 6) * 0.0125f;
+ tiled_f32_value[index] = (float)(index % 29 - 14) * 0.025f;
+ }
+ for (int head = 0; head < FHEADS; head++)
+ for (int row = 0; row < FSEQ; row++) {
+ float scores[FSEQ], maximum = -INFINITY, denominator = 0.0f;
+ for (int key_row = 0; key_row < FSEQ; key_row++) {
+ float score = 0.0f;
+ for (int dimension = 0; dimension < FDIM; dimension++)
+ score += tiled_f32_query[(head * FSEQ + row) * FDIM +
+ dimension] *
+ tiled_f32_key[(head * FSEQ + key_row) * FDIM +
+ dimension];
+ scores[key_row] = score / sqrtf((float)FDIM);
+ maximum = fmaxf(maximum, scores[key_row]);
+ }
+ for (int key_row = 0; key_row < FSEQ; key_row++)
+ denominator += expf(scores[key_row] - maximum);
+ for (int dimension = 0; dimension < FDIM; dimension++) {
+ float sum = 0.0f;
+ for (int key_row = 0; key_row < FSEQ; key_row++)
+ sum += expf(scores[key_row] - maximum) *
+ tiled_f32_value[(head * FSEQ + key_row) * FDIM +
+ dimension];
+ tiled_f32_expected[(row * FHEADS + head) * FDIM + dimension] =
+ sum / denominator;
+ }
+ }
+ h3_gpu_tensor *f32_q = h3_gpu_tensor_new_f32(gpu, FELEMS);
+ h3_gpu_tensor *f32_k = h3_gpu_tensor_new_f32(gpu, FELEMS);
+ h3_gpu_tensor *f32_v = h3_gpu_tensor_new_f32(gpu, FELEMS);
+ h3_gpu_tensor *f32_o = h3_gpu_tensor_new_f32(gpu, FELEMS);
+ CHECK(f32_q && f32_k && f32_v && f32_o);
+ CHECK(h3_gpu_tensor_write_f32(f32_q, tiled_f32_query, FELEMS));
+ CHECK(h3_gpu_tensor_write_f32(f32_k, tiled_f32_key, FELEMS));
+ CHECK(h3_gpu_tensor_write_f32(f32_v, tiled_f32_value, FELEMS));
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_sdpa_f32(gpu, f32_o, f32_q, f32_k, f32_v,
+ FSEQ, FHEADS, FDIM, 1.0f / sqrtf((float)FDIM)));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(f32_o, tiled_f32_actual, FELEMS));
+ CHECK(close_values(tiled_f32_actual, tiled_f32_expected, FELEMS, 2e-5f));
+ CHECK(setenv("H3_DISABLE_TILED_ATTENTION", "1", 1) == 0);
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_sdpa_f32(gpu, f32_o, f32_q, f32_k, f32_v,
+ FSEQ, FHEADS, FDIM, 1.0f / sqrtf((float)FDIM)));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(f32_o, tiled_f32_scalar, FELEMS));
+ CHECK(unsetenv("H3_DISABLE_TILED_ATTENTION") == 0);
+ CHECK(close_values(tiled_f32_scalar, tiled_f32_expected, FELEMS, 2e-5f));
+ CHECK(close_values(tiled_f32_scalar, tiled_f32_actual, FELEMS, 2e-5f));
+
+ /* Video VAE limit shape: sequence 2805, head_dim 64. */
+ enum { LSEQ = 2805, LHEADS = 4, LDIM = 64, LELEMS = LSEQ * LHEADS * LDIM };
+ float *long_query = malloc(LELEMS * sizeof(float));
+ float *long_key = malloc(LELEMS * sizeof(float));
+ float *long_value = malloc(LELEMS * sizeof(float));
+ float *long_expected = malloc(LELEMS * sizeof(float));
+ float *long_actual = malloc(LELEMS * sizeof(float));
+ float *long_scores = malloc(LSEQ * sizeof(float));
+ CHECK(long_query && long_key && long_value && long_expected &&
+ long_actual && long_scores);
+ for (int index = 0; index < LELEMS; index++) {
+ long_query[index] = (float)(index % 31 - 15) * 0.005f;
+ long_key[index] = (float)(index % 19 - 9) * 0.0075f;
+ long_value[index] = (float)(index % 47 - 23) * 0.0125f;
+ }
+ for (int head = 0; head < LHEADS; head++)
+ for (int row = 0; row < LSEQ; row++) {
+ float maximum = -INFINITY, denominator = 0.0f;
+ for (int key_row = 0; key_row < LSEQ; key_row++) {
+ float score = 0.0f;
+ for (int dimension = 0; dimension < LDIM; dimension++)
+ score += long_query[(head * LSEQ + row) * LDIM +
+ dimension] *
+ long_key[(head * LSEQ + key_row) * LDIM +
+ dimension];
+ long_scores[key_row] = score / sqrtf((float)LDIM);
+ maximum = fmaxf(maximum, long_scores[key_row]);
+ }
+ for (int key_row = 0; key_row < LSEQ; key_row++)
+ denominator += expf(long_scores[key_row] - maximum);
+ for (int dimension = 0; dimension < LDIM; dimension++) {
+ float sum = 0.0f;
+ for (int key_row = 0; key_row < LSEQ; key_row++)
+ sum += expf(long_scores[key_row] - maximum) *
+ long_value[(head * LSEQ + key_row) * LDIM +
+ dimension];
+ long_expected[(row * LHEADS + head) * LDIM + dimension] =
+ sum / denominator;
+ }
+ }
+ h3_gpu_tensor *long_q = h3_gpu_tensor_new_f32(gpu, LELEMS);
+ h3_gpu_tensor *long_k = h3_gpu_tensor_new_f32(gpu, LELEMS);
+ h3_gpu_tensor *long_v = h3_gpu_tensor_new_f32(gpu, LELEMS);
+ h3_gpu_tensor *long_o = h3_gpu_tensor_new_f32(gpu, LELEMS);
+ CHECK(long_q && long_k && long_v && long_o);
+ CHECK(h3_gpu_tensor_write_f32(long_q, long_query, LELEMS));
+ CHECK(h3_gpu_tensor_write_f32(long_k, long_key, LELEMS));
+ CHECK(h3_gpu_tensor_write_f32(long_v, long_value, LELEMS));
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_sdpa_f32(gpu, long_o, long_q, long_k, long_v,
+ LSEQ, LHEADS, LDIM, 1.0f / sqrtf((float)LDIM)));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(long_o, long_actual, LELEMS));
+ CHECK(close_values(long_actual, long_expected, LELEMS, 2e-5f));
+ h3_gpu_tensor_free(long_o); h3_gpu_tensor_free(long_v);
+ h3_gpu_tensor_free(long_k); h3_gpu_tensor_free(long_q);
+ free(long_scores); free(long_actual);
+ free(long_expected); free(long_value); free(long_key); free(long_query);
+ h3_gpu_tensor_free(f32_o); h3_gpu_tensor_free(f32_v);
+ h3_gpu_tensor_free(f32_k); h3_gpu_tensor_free(f32_q);
+
+ h3_gpu_tensor *gqa_query = h3_gpu_tensor_new_bf16(gpu, 8);
+ h3_gpu_tensor *gqa_key = h3_gpu_tensor_new_bf16(gpu, 8);
+ h3_gpu_tensor *gqa_value = h3_gpu_tensor_new_bf16(gpu, 8);
+ CHECK(gqa_query && gqa_key && gqa_value);
+ CHECK(h3_gpu_tensor_write_f32(gqa_query, (const float[8]){0}, 8));
+ CHECK(h3_gpu_tensor_write_f32(gqa_key, (const float[8]){0}, 8));
+ CHECK(h3_gpu_tensor_write_f32(gqa_value,
+ (const float[]){1,2,10,20, 3,4,30,40}, 8));
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_gqa_causal_bf16(gpu, boutput, gqa_query, gqa_key,
+ gqa_value, 2, 2, 2, 2, 1.0f));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(boutput, actual, 8));
+ const float gqa_expected[] = {1,2,10,20, 2,3,20,30};
+ CHECK(close_values(actual, gqa_expected, 8, 0.02f));
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(!h3_gpu_gqa_causal_bf16(gpu, boutput, gqa_query, gqa_key,
+ gqa_value, 2, 3, 2, 2, 1.0f));
+ CHECK(h3_gpu_error(gpu)[0] != '\0');
+ CHECK(h3_gpu_submit(gpu));
+
+ h3_gpu_tensor_free(gqa_value);
+ h3_gpu_tensor_free(gqa_key);
+ h3_gpu_tensor_free(gqa_query);
+ h3_gpu_tensor_free(tiled_o); h3_gpu_tensor_free(tiled_v);
+ h3_gpu_tensor_free(tiled_k); h3_gpu_tensor_free(tiled_q);
+ h3_gpu_tensor_free(wide_output);
+ h3_gpu_tensor_free(wide_value);
+ h3_gpu_tensor_free(wide_key);
+ h3_gpu_tensor_free(wide_query);
+ h3_gpu_tensor_free(boutput);
+ h3_gpu_tensor_free(bvalue);
+ h3_gpu_tensor_free(bkey);
+ h3_gpu_tensor_free(bquery);
+ h3_gpu_tensor_free(output);
+ h3_gpu_tensor_free(value);
+ h3_gpu_tensor_free(key);
+ h3_gpu_tensor_free(query);
+ h3_gpu_free(gpu);
+ puts("ok: CUDA SDPA causal, GQA and head-major layouts");
+ return 0;
+}
diff --git a/tests/test_cuda_linear.c b/tests/test_cuda_linear.c
new file mode 100644
index 00000000..18d8f573
--- /dev/null
+++ b/tests/test_cuda_linear.c
@@ -0,0 +1,310 @@
+#include "h3_gpu.h"
+
+#include
+#include
+#include
+#include
+#include
+
+#define CHECK(condition) do { if (!(condition)) { \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #condition); \
+ return 1; \
+} } while (0)
+
+static int close_values(const float *actual, const float *expected,
+ size_t count, float tolerance) {
+ for (size_t index = 0; index < count; index++)
+ if (fabsf(actual[index] - expected[index]) > tolerance) return 0;
+ return 1;
+}
+
+static double wall_time(void) {
+ struct timespec value;
+ CHECK(clock_gettime(CLOCK_MONOTONIC, &value) == 0);
+ return (double)value.tv_sec + (double)value.tv_nsec * 1e-9;
+}
+
+static int benchmark_shape(h3_gpu *gpu, const char *name, uint32_t rows,
+ uint32_t input_dim, uint32_t output_dim) {
+ h3_gpu_tensor *input = h3_gpu_tensor_new_bf16(
+ gpu, (size_t)rows * input_dim);
+ h3_gpu_tensor *weight = h3_gpu_tensor_new_bf16(
+ gpu, (size_t)output_dim * input_dim);
+ h3_gpu_tensor *output = h3_gpu_tensor_new_bf16(
+ gpu, (size_t)rows * output_dim);
+ CHECK(input && weight && output);
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_linear_bf16(gpu, output, input, weight, NULL, rows,
+ input_dim, output_dim));
+ CHECK(h3_gpu_submit(gpu));
+ for (int run = 0; run < 3; run++) {
+ double start = wall_time();
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_linear_bf16(gpu, output, input, weight, NULL, rows,
+ input_dim, output_dim));
+ CHECK(h3_gpu_submit(gpu));
+ printf("benchmark %s run=%d seconds=%.6f\n", name, run + 1,
+ wall_time() - start);
+ }
+ h3_gpu_tensor_free(output);
+ h3_gpu_tensor_free(weight);
+ h3_gpu_tensor_free(input);
+ return 0;
+}
+
+static int run_benchmark(void) {
+ char error[256];
+ h3_gpu *gpu = h3_gpu_create(NULL, error, sizeof(error));
+ CHECK(gpu);
+ CHECK(!benchmark_shape(gpu, "qkv", 2048, 5376, 21504));
+ CHECK(!benchmark_shape(gpu, "mlp", 2048, 5376, 14336));
+ CHECK(!benchmark_shape(gpu, "output", 2048, 5376, 5376));
+ h3_gpu_free(gpu);
+ return 0;
+}
+
+int main(int argc, char **argv) {
+ if (argc == 2 && strcmp(argv[1], "benchmark") == 0)
+ return run_benchmark();
+ char error[256];
+ h3_gpu *gpu = h3_gpu_create(NULL, error, sizeof(error));
+ CHECK(gpu);
+ const float input_values[] = {1, 2, 3, -1, 0.5f, 2};
+ const float weight_values[] = {2, -1, 0.5f, -3, 4, 1};
+ const float bias_values[] = {0.25f, -0.5f};
+ const float expected[] = {1.75f, 7.5f, -1.25f, 6.5f};
+ float actual[4];
+
+ h3_gpu_tensor *input = h3_gpu_tensor_from_f32(gpu, input_values, 6);
+ h3_gpu_tensor *weight = h3_gpu_tensor_from_f32(gpu, weight_values, 6);
+ h3_gpu_tensor *bias = h3_gpu_tensor_from_f32(gpu, bias_values, 2);
+ h3_gpu_tensor *output = h3_gpu_tensor_new_f32(gpu, 4);
+ CHECK(input && weight && bias && output);
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_linear_f32(gpu, output, input, weight, bias, 2, 3, 2));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(output, actual, 4));
+ CHECK(close_values(actual, expected, 4, 1e-6f));
+
+ h3_gpu_tensor *bf_input = h3_gpu_tensor_new_bf16(gpu, 6);
+ h3_gpu_tensor *bf_weight = h3_gpu_tensor_new_bf16(gpu, 6);
+ h3_gpu_tensor *bf_bias = h3_gpu_tensor_new_bf16(gpu, 2);
+ h3_gpu_tensor *bf_output = h3_gpu_tensor_new_bf16(gpu, 4);
+ CHECK(bf_input && bf_weight && bf_bias && bf_output);
+ CHECK(h3_gpu_tensor_write_f32(bf_input, input_values, 6));
+ CHECK(h3_gpu_tensor_write_f32(bf_weight, weight_values, 6));
+ CHECK(h3_gpu_tensor_write_f32(bf_bias, bias_values, 2));
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_linear_bf16(gpu, bf_output, bf_input, bf_weight, bf_bias,
+ 2, 3, 2));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(bf_output, actual, 4));
+ CHECK(close_values(actual, expected, 4, 0.04f));
+
+ const float int8_input_values[] = {1, -2, 0.5f, 1};
+ const float int8_weight_values[] = {2, -1, -1, 3};
+ const float int8_expected[] = {4, -7, 0, 2.5f};
+ h3_gpu_tensor *int8_input = h3_gpu_tensor_new_bf16(gpu, 4);
+ h3_gpu_tensor *int8_weight_source = h3_gpu_tensor_new_bf16(gpu, 4);
+ h3_gpu_tensor *int8_weight = h3_gpu_tensor_new_i8(gpu, 4);
+ h3_gpu_tensor *int8_weight_scales = h3_gpu_tensor_new_f32(gpu, 2);
+ h3_gpu_tensor *int8_quantized_input = h3_gpu_tensor_new_i8(gpu, 4);
+ h3_gpu_tensor *int8_input_scales = h3_gpu_tensor_new_f32(gpu, 2);
+ h3_gpu_tensor *int8_output = h3_gpu_tensor_new_bf16(gpu, 4);
+ CHECK(int8_input && int8_weight_source && int8_weight &&
+ int8_weight_scales && int8_quantized_input && int8_input_scales &&
+ int8_output);
+ CHECK(h3_gpu_tensor_write_f32(int8_input, int8_input_values, 4));
+ CHECK(h3_gpu_tensor_write_f32(int8_weight_source, int8_weight_values, 4));
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_quantize_weight_int8(gpu, int8_weight, int8_weight_scales,
+ int8_weight_source, 2, 2));
+ CHECK(h3_gpu_linear_int8_bf16(
+ gpu, int8_output, int8_quantized_input, int8_input_scales, int8_input,
+ int8_weight, int8_weight_scales, 2, 2, 2, 0));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(int8_output, actual, 4));
+ CHECK(close_values(actual, int8_expected, 4, 0.06f));
+
+ const float mlp_input_values[] = {1, 2};
+ const float mlp_fc1_values[] = {1,0, 0,1, 2,0, 0,3};
+ const float mlp_fc2_values[] = {1,0, 0,1};
+ const float mlp_expected[] = {
+ 2.0f / (1.0f + expf(-1.0f)),
+ 12.0f / (1.0f + expf(-2.0f))
+ };
+ h3_gpu_tensor *mlp_input = h3_gpu_tensor_new_bf16(gpu, 2);
+ h3_gpu_tensor *mlp_fc1 = h3_gpu_tensor_new_bf16(gpu, 8);
+ h3_gpu_tensor *mlp_fc2 = h3_gpu_tensor_new_bf16(gpu, 4);
+ h3_gpu_tensor *mlp_output = h3_gpu_tensor_new_bf16(gpu, 2);
+ h3_gpu_tensor *mlp_activated = h3_gpu_tensor_new_bf16(gpu, 2);
+ CHECK(mlp_input && mlp_fc1 && mlp_fc2 && mlp_output && mlp_activated);
+ CHECK(h3_gpu_tensor_write_f32(mlp_input, mlp_input_values, 2));
+ CHECK(h3_gpu_tensor_write_f32(mlp_fc1, mlp_fc1_values, 8));
+ CHECK(h3_gpu_tensor_write_f32(mlp_fc2, mlp_fc2_values, 4));
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_mlp_bf16(gpu, mlp_output, mlp_input, mlp_fc1, mlp_fc2,
+ 1, 2, 2, 2));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(mlp_output, actual, 2));
+ CHECK(close_values(actual, mlp_expected, 2, 0.08f));
+
+ h3_gpu_tensor *mlp_fc1_i8 = h3_gpu_tensor_new_i8(gpu, 8);
+ h3_gpu_tensor *mlp_fc1_scales = h3_gpu_tensor_new_f32(gpu, 4);
+ h3_gpu_tensor *mlp_fc2_i8 = h3_gpu_tensor_new_i8(gpu, 4);
+ h3_gpu_tensor *mlp_fc2_scales = h3_gpu_tensor_new_f32(gpu, 2);
+ h3_gpu_tensor *mlp_quantized = h3_gpu_tensor_new_i8(gpu, 2);
+ h3_gpu_tensor *mlp_scales = h3_gpu_tensor_new_f32(gpu, 1);
+ CHECK(mlp_fc1_i8 && mlp_fc1_scales && mlp_fc2_i8 && mlp_fc2_scales &&
+ mlp_quantized && mlp_scales);
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_quantize_weight_int8(gpu, mlp_fc1_i8, mlp_fc1_scales,
+ mlp_fc1, 4, 2));
+ CHECK(h3_gpu_quantize_weight_int8(gpu, mlp_fc2_i8, mlp_fc2_scales,
+ mlp_fc2, 2, 2));
+ CHECK(h3_gpu_mlp_int8_bf16(
+ gpu, mlp_output, mlp_activated, mlp_quantized, mlp_scales, mlp_input,
+ mlp_fc1_i8, mlp_fc1_scales, mlp_fc2_i8, mlp_fc2_scales, mlp_fc1,
+ mlp_fc2, 1, 2, 2, 2, 0, 0, 0, 0));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(mlp_output, actual, 2));
+ CHECK(close_values(actual, mlp_expected, 2, 0.12f));
+
+ const float head_values[] = {1,2, 3,4, 5,6, 7,8};
+ const float identity_values[] = {
+ 1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1
+ };
+ const float head_expected[] = {1,2,5,6, 3,4,7,8};
+ h3_gpu_tensor *head_input = h3_gpu_tensor_new_bf16(gpu, 8);
+ h3_gpu_tensor *head_weight_source = h3_gpu_tensor_new_bf16(gpu, 16);
+ h3_gpu_tensor *head_weight = h3_gpu_tensor_new_i8(gpu, 16);
+ h3_gpu_tensor *head_weight_scales = h3_gpu_tensor_new_f32(gpu, 4);
+ h3_gpu_tensor *head_quantized = h3_gpu_tensor_new_i8(gpu, 8);
+ h3_gpu_tensor *head_scales = h3_gpu_tensor_new_f32(gpu, 2);
+ h3_gpu_tensor *head_output = h3_gpu_tensor_new_bf16(gpu, 8);
+ float head_actual[8];
+ CHECK(head_input && head_weight_source && head_weight &&
+ head_weight_scales && head_quantized && head_scales && head_output);
+ CHECK(h3_gpu_tensor_write_f32(head_input, head_values, 8));
+ CHECK(h3_gpu_tensor_write_f32(head_weight_source, identity_values, 16));
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_quantize_weight_int8(gpu, head_weight, head_weight_scales,
+ head_weight_source, 4, 4));
+ CHECK(h3_gpu_linear_int8_head_major_bf16(
+ gpu, head_output, head_quantized, head_scales, head_input, head_weight,
+ head_weight_scales, 2, 2, 2, 4));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(head_output, head_actual, 8));
+ CHECK(close_values(head_actual, head_expected, 8, 0.08f));
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_mlp_nax_bf16(gpu, mlp_output, mlp_activated, mlp_input,
+ mlp_fc1, mlp_fc2, 1, 2, 2, 2));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(mlp_activated, actual, 2));
+ CHECK(close_values(actual, mlp_expected, 2, 0.08f));
+
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(!h3_gpu_linear_bf16(gpu, bf_output, input, bf_weight, bf_bias,
+ 2, 3, 2));
+ CHECK(h3_gpu_error(gpu)[0] != '\0');
+ CHECK(h3_gpu_submit(gpu));
+
+ const uint32_t patch_input_dim = 32;
+ const uint32_t patch_output_dim = 5376;
+ float *patch_input_values = calloc(patch_input_dim + 1, sizeof(float));
+ float *patch_weight_values = calloc(
+ (size_t)patch_output_dim * patch_input_dim, sizeof(float));
+ float *patch_bias_values = malloc((size_t)patch_output_dim * sizeof(float));
+ float *patch_actual = malloc((size_t)(patch_output_dim * 2) * sizeof(float));
+ CHECK(patch_input_values && patch_weight_values && patch_bias_values &&
+ patch_actual);
+ for (uint32_t column = 0; column < patch_input_dim; column++)
+ patch_input_values[column + 1] = (float)(column + 1);
+ for (uint32_t row = 0; row < patch_output_dim; row++)
+ patch_bias_values[row] = 1.0f;
+ patch_weight_values[0] = 2.0f;
+ patch_weight_values[patch_input_dim + 1] = -1.0f;
+ h3_gpu_tensor *patch_input = h3_gpu_tensor_from_f32(
+ gpu, patch_input_values, patch_input_dim + 1);
+ h3_gpu_tensor *patch_weight = h3_gpu_tensor_from_f32(
+ gpu, patch_weight_values, (size_t)patch_output_dim * patch_input_dim);
+ h3_gpu_tensor *patch_bias = h3_gpu_tensor_from_f32(
+ gpu, patch_bias_values, patch_output_dim);
+ h3_gpu_tensor *patch_output = h3_gpu_tensor_new_bf16(
+ gpu, patch_output_dim + 2);
+ CHECK(patch_input && patch_weight && patch_bias && patch_output);
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_patch_linear_bf16_offset(
+ gpu, patch_output, 2, patch_input, 1, patch_weight, patch_bias, 1,
+ patch_input_dim, patch_output_dim));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32_range(
+ patch_output, 2, patch_actual, patch_output_dim));
+ CHECK(fabsf(patch_actual[0] - 3.0f) < 0.04f);
+ CHECK(fabsf(patch_actual[1] + 1.0f) < 0.04f);
+ CHECK(fabsf(patch_actual[patch_output_dim - 1] - 1.0f) < 0.04f);
+
+ const uint32_t map_value[] = {1};
+ h3_gpu_tensor *row_map = h3_gpu_tensor_from_u32(gpu, map_value, 1);
+ h3_gpu_tensor *mapped_output = h3_gpu_tensor_new_bf16(
+ gpu, (size_t)patch_output_dim * 2);
+ CHECK(row_map && mapped_output);
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_patch_linear_bf16_map(
+ gpu, mapped_output, patch_input, patch_weight, patch_bias, row_map,
+ 2, 1, patch_input_dim, patch_output_dim));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(mapped_output, patch_actual,
+ (size_t)patch_output_dim * 2));
+ CHECK(fabsf(patch_actual[patch_output_dim] - 1.0f) < 0.04f);
+ CHECK(fabsf(patch_actual[patch_output_dim + 1]) < 0.04f);
+
+ h3_gpu_tensor_free(mapped_output);
+ h3_gpu_tensor_free(row_map);
+ h3_gpu_tensor_free(patch_output);
+ h3_gpu_tensor_free(patch_bias);
+ h3_gpu_tensor_free(patch_weight);
+ h3_gpu_tensor_free(patch_input);
+ free(patch_actual);
+ free(patch_bias_values);
+ free(patch_weight_values);
+ free(patch_input_values);
+
+ h3_gpu_tensor_free(int8_output);
+ h3_gpu_tensor_free(int8_input_scales);
+ h3_gpu_tensor_free(int8_quantized_input);
+ h3_gpu_tensor_free(int8_weight_scales);
+ h3_gpu_tensor_free(int8_weight);
+ h3_gpu_tensor_free(int8_weight_source);
+ h3_gpu_tensor_free(int8_input);
+ h3_gpu_tensor_free(mlp_activated);
+ h3_gpu_tensor_free(mlp_output);
+ h3_gpu_tensor_free(mlp_fc2);
+ h3_gpu_tensor_free(mlp_fc1);
+ h3_gpu_tensor_free(mlp_input);
+ h3_gpu_tensor_free(mlp_scales);
+ h3_gpu_tensor_free(mlp_quantized);
+ h3_gpu_tensor_free(mlp_fc2_scales);
+ h3_gpu_tensor_free(mlp_fc2_i8);
+ h3_gpu_tensor_free(mlp_fc1_scales);
+ h3_gpu_tensor_free(mlp_fc1_i8);
+ h3_gpu_tensor_free(head_output);
+ h3_gpu_tensor_free(head_scales);
+ h3_gpu_tensor_free(head_quantized);
+ h3_gpu_tensor_free(head_weight_scales);
+ h3_gpu_tensor_free(head_weight);
+ h3_gpu_tensor_free(head_weight_source);
+ h3_gpu_tensor_free(head_input);
+
+ h3_gpu_tensor_free(bf_output);
+ h3_gpu_tensor_free(bf_bias);
+ h3_gpu_tensor_free(bf_weight);
+ h3_gpu_tensor_free(bf_input);
+ h3_gpu_tensor_free(output);
+ h3_gpu_tensor_free(bias);
+ h3_gpu_tensor_free(weight);
+ h3_gpu_tensor_free(input);
+ h3_gpu_free(gpu);
+ puts("ok: CUDA cuBLASLt F32/BF16 linear");
+ return 0;
+}
diff --git a/tests/test_cuda_ops.c b/tests/test_cuda_ops.c
new file mode 100644
index 00000000..eddc4da7
--- /dev/null
+++ b/tests/test_cuda_ops.c
@@ -0,0 +1,229 @@
+#include "h3_gpu.h"
+
+#include
+#include
+
+#define CHECK(condition) do { if (!(condition)) { \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #condition); \
+ return 1; \
+} } while (0)
+
+static int close_values(const float *actual, const float *expected,
+ size_t count, float tolerance) {
+ for (size_t index = 0; index < count; index++)
+ if (fabsf(actual[index] - expected[index]) > tolerance) return 0;
+ return 1;
+}
+
+static float snake_oracle(const float *input, const float *up,
+ const float *down, unsigned length, unsigned time) {
+ float result = 0.0f;
+ for (int down_tap = 0; down_tap < 12; down_tap++) {
+ int up_time = (int)time * 2 + down_tap - 5;
+ if (up_time < 0) up_time = 0;
+ if (up_time >= (int)length * 2) up_time = (int)length * 2 - 1;
+ int raw_time = up_time + 15;
+ float upsampled = 0.0f;
+ for (int up_tap = 0; up_tap < 12; up_tap++) {
+ int numerator = raw_time - up_tap;
+ if (numerator < 0 || (numerator & 1)) continue;
+ int source = numerator / 2 - 5;
+ if (source < 0) source = 0;
+ if (source >= (int)length) source = (int)length - 1;
+ upsampled += input[source] * 2.0f * up[up_tap];
+ }
+ float sine = sinf(upsampled);
+ result += (upsampled + sine * sine) * down[down_tap];
+ }
+ return result;
+}
+
+int main(void) {
+ char error[256];
+ h3_gpu *gpu = h3_gpu_create(NULL, error, sizeof(error));
+ CHECK(gpu);
+ float actual[64];
+
+ const float input1d_values[] = {1,2,3,4};
+ const float weight1d_values[] = {1,0,-1};
+ const float bias1d_values[] = {0.5f};
+ h3_gpu_tensor *input1d = h3_gpu_tensor_from_f32(gpu, input1d_values, 4);
+ h3_gpu_tensor *weight1d = h3_gpu_tensor_from_f32(gpu, weight1d_values, 3);
+ h3_gpu_tensor *bias1d = h3_gpu_tensor_from_f32(gpu, bias1d_values, 1);
+ h3_gpu_tensor *output1d = h3_gpu_tensor_new_f32(gpu, 4);
+ CHECK(input1d && weight1d && bias1d && output1d);
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_conv1d_f32(gpu, output1d, input1d, weight1d, bias1d,
+ 1, 4, 1, 1, 3, 1, 1));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(output1d, actual, 4));
+ CHECK(close_values(actual, (const float[]){-1.5f,-1.5f,-1.5f,3.5f},
+ 4, 1e-6f));
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_conv1d_stride_f32(gpu, output1d, input1d, weight1d, bias1d,
+ 1, 4, 1, 1, 3, 2, 1, 1));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(output1d, actual, 2));
+ CHECK(close_values(actual, (const float[]){-1.5f,-1.5f}, 2, 1e-6f));
+
+ const float transpose_input_values[] = {1,2};
+ const float transpose_weight_values[] = {1,2};
+ h3_gpu_tensor *transpose_input = h3_gpu_tensor_from_f32(
+ gpu, transpose_input_values, 2);
+ h3_gpu_tensor *transpose_weight = h3_gpu_tensor_from_f32(
+ gpu, transpose_weight_values, 2);
+ CHECK(transpose_input && transpose_weight);
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_conv_transpose1d_f32(gpu, output1d, transpose_input,
+ transpose_weight, NULL, 1, 2, 1, 1, 2, 2, 0));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(output1d, actual, 4));
+ CHECK(close_values(actual, (const float[]){1,2,2,4}, 4, 1e-6f));
+
+ h3_gpu_tensor *long_input = h3_gpu_tensor_new_f32(gpu, 65536);
+ h3_gpu_tensor *long_output = h3_gpu_tensor_new_f32(gpu, 65536);
+ CHECK(long_input && long_output);
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_conv1d_f32(gpu, long_output, long_input, weight1d, NULL,
+ 1, 65536, 1, 1, 1, 0, 1));
+ CHECK(h3_gpu_conv_transpose1d_f32(gpu, long_output, long_input,
+ transpose_weight, NULL, 1, 65536, 1, 1, 1, 1, 0));
+ CHECK(h3_gpu_conv3d_f32(gpu, long_output, long_input, weight1d, NULL,
+ 1, 1, 256, 256, 1, 1, 1, 1, 1, 1, 1, 1));
+ CHECK(h3_gpu_submit(gpu));
+
+ const float volume[] = {1,2,3,4,5,6,7,8};
+ const float volume_weight[] = {1,1,1,1,1,1,1,1};
+ h3_gpu_tensor *volume_input = h3_gpu_tensor_from_f32(gpu, volume, 8);
+ h3_gpu_tensor *volume_weights = h3_gpu_tensor_from_f32(
+ gpu, volume_weight, 8);
+ h3_gpu_tensor *volume_output = h3_gpu_tensor_new_f32(gpu, 1);
+ CHECK(volume_input && volume_weights && volume_output);
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_conv3d_f32(gpu, volume_output, volume_input, volume_weights,
+ bias1d, 1, 2, 2, 2, 1, 1, 2, 2, 2, 1, 1, 1));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(volume_output, actual, 1));
+ CHECK(fabsf(actual[0] - 36.5f) < 1e-6f);
+
+ const float image[] = {1,2,3,4};
+ h3_gpu_tensor *image_input = h3_gpu_tensor_from_f32(gpu, image, 4);
+ h3_gpu_tensor *image_output = h3_gpu_tensor_new_f32(gpu, 32);
+ CHECK(image_input && image_output);
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_vae_encoder_pad_f32(gpu, image_output, image_input,
+ 1, 1, 2, 2, 1, 1, 1, 1, 1, 1));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(image_output, actual, 32));
+ for (size_t index = 0; index < 16; index++) CHECK(actual[index] == 0.0f);
+ const float reflected[] = {4,3,4,3, 2,1,2,1, 4,3,4,3, 2,1,2,1};
+ CHECK(close_values(actual + 16, reflected, 16, 0.0f));
+
+ const float norm_input_values[] = {1,3,5,7};
+ const float norm_weight_values[] = {1,1};
+ const float norm_bias_values[] = {0,0};
+ h3_gpu_tensor *norm_input = h3_gpu_tensor_from_f32(gpu, norm_input_values, 4);
+ h3_gpu_tensor *norm_weight = h3_gpu_tensor_from_f32(gpu, norm_weight_values, 2);
+ h3_gpu_tensor *norm_bias = h3_gpu_tensor_from_f32(gpu, norm_bias_values, 2);
+ h3_gpu_tensor *norm_output = h3_gpu_tensor_new_f32(gpu, 4);
+ CHECK(norm_input && norm_weight && norm_bias && norm_output);
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_vae_encoder_group_norm_silu_f32(gpu, norm_output, norm_input,
+ norm_weight, norm_bias, 1, 1, 1, 2, 2, 1, 1e-5f));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(norm_output, actual, 4));
+ for (size_t index = 0; index < 4; index++) {
+ float normalized = (norm_input_values[index] - 4.0f) /
+ sqrtf(5.0f + 1e-5f);
+ float expected = normalized / (1.0f + expf(-normalized));
+ CHECK(fabsf(actual[index] - expected) < 2e-6f);
+ }
+
+ const float qkv_values[] = {1,2, 3,4, 5,6};
+ const float q_bias_values[] = {10,20};
+ const float k_bias_values[] = {30,40};
+ const float v_bias_values[] = {50,60};
+ h3_gpu_tensor *qkv = h3_gpu_tensor_from_f32(gpu, qkv_values, 6);
+ h3_gpu_tensor *q_bias = h3_gpu_tensor_from_f32(gpu, q_bias_values, 2);
+ h3_gpu_tensor *k_bias = h3_gpu_tensor_from_f32(gpu, k_bias_values, 2);
+ h3_gpu_tensor *v_bias = h3_gpu_tensor_from_f32(gpu, v_bias_values, 2);
+ h3_gpu_tensor *q = h3_gpu_tensor_new_f32(gpu, 2);
+ h3_gpu_tensor *k = h3_gpu_tensor_new_f32(gpu, 2);
+ h3_gpu_tensor *v = h3_gpu_tensor_new_f32(gpu, 2);
+ CHECK(qkv && q_bias && k_bias && v_bias && q && k && v);
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_audio_qkv_split_f32(gpu, q, k, v, qkv, q_bias, k_bias,
+ v_bias, 1, 1, 1, 2));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(q, actual, 2));
+ CHECK(close_values(actual, (const float[]){11,22}, 2, 0.0f));
+
+ const float audio_multi_values[] = {
+ 1,2,3,4, 0,0,0,0, 0,0,0,0,
+ 5,6,7,8, 0,0,0,0, 0,0,0,0};
+ const float audio_zero_bias[] = {0,0,0,0};
+ h3_gpu_tensor *audio_multi = h3_gpu_tensor_from_f32(
+ gpu, audio_multi_values, 24);
+ h3_gpu_tensor *audio_bias = h3_gpu_tensor_from_f32(
+ gpu, audio_zero_bias, 4);
+ h3_gpu_tensor *audio_q = h3_gpu_tensor_new_f32(gpu, 8);
+ h3_gpu_tensor *audio_k = h3_gpu_tensor_new_f32(gpu, 8);
+ h3_gpu_tensor *audio_v = h3_gpu_tensor_new_f32(gpu, 8);
+ CHECK(audio_multi && audio_bias && audio_q && audio_k && audio_v);
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_audio_qkv_split_f32(gpu, audio_q, audio_k, audio_v,
+ audio_multi, audio_bias, audio_bias, audio_bias, 1, 2, 2, 2));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(audio_q, actual, 8));
+ CHECK(close_values(actual,
+ (const float[]){1,2,5,6,3,4,7,8}, 8, 0.0f));
+
+ const float attended_values[] = {1,2,3,4,5,6,7,8};
+ h3_gpu_tensor *attended = h3_gpu_tensor_from_f32(gpu, attended_values, 8);
+ h3_gpu_tensor *pooled = h3_gpu_tensor_new_f32(gpu, 2);
+ CHECK(attended && pooled);
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_audio_attention_pool_f32(gpu, pooled, attended,
+ 1, 1, 2, 4, 2));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(pooled, actual, 2));
+ CHECK(close_values(actual, (const float[]){3.5f,5.5f}, 2, 0.0f));
+
+ const float filter[] = {0.02f,0.04f,0.06f,0.08f,0.1f,0.2f,
+ 0.2f,0.1f,0.08f,0.06f,0.04f,0.02f};
+ const float logs[] = {0};
+ h3_gpu_tensor *filter_tensor = h3_gpu_tensor_from_f32(gpu, filter, 12);
+ h3_gpu_tensor *log_tensor = h3_gpu_tensor_from_f32(gpu, logs, 1);
+ h3_gpu_tensor *snake_output = h3_gpu_tensor_new_f32(gpu, 4);
+ CHECK(filter_tensor && log_tensor && snake_output);
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_alias_free_snake_f32(gpu, long_output, long_input,
+ log_tensor, log_tensor, filter_tensor, filter_tensor, 1, 65536, 1));
+ CHECK(h3_gpu_alias_free_snake_f32(gpu, snake_output, input1d, log_tensor,
+ log_tensor, filter_tensor, filter_tensor, 1, 4, 1));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(snake_output, actual, 4));
+ for (unsigned time = 0; time < 4; time++)
+ CHECK(fabsf(actual[time] - snake_oracle(input1d_values, filter, filter,
+ 4, time)) < 2e-5f);
+
+ h3_gpu_tensor_free(snake_output); h3_gpu_tensor_free(log_tensor);
+ h3_gpu_tensor_free(filter_tensor); h3_gpu_tensor_free(pooled);
+ h3_gpu_tensor_free(attended); h3_gpu_tensor_free(audio_v);
+ h3_gpu_tensor_free(audio_k); h3_gpu_tensor_free(audio_q);
+ h3_gpu_tensor_free(audio_bias); h3_gpu_tensor_free(audio_multi);
+ h3_gpu_tensor_free(v); h3_gpu_tensor_free(k);
+ h3_gpu_tensor_free(q); h3_gpu_tensor_free(v_bias); h3_gpu_tensor_free(k_bias);
+ h3_gpu_tensor_free(q_bias); h3_gpu_tensor_free(qkv);
+ h3_gpu_tensor_free(norm_output); h3_gpu_tensor_free(norm_bias);
+ h3_gpu_tensor_free(norm_weight); h3_gpu_tensor_free(norm_input);
+ h3_gpu_tensor_free(image_output); h3_gpu_tensor_free(image_input);
+ h3_gpu_tensor_free(volume_output); h3_gpu_tensor_free(volume_weights);
+ h3_gpu_tensor_free(volume_input); h3_gpu_tensor_free(transpose_weight);
+ h3_gpu_tensor_free(transpose_input); h3_gpu_tensor_free(long_output);
+ h3_gpu_tensor_free(long_input); h3_gpu_tensor_free(output1d);
+ h3_gpu_tensor_free(bias1d); h3_gpu_tensor_free(weight1d);
+ h3_gpu_tensor_free(input1d); h3_gpu_free(gpu);
+ puts("ok: CUDA convolution, audio and VAE operators");
+ return 0;
+}
diff --git a/tests/test_cuda_primitives.c b/tests/test_cuda_primitives.c
new file mode 100644
index 00000000..4c50f419
--- /dev/null
+++ b/tests/test_cuda_primitives.c
@@ -0,0 +1,201 @@
+#include "h3_gpu.h"
+
+#include
+#include
+#include
+#include
+
+#define CHECK(x) do { if (!(x)) { \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #x); return 1; \
+} } while (0)
+
+static int close_array(const float *got, const float *want, size_t count,
+ float tolerance) {
+ for (size_t index = 0; index < count; index++)
+ if (fabsf(got[index] - want[index]) > tolerance) return 0;
+ return 1;
+}
+
+int main(void) {
+ char error[256];
+ h3_gpu *gpu = h3_gpu_create(NULL, error, sizeof(error));
+ CHECK(gpu);
+ const float input[] = {-2.0f, -0.5f, 0.5f, 2.0f};
+ const float other[] = {1.0f, 2.0f, 3.0f, 4.0f};
+ h3_gpu_tensor *x = h3_gpu_tensor_from_f32(gpu, input, 4);
+ h3_gpu_tensor *y = h3_gpu_tensor_from_f32(gpu, other, 4);
+ h3_gpu_tensor *out = h3_gpu_tensor_new_f32(gpu, 4);
+ h3_gpu_tensor *bx = h3_gpu_tensor_new_bf16(gpu, 4);
+ h3_gpu_tensor *by = h3_gpu_tensor_new_bf16(gpu, 4);
+ h3_gpu_tensor *bout = h3_gpu_tensor_new_bf16(gpu, 4);
+ CHECK(x && y && out && bx && by && bout);
+ CHECK(h3_gpu_tensor_write_f32(bx, input, 4));
+ CHECK(h3_gpu_tensor_write_f32(by, other, 4));
+
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_silu_f32(gpu, out, x, 4));
+ CHECK(h3_gpu_submit(gpu));
+ float got[4];
+ CHECK(h3_gpu_tensor_read_f32(out, got, 4));
+ float want_silu[4];
+ for (size_t i = 0; i < 4; i++)
+ want_silu[i] = input[i] / (1.0f + expf(-input[i]));
+ CHECK(close_array(got, want_silu, 4, 1e-6f));
+
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_add_scaled_f32(gpu, out, x, y, 0.5f, -2.0f, 4));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(out, got, 4));
+ const float want_scaled[] = {-3.0f, -4.25f, -5.75f, -7.0f};
+ CHECK(close_array(got, want_scaled, 4, 1e-7f));
+
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_add_bf16(gpu, bout, bx, by, 4));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(bout, got, 4));
+ const float want_add[] = {-1.0f, 1.5f, 3.5f, 6.0f};
+ CHECK(close_array(got, want_add, 4, 0.01f));
+
+ const float fused_values[] = {-2, -0.5f, 0.5f, 2, 1, 2, 3, 4};
+ const float unit_values[] = {1,1,1,1};
+ const float magnitude_values[] = {2};
+ h3_gpu_tensor *fused = h3_gpu_tensor_from_f32(gpu, fused_values, 8);
+ h3_gpu_tensor *bfused = h3_gpu_tensor_new_bf16(gpu, 8);
+ h3_gpu_tensor *units = h3_gpu_tensor_from_f32(gpu, unit_values, 4);
+ h3_gpu_tensor *bunits = h3_gpu_tensor_new_bf16(gpu, 4);
+ h3_gpu_tensor *magnitude = h3_gpu_tensor_from_f32(gpu, magnitude_values, 1);
+ CHECK(fused && bfused && units && bunits && magnitude);
+ CHECK(h3_gpu_tensor_write_f32(bfused, fused_values, 8));
+ CHECK(h3_gpu_tensor_write_f32(bunits, unit_values, 4));
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_clip_f32(gpu, out, x, 4, -1.0f, 1.0f));
+ CHECK(h3_gpu_scale_add_f32(gpu, out, x, y, units, 1, 4));
+ CHECK(h3_gpu_swiglu_f32(gpu, out, fused, 1, 4));
+ CHECK(h3_gpu_swiglu_bf16(gpu, bout, bfused, 1, 4));
+ CHECK(h3_gpu_geglu_f32(gpu, out, x, y, 4));
+ CHECK(h3_gpu_snake1d_f32(gpu, out, x, units, 1, 1, 4));
+ CHECK(h3_gpu_weight_norm_f32(gpu, out, x, magnitude, 1, 4));
+ CHECK(h3_gpu_sub_bf16(gpu, bout, by, bx, 4));
+ CHECK(h3_gpu_silu_bf16(gpu, bout, bx, 4));
+ CHECK(h3_gpu_gelu_bf16(gpu, bout, bx, 4, 0));
+ CHECK(h3_gpu_gelu_bf16(gpu, bout, bx, 4, 1));
+ CHECK(h3_gpu_silu_mul_bf16(gpu, bout, bx, by, 4));
+ CHECK(h3_gpu_cast_f32_to_bf16(gpu, bout, x, 4));
+ CHECK(h3_gpu_cast_bf16_to_f32(gpu, out, bout, 4));
+ CHECK(h3_gpu_euler_bf16(gpu, out, 0, bx, by, 4, 0.1f, 0.5f));
+ CHECK(h3_gpu_rms_norm_bf16(gpu, bout, bx, bunits, 1, 4, 1e-5f));
+ CHECK(h3_gpu_layer_norm_bf16(gpu, bout, bx, bunits, bunits,
+ 1, 4, 1e-5f));
+ CHECK(h3_gpu_submit(gpu));
+
+ const float norm_input[] = {1, 2, 3, 4, -1, -2, -3, -4};
+ const float weights[] = {1, 1, 1, 1};
+ const float biases[] = {0, 0, 0, 0};
+ h3_gpu_tensor *nx = h3_gpu_tensor_from_f32(gpu, norm_input, 8);
+ h3_gpu_tensor *nw = h3_gpu_tensor_from_f32(gpu, weights, 4);
+ h3_gpu_tensor *nb = h3_gpu_tensor_from_f32(gpu, biases, 4);
+ h3_gpu_tensor *no = h3_gpu_tensor_new_f32(gpu, 8);
+ CHECK(nx && nw && nb && no);
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_rms_norm_f32(gpu, no, nx, nw, 2, 4, 1e-5f));
+ CHECK(h3_gpu_submit(gpu));
+ float norm_got[8];
+ CHECK(h3_gpu_tensor_read_f32(no, norm_got, 8));
+ float inverse = 1.0f / sqrtf(7.5f + 1e-5f);
+ for (size_t i = 0; i < 8; i++) CHECK(fabsf(norm_got[i] - norm_input[i] * inverse) < 2e-6f);
+
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_layer_norm_f32(gpu, no, nx, nw, nb, 2, 4, 1e-5f));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(no, norm_got, 8));
+ CHECK(fabsf(norm_got[0] + 1.341635f) < 2e-5f);
+ CHECK(fabsf(norm_got[3] - 1.341635f) < 2e-5f);
+
+ const uint32_t row_map_values[] = {1, 0};
+ const float modulation_values[] = {
+ 0,0,0,0, 0.1f,0.2f,0.3f,0.4f, 0.5f,0.5f,0.5f,0.5f,
+ 0,0,0,0, -0.1f,-0.2f,-0.3f,-0.4f, 2,2,2,2
+ };
+ h3_gpu_tensor *row_map = h3_gpu_tensor_from_u32(gpu, row_map_values, 2);
+ h3_gpu_tensor *mod = h3_gpu_tensor_from_f32(gpu, modulation_values, 24);
+ h3_gpu_tensor *bnx = h3_gpu_tensor_new_bf16(gpu, 8);
+ h3_gpu_tensor *bnw = h3_gpu_tensor_new_bf16(gpu, 4);
+ h3_gpu_tensor *bmod = h3_gpu_tensor_new_bf16(gpu, 24);
+ h3_gpu_tensor *bgated = h3_gpu_tensor_new_bf16(gpu, 8);
+ h3_gpu_tensor *bnout = h3_gpu_tensor_new_bf16(gpu, 8);
+ CHECK(row_map && mod && bnx && bnw && bmod && bgated && bnout);
+ CHECK(h3_gpu_tensor_write_f32(bnx, norm_input, 8));
+ CHECK(h3_gpu_tensor_write_f32(bnw, weights, 4));
+ CHECK(h3_gpu_tensor_write_f32(bmod, modulation_values, 24));
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_adaln_f32(gpu, no, nx, nw, mod, row_map,
+ 2, 4, 3, 1, 2, 1e-5f));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(no, norm_got, 8));
+ CHECK(fabsf(norm_got[0] - (norm_input[0] * inverse * 3.0f - 0.1f)) < 2e-5f);
+ CHECK(fabsf(norm_got[4] - (norm_input[4] * inverse * 1.5f + 0.1f)) < 2e-5f);
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_gate_f32(gpu, no, nx, nx, mod, row_map, 2, 4, 3, 2));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(no, norm_got, 8));
+ CHECK(norm_got[0] == 3.0f && norm_got[4] == -1.5f);
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_adaln_bf16_offset(gpu, bnout, bnx, 0, bnw, bmod, row_map,
+ 2, 4, 3, 1, 2, 1e-5f));
+ CHECK(h3_gpu_gate_adaln_bf16(gpu, bgated, bnout, bnx, bnx, bnw,
+ bmod, bmod, row_map, 2, 4, 3, 2, 1, 2, 1e-5f));
+ CHECK(h3_gpu_submit(gpu));
+ const float identity4[] = {
+ 1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1
+ };
+ h3_gpu_tensor *bidentity = h3_gpu_tensor_new_bf16(gpu, 16);
+ h3_gpu_tensor *binverse = h3_gpu_tensor_new_f32(gpu, 2);
+ h3_gpu_tensor *blinear = h3_gpu_tensor_new_bf16(gpu, 8);
+ h3_gpu_tensor *bquantized = h3_gpu_tensor_new_i8(gpu, 12);
+ h3_gpu_tensor *bscales = h3_gpu_tensor_new_f32(gpu, 3);
+ CHECK(bidentity && binverse && blinear && bquantized && bscales);
+ CHECK(h3_gpu_tensor_write_f32(bidentity, identity4, 16));
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_adaln_linear_bf16(gpu, blinear, binverse, bnx, 0, bnw,
+ bmod, row_map, bidentity, NULL, 2, 4, 4, 3, 1, 2, 1e-5f));
+ CHECK(h3_gpu_gate_adaln_quantize_int8(gpu, bgated, bquantized, bscales,
+ bnx, bnx, bnw, bmod, bmod, row_map, 2, 3, 4, 3, 2, 1, 2, 1e-5f));
+ CHECK(h3_gpu_submit(gpu));
+ float inverse_values[2];
+ CHECK(h3_gpu_tensor_read_f32(binverse, inverse_values, 2));
+ CHECK(fabsf(inverse_values[0] - inverse) < 2e-5f);
+
+ const float embedding_values[] = {1,2, 3,4, 5,6};
+ const uint32_t ids[] = {2, 0, 9};
+ h3_gpu_tensor *embedding = h3_gpu_tensor_new_bf16(gpu, 6);
+ h3_gpu_tensor *embedding_out = h3_gpu_tensor_new_bf16(gpu, 6);
+ h3_gpu_tensor *token_ids = h3_gpu_tensor_from_u32(gpu, ids, 3);
+ CHECK(embedding && embedding_out && token_ids);
+ CHECK(h3_gpu_tensor_write_f32(embedding, embedding_values, 6));
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_embedding_bf16(gpu, embedding_out, embedding, token_ids,
+ 3, 3, 2));
+ CHECK(h3_gpu_submit(gpu));
+ float embedding_got[6];
+ CHECK(h3_gpu_tensor_read_f32(embedding_out, embedding_got, 6));
+ const float embedding_want[] = {5,6, 1,2, 0,0};
+ CHECK(close_array(embedding_got, embedding_want, 6, 0.0f));
+
+ h3_gpu_tensor_free(bscales); h3_gpu_tensor_free(bquantized);
+ h3_gpu_tensor_free(blinear); h3_gpu_tensor_free(binverse);
+ h3_gpu_tensor_free(bidentity);
+ h3_gpu_tensor_free(token_ids); h3_gpu_tensor_free(embedding_out);
+ h3_gpu_tensor_free(embedding); h3_gpu_tensor_free(bnout);
+ h3_gpu_tensor_free(bgated); h3_gpu_tensor_free(bmod);
+ h3_gpu_tensor_free(bnw); h3_gpu_tensor_free(bnx); h3_gpu_tensor_free(mod);
+ h3_gpu_tensor_free(row_map); h3_gpu_tensor_free(magnitude);
+ h3_gpu_tensor_free(bunits); h3_gpu_tensor_free(units);
+ h3_gpu_tensor_free(bfused); h3_gpu_tensor_free(fused);
+ h3_gpu_tensor_free(no);
+ h3_gpu_tensor_free(nb); h3_gpu_tensor_free(nw);
+ h3_gpu_tensor_free(nx); h3_gpu_tensor_free(bout); h3_gpu_tensor_free(by);
+ h3_gpu_tensor_free(bx); h3_gpu_tensor_free(out); h3_gpu_tensor_free(y);
+ h3_gpu_tensor_free(x); h3_gpu_free(gpu);
+ puts("ok: CUDA elementwise and normalization primitives");
+ return 0;
+}
diff --git a/tests/test_cuda_rope_tokens.c b/tests/test_cuda_rope_tokens.c
new file mode 100644
index 00000000..b9eab73b
--- /dev/null
+++ b/tests/test_cuda_rope_tokens.c
@@ -0,0 +1,234 @@
+#include "h3_gpu.h"
+
+#include
+#include
+#include
+#include
+
+#define CHECK(x) do { if (!(x)) { \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #x); return 1; \
+} } while (0)
+
+static int close_values(const float *got, const float *want, size_t count,
+ float tolerance) {
+ for (size_t i = 0; i < count; i++)
+ if (fabsf(got[i] - want[i]) > tolerance) return 0;
+ return 1;
+}
+
+int main(void) {
+ char error[256];
+ h3_gpu *gpu = h3_gpu_create(NULL, error, sizeof(error));
+ CHECK(gpu);
+ const float qkv_values[] = {1,2,3,4, 5,6,7,8, 9,10,11,12};
+ const float ones[] = {1,1,1,1};
+ const float cos_values[] = {1,1};
+ const float sin_values[] = {0,0};
+ h3_gpu_tensor *qkv = h3_gpu_tensor_from_f32(gpu, qkv_values, 12);
+ h3_gpu_tensor *weight = h3_gpu_tensor_from_f32(gpu, ones, 4);
+ h3_gpu_tensor *cosine = h3_gpu_tensor_from_f32(gpu, cos_values, 2);
+ h3_gpu_tensor *sine = h3_gpu_tensor_from_f32(gpu, sin_values, 2);
+ h3_gpu_tensor *q = h3_gpu_tensor_new_f32(gpu, 4);
+ h3_gpu_tensor *k = h3_gpu_tensor_new_f32(gpu, 4);
+ h3_gpu_tensor *v = h3_gpu_tensor_new_f32(gpu, 4);
+ CHECK(qkv && weight && cosine && sine && q && k && v);
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_qkv_rope_f32(gpu, q, k, v, qkv, weight, weight,
+ cosine, sine, 1, 1, 4, 2, 1e-5f));
+ CHECK(h3_gpu_submit(gpu));
+ float got[12];
+ CHECK(h3_gpu_tensor_read_f32(q, got, 4));
+ float q_inverse = 1.0f / sqrtf(7.5f + 1e-5f);
+ const float q_want[] = {1*q_inverse,2*q_inverse,3*q_inverse,4*q_inverse};
+ CHECK(close_values(got, q_want, 4, 2e-6f));
+ CHECK(h3_gpu_tensor_read_f32(v, got, 4));
+ CHECK(close_values(got, qkv_values + 8, 4, 0.0f));
+
+ const float multi_qkv_values[] = {
+ 1,2,3,4, 5,6,7,8, 9,10,11,12,
+ 13,14,15,16, 17,18,19,20, 21,22,23,24};
+ h3_gpu_tensor *multi_qkv = h3_gpu_tensor_from_f32(
+ gpu, multi_qkv_values, 24);
+ h3_gpu_tensor *multi_q = h3_gpu_tensor_new_f32(gpu, 8);
+ h3_gpu_tensor *multi_k = h3_gpu_tensor_new_f32(gpu, 8);
+ h3_gpu_tensor *multi_v = h3_gpu_tensor_new_f32(gpu, 8);
+ CHECK(multi_qkv && multi_q && multi_k && multi_v);
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_qkv_rope_f32(gpu, multi_q, multi_k, multi_v, multi_qkv,
+ weight, weight, weight, weight, 2, 2, 2, 0, 1e-5f));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(multi_v, got, 8));
+ CHECK(close_values(got,
+ (const float[]){9,10,21,22,11,12,23,24}, 8, 0.0f));
+
+ h3_gpu_tensor *bqkv = h3_gpu_tensor_new_bf16(gpu, 12);
+ h3_gpu_tensor *bweight = h3_gpu_tensor_new_bf16(gpu, 4);
+ h3_gpu_tensor *bcos = h3_gpu_tensor_new_bf16(gpu, 2);
+ h3_gpu_tensor *bsin = h3_gpu_tensor_new_bf16(gpu, 2);
+ h3_gpu_tensor *bq = h3_gpu_tensor_new_bf16(gpu, 4);
+ h3_gpu_tensor *bk = h3_gpu_tensor_new_bf16(gpu, 4);
+ h3_gpu_tensor *bv = h3_gpu_tensor_new_bf16(gpu, 4);
+ CHECK(bqkv && bweight && bcos && bsin && bq && bk && bv);
+ CHECK(h3_gpu_tensor_write_f32(bqkv, qkv_values, 12));
+ CHECK(h3_gpu_tensor_write_f32(bweight, ones, 4));
+ CHECK(h3_gpu_tensor_write_f32(bcos, cos_values, 2));
+ CHECK(h3_gpu_tensor_write_f32(bsin, sin_values, 2));
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_qkv_rope_bf16(gpu, bq, bk, bv, bqkv, bweight, bweight,
+ bcos, bsin, 1, 1, 4, 2, 1e-5f));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(bq, got, 4));
+ CHECK(close_values(got, q_want, 4, 0.01f));
+ const float projection_input_values[] = {1,1,1,1};
+ float projection_weight_values[48] = {0};
+ for (size_t row = 0; row < 12; row++)
+ projection_weight_values[row * 4] = qkv_values[row];
+ h3_gpu_tensor *projection_input = h3_gpu_tensor_new_bf16(gpu, 4);
+ h3_gpu_tensor *projection_weight = h3_gpu_tensor_new_bf16(gpu, 48);
+ CHECK(projection_input && projection_weight);
+ CHECK(h3_gpu_tensor_write_f32(projection_input, projection_input_values, 4));
+ CHECK(h3_gpu_tensor_write_f32(projection_weight, projection_weight_values, 48));
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_grouped_qkv_linear_rope_bf16(
+ gpu, bq, bk, bv, bqkv, projection_input, projection_weight, bweight,
+ bweight, bcos, bsin, 1, 4, 1, 4, 2, 1e-5f));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(bq, got, 4));
+ CHECK(close_values(got, q_want, 4, 0.01f));
+ h3_gpu_tensor *projection_weight_i8 = h3_gpu_tensor_new_i8(gpu, 48);
+ h3_gpu_tensor *projection_weight_scales = h3_gpu_tensor_new_f32(gpu, 12);
+ h3_gpu_tensor *projection_quantized = h3_gpu_tensor_new_i8(gpu, 4);
+ h3_gpu_tensor *projection_scales = h3_gpu_tensor_new_f32(gpu, 1);
+ CHECK(projection_weight_i8 && projection_weight_scales &&
+ projection_quantized && projection_scales);
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_quantize_weight_int8(gpu, projection_weight_i8,
+ projection_weight_scales, projection_weight, 12, 4));
+ CHECK(h3_gpu_grouped_qkv_linear_rope_int8(
+ gpu, bq, bk, bv, projection_quantized, projection_scales,
+ projection_input, projection_weight_i8, projection_weight_scales,
+ bweight, bweight, bcos, bsin, 1, 4, 1, 4, 2, 1e-5f,
+ 0, 0, 0, 0));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(bq, got, 4));
+ CHECK(close_values(got, q_want, 4, 0.02f));
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_grouped_qkv_rope_bf16(gpu, bq, bk, bv, bqkv, bweight,
+ bweight, bcos, bsin, 1, 1, 4, 2, 1e-5f));
+ CHECK(h3_gpu_grouped_qkv_rope_bf16(gpu, bq, bk, bv, bqkv, bweight,
+ bweight, bweight, bweight, 1, 1, 4, 0, 1e-5f));
+ CHECK(h3_gpu_vision_qkv_rope_bf16(gpu, bq, bk, bv, bqkv, bcos, bsin,
+ 1, 1, 4, 2));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(bq, got, 4));
+ CHECK(close_values(got, qkv_values, 4, 0.0f));
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_video_qkv_rope_f32(gpu, q, k, v, qkv, cosine, sine,
+ 1, 1, 4, 2, 1e-5f));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(q, got, 4));
+ CHECK(close_values(got, q_want, 4, 2e-6f));
+
+ const float text_values[] = {1,2,3,4};
+ const float zero_cos[] = {0,0};
+ const float one_sin[] = {1,1};
+ h3_gpu_tensor *text_q = h3_gpu_tensor_new_bf16(gpu, 4);
+ h3_gpu_tensor *text_k = h3_gpu_tensor_new_bf16(gpu, 4);
+ h3_gpu_tensor *text_cos = h3_gpu_tensor_from_f32(gpu, zero_cos, 2);
+ h3_gpu_tensor *text_sin = h3_gpu_tensor_from_f32(gpu, one_sin, 2);
+ CHECK(text_q && text_k && text_cos && text_sin);
+ CHECK(h3_gpu_tensor_write_f32(text_q, text_values, 4));
+ CHECK(h3_gpu_tensor_write_f32(text_k, text_values, 4));
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_rope_text_bf16(gpu, text_q, text_k, text_cos, text_sin,
+ 1, 1, 1, 4));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(text_q, got, 4));
+ const float text_want[] = {-3,-4,1,2};
+ CHECK(close_values(got, text_want, 4, 0.0f));
+ CHECK(h3_gpu_tensor_write_f32(text_q, text_values, 4));
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_head_rms_norm_bf16(gpu, text_q, bweight, 1, 1, 4, 1e-5f));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(text_q, got, 4));
+ CHECK(close_values(got, q_want, 4, 0.01f));
+ h3_gpu_tensor *text_q_out = h3_gpu_tensor_new_bf16(gpu, 4);
+ h3_gpu_tensor *text_k_out = h3_gpu_tensor_new_bf16(gpu, 4);
+ h3_gpu_tensor *bzero_cos = h3_gpu_tensor_new_bf16(gpu, 2);
+ h3_gpu_tensor *bone_sin = h3_gpu_tensor_new_bf16(gpu, 2);
+ CHECK(text_q_out && text_k_out && bzero_cos && bone_sin);
+ CHECK(h3_gpu_tensor_write_f32(text_q, text_values, 4));
+ CHECK(h3_gpu_tensor_write_f32(text_k, text_values, 4));
+ CHECK(h3_gpu_tensor_write_f32(bzero_cos, zero_cos, 2));
+ CHECK(h3_gpu_tensor_write_f32(bone_sin, one_sin, 2));
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_text_qk_rope_bf16(gpu, text_q_out, text_k_out, text_q,
+ text_k, bweight, bweight, bzero_cos, bone_sin, 1, 1, 1, 4, 1e-5f));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(text_q_out, got, 4));
+ const float text_norm_want[] = {-3*q_inverse,-4*q_inverse,
+ 1*q_inverse, 2*q_inverse};
+ CHECK(close_values(got, text_norm_want, 4, 0.01f));
+
+ const float pool_input[] = {
+ 1,2, 3,4, 5,6, 7,8
+ };
+ const uint32_t pairs_values[] = {0,0, 1,2, 3,3};
+ const uint32_t baseline_index_values[] = {UINT32_MAX, 0, UINT32_MAX};
+ h3_gpu_tensor *pool_source = h3_gpu_tensor_new_bf16(gpu, 8);
+ h3_gpu_tensor *pool_output = h3_gpu_tensor_new_bf16(gpu, 6);
+ h3_gpu_tensor *original = h3_gpu_tensor_new_bf16(gpu, 8);
+ h3_gpu_tensor *baseline = h3_gpu_tensor_new_bf16(gpu, 2);
+ h3_gpu_tensor *pairs = h3_gpu_tensor_from_u32(gpu, pairs_values, 6);
+ h3_gpu_tensor *baseline_indices = h3_gpu_tensor_from_u32(
+ gpu, baseline_index_values, 3);
+ CHECK(pool_source && pool_output && original && baseline && pairs && baseline_indices);
+ CHECK(h3_gpu_tensor_write_f32(pool_source, pool_input, 8));
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_token_pool_bf16(gpu, pool_output, pool_source, 0, original,
+ 0, baseline, 0, baseline_indices, pairs, 4, 3, 1, 2));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(pool_output, got, 6));
+ const float pooled_want[] = {1,2, 4,5, 7,8};
+ CHECK(close_values(got, pooled_want, 6, 0.0f));
+
+ const float reduced_values[] = {10,20, 6,8, 70,80};
+ const uint32_t parents_values[] = {0,1,1,2};
+ h3_gpu_tensor *reduced = h3_gpu_tensor_new_bf16(gpu, 6);
+ h3_gpu_tensor *expanded = h3_gpu_tensor_new_bf16(gpu, 8);
+ h3_gpu_tensor *parents = h3_gpu_tensor_from_u32(gpu, parents_values, 4);
+ CHECK(reduced && expanded && parents);
+ CHECK(h3_gpu_tensor_write_f32(reduced, reduced_values, 6));
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_token_expand_delta_bf16(gpu, expanded, original, 0, reduced,
+ baseline, 0, baseline_indices, parents, 4, 3, 1, 2, 1, 1.0f));
+ CHECK(h3_gpu_submit(gpu));
+ CHECK(h3_gpu_tensor_read_f32(expanded, got, 8));
+ const float expanded_want[] = {10,20, 5,7, 7,9, 70,80};
+ CHECK(close_values(got, expanded_want, 8, 0.0f));
+
+ h3_gpu_tensor_free(parents); h3_gpu_tensor_free(expanded);
+ h3_gpu_tensor_free(reduced); h3_gpu_tensor_free(baseline_indices);
+ h3_gpu_tensor_free(pairs); h3_gpu_tensor_free(baseline);
+ h3_gpu_tensor_free(original); h3_gpu_tensor_free(pool_output);
+ h3_gpu_tensor_free(pool_source); h3_gpu_tensor_free(bone_sin);
+ h3_gpu_tensor_free(bzero_cos); h3_gpu_tensor_free(text_k_out);
+ h3_gpu_tensor_free(text_q_out); h3_gpu_tensor_free(text_sin);
+ h3_gpu_tensor_free(text_cos); h3_gpu_tensor_free(text_k);
+ h3_gpu_tensor_free(projection_scales);
+ h3_gpu_tensor_free(projection_quantized);
+ h3_gpu_tensor_free(projection_weight_scales);
+ h3_gpu_tensor_free(projection_weight_i8);
+ h3_gpu_tensor_free(projection_weight);
+ h3_gpu_tensor_free(projection_input);
+ h3_gpu_tensor_free(text_q); h3_gpu_tensor_free(multi_v);
+ h3_gpu_tensor_free(multi_k); h3_gpu_tensor_free(multi_q);
+ h3_gpu_tensor_free(multi_qkv); h3_gpu_tensor_free(v); h3_gpu_tensor_free(k);
+ h3_gpu_tensor_free(q); h3_gpu_tensor_free(bv); h3_gpu_tensor_free(bk);
+ h3_gpu_tensor_free(bq); h3_gpu_tensor_free(bsin); h3_gpu_tensor_free(bcos);
+ h3_gpu_tensor_free(bweight); h3_gpu_tensor_free(bqkv);
+ h3_gpu_tensor_free(sine); h3_gpu_tensor_free(cosine);
+ h3_gpu_tensor_free(weight); h3_gpu_tensor_free(qkv); h3_gpu_free(gpu);
+ puts("ok: CUDA RoPE and token transforms");
+ return 0;
+}
diff --git a/tests/test_cuda_runtime.c b/tests/test_cuda_runtime.c
new file mode 100644
index 00000000..1b30a948
--- /dev/null
+++ b/tests/test_cuda_runtime.c
@@ -0,0 +1,107 @@
+#include "h3_gpu.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define CHECK(expression) do { \
+ if (!(expression)) { \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #expression); \
+ return 1; \
+ } \
+} while (0)
+
+int main(void) {
+ char error[256];
+ h3_gpu *gpu = h3_gpu_create(NULL, error, sizeof(error));
+ CHECK(gpu != NULL);
+ CHECK(!h3_gpu_is_m5(gpu));
+ CHECK(!h3_gpu_has_nax_mlp(gpu));
+
+ const float input[] = {1.0f, -2.5f, 0.125f, 65504.0f};
+ h3_gpu_tensor *f32 = h3_gpu_tensor_from_f32(gpu, input, 4);
+ h3_gpu_tensor *copy = h3_gpu_tensor_new_f32(gpu, 6);
+ h3_gpu_tensor *bf16 = h3_gpu_tensor_new_bf16(gpu, 4);
+ h3_gpu_tensor *bf16_copy = h3_gpu_tensor_new_bf16(gpu, 6);
+ CHECK(f32 && copy && bf16 && bf16_copy);
+ CHECK(h3_gpu_tensor_elements(f32) == 4);
+ CHECK(h3_gpu_tensor_dtype(f32) == H3_GPU_F32);
+ CHECK(h3_gpu_tensor_write_f32(bf16, input, 4));
+
+ float roundtrip[4] = {0};
+ CHECK(h3_gpu_tensor_read_f32(bf16, roundtrip, 4));
+ for (size_t index = 0; index < 4; index++)
+ CHECK(fabsf(roundtrip[index] - input[index]) <=
+ fmaxf(0.01f, fabsf(input[index]) * 0.008f));
+
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_copy_f32(gpu, copy, 1, f32, 0, 4));
+ CHECK(h3_gpu_continue(gpu));
+ CHECK(h3_gpu_submit(gpu));
+ float copied[4] = {0};
+ CHECK(h3_gpu_tensor_read_f32_range(copy, 1, copied, 4));
+ CHECK(memcmp(input, copied, sizeof(input)) == 0);
+ CHECK(!h3_gpu_tensor_read_f32_range(copy, 5, copied, 4));
+ CHECK(strstr(h3_gpu_error(gpu), "range") != NULL);
+
+ const uint16_t range_values[] = {0x3f80, 0xc020, 0x3e00, 0x4780};
+ CHECK(h3_gpu_tensor_write_bf16_range(bf16_copy, 1, range_values, 4));
+ CHECK(h3_gpu_begin(gpu));
+ CHECK(h3_gpu_copy_bf16(gpu, bf16, 0, bf16_copy, 1, 4));
+ CHECK(h3_gpu_submit(gpu));
+ uint16_t range_roundtrip[4] = {0};
+ CHECK(h3_gpu_tensor_read_bf16(bf16, range_roundtrip, 4));
+ CHECK(memcmp(range_values, range_roundtrip, sizeof(range_values)) == 0);
+
+ char path[] = "/tmp/h3-cuda-runtime-XXXXXX";
+ int fd = mkstemp(path);
+ CHECK(fd >= 0);
+ const uint16_t file_values[] = {0x3f80, 0xc020, 0x3e00, 0x4780};
+ CHECK(write(fd, file_values, sizeof(file_values)) ==
+ (ssize_t)sizeof(file_values));
+ const float file_f32[] = {3.5f, -7.0f};
+ CHECK(write(fd, file_f32, sizeof(file_f32)) == (ssize_t)sizeof(file_f32));
+ CHECK(close(fd) == 0);
+ h3_gpu_tensor *loaded = h3_gpu_tensor_load_bf16(gpu, path, 0, 4);
+ CHECK(loaded != NULL);
+ uint16_t loaded_values[4] = {0};
+ CHECK(h3_gpu_tensor_read_bf16(loaded, loaded_values, 4));
+ CHECK(memcmp(file_values, loaded_values, sizeof(file_values)) == 0);
+ h3_gpu_tensor *loaded_f32 = h3_gpu_tensor_load_f32(
+ gpu, path, sizeof(file_values), 2);
+ CHECK(loaded_f32 != NULL);
+ float loaded_f32_values[2] = {0};
+ CHECK(h3_gpu_tensor_read_f32(loaded_f32, loaded_f32_values, 2));
+ CHECK(memcmp(file_f32, loaded_f32_values, sizeof(file_f32)) == 0);
+ CHECK(h3_gpu_tensor_stream_file_bf16(bf16, path, 0, 4,
+ error, sizeof(error)));
+ CHECK(!h3_gpu_tensor_stream_file_bf16(
+ bf16, path, (uint64_t)INT64_MAX, 4, error, sizeof(error)));
+ CHECK(strstr(error, "overflows") != NULL);
+ CHECK(unlink(path) == 0);
+
+ h3_gpu_stats stats;
+ CHECK(h3_gpu_get_stats(gpu, &stats));
+ CHECK(stats.tensor_allocations == 6);
+ CHECK(stats.live_bytes > 0 && stats.peak_live_bytes >= stats.live_bytes);
+ CHECK(stats.blit_copies == 2 && stats.submissions == 3);
+ CHECK(stats.gpu_seconds >= 0.0 && stats.command_encode_seconds >= 0.0);
+
+ h3_gpu_tensor_free(loaded_f32);
+ h3_gpu_tensor_free(loaded);
+ h3_gpu_tensor_free(bf16_copy);
+ h3_gpu_tensor_free(bf16);
+ h3_gpu_tensor_free(copy);
+ h3_gpu_tensor_free(f32);
+ CHECK(h3_gpu_get_stats(gpu, &stats));
+ CHECK(stats.live_bytes == 0);
+ CHECK(setenv("H3_PROFILE", "1", 1) == 0);
+ h3_gpu_profile_set_label(gpu, "runtime test");
+ h3_gpu_profile_mark(gpu, "complete");
+ h3_gpu_free(gpu);
+ puts("ok: CUDA runtime allocation, conversion, copy and file I/O");
+ return 0;
+}
diff --git a/tests/test_device.c b/tests/test_device.c
new file mode 100644
index 00000000..f80dcc73
--- /dev/null
+++ b/tests/test_device.c
@@ -0,0 +1,23 @@
+#include "h3_device.h"
+
+#include
+#include
+
+int main(void) {
+ h3_device_info info;
+ char error[256] = {0};
+ if (!h3_device_probe(&info, error, sizeof(error))) {
+ fprintf(stderr, "FAIL device probe: %s\n", error);
+ return 1;
+ }
+ if (!info.name[0] || !info.architecture[0] ||
+ !info.recommended_working_set || !info.max_buffer_length) {
+ fprintf(stderr, "FAIL device probe: incomplete device information\n");
+ return 1;
+ }
+ printf("ok: %s (%s), %.1f GiB GPU memory, unified=%s\n", info.name,
+ info.architecture,
+ (double)info.recommended_working_set / (1024.0 * 1024.0 * 1024.0),
+ info.unified_memory ? "yes" : "no");
+ return 0;
+}
diff --git a/tests/test_h3.c b/tests/test_h3.c
index 3f66fa32..52a5308f 100644
--- a/tests/test_h3.c
+++ b/tests/test_h3.c
@@ -1,6 +1,6 @@
#include "h3_host.h"
#include "h3_dit.h"
-#include "h3_metal.h"
+#include "h3_device.h"
#include "h3_safetensors.h"
#include "h3_terminal.h"
@@ -373,14 +373,14 @@ static void test_dit_row_conversions(void) {
CHECK(memcmp(audio, unpacked, sizeof(audio)) == 0);
}
-static void test_metal_probe(void) {
+static void test_device_probe(void) {
h3_device_info info;
char error[256];
- CHECK(h3_metal_probe(&info, error, sizeof(error)));
+ CHECK(h3_device_probe(&info, error, sizeof(error)));
CHECK(info.name[0] != '\0');
CHECK(info.physical_memory >= UINT64_C(8) * 1024 * 1024 * 1024);
CHECK(info.max_buffer_length > 0);
- CHECK(info.apple_gpu_family > 0);
+ CHECK(info.recommended_working_set > 0);
}
static void test_terminal_zoom(void) {
@@ -407,7 +407,7 @@ int main(void) {
test_rng_and_solver();
test_rgb_resize();
test_dit_row_conversions();
- test_metal_probe();
+ test_device_probe();
test_terminal_zoom();
printf("ok: %d checks\n", tests_run);
return 0;
diff --git a/tests/test_host_portable.c b/tests/test_host_portable.c
new file mode 100644
index 00000000..a9d4fd63
--- /dev/null
+++ b/tests/test_host_portable.c
@@ -0,0 +1,24 @@
+#include "h3_host.h"
+
+#include
+#include
+#include
+#include
+
+#define CHECK(x) do { if (!(x)) { \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #x); return 1; \
+} } while (0)
+
+int main(void) {
+ const uint8_t constant[] = {
+ 17,33,201, 17,33,201,
+ 17,33,201, 17,33,201
+ };
+ uint8_t *output = NULL;
+ CHECK(h3_resize_rgb24_high_quality(constant, 1, 2, 2, 8, 8, &output));
+ for (size_t pixel = 0; pixel < 64; pixel++)
+ CHECK(!memcmp(output + pixel * 3, constant, 3));
+ free(output);
+ puts("ok: portable RGB resize");
+ return 0;
+}
diff --git a/tests/test_tokenizer_portable.c b/tests/test_tokenizer_portable.c
new file mode 100644
index 00000000..8457dfb0
--- /dev/null
+++ b/tests/test_tokenizer_portable.c
@@ -0,0 +1,47 @@
+#include "h3_tokenizer.h"
+
+#include
+#include
+#include
+#include
+
+#define CHECK(x) do { if (!(x)) { \
+ fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #x); return 1; \
+} } while (0)
+
+static int check(h3_tokenizer *tokenizer, const char *text,
+ const uint32_t *expected, size_t expected_count,
+ const char *decoded_expected) {
+ char error[256];
+ uint32_t *ids = NULL;
+ size_t count = 0;
+ CHECK(h3_tokenizer_encode(tokenizer, text, 0, &ids, &count,
+ error, sizeof(error)));
+ CHECK(count == expected_count);
+ CHECK(!memcmp(ids, expected, count * sizeof(*ids)));
+ char *decoded = h3_tokenizer_decode(tokenizer, ids, count,
+ error, sizeof(error));
+ CHECK(decoded && !strcmp(decoded, decoded_expected));
+ free(decoded); h3_tokenizer_ids_free(ids); return 0;
+}
+
+int main(int argc, char **argv) {
+ const char *path = argc > 1 ? argv[1] :
+ "tests/tokenizer_portable_fixture.json";
+ char error[256];
+ h3_tokenizer *tokenizer = h3_tokenizer_load(path, error, sizeof(error));
+ CHECK(tokenizer != NULL);
+ const uint32_t spaced[] = {1, 3};
+ CHECK(!check(tokenizer, "A A", spaced, 2, "A A"));
+ const uint32_t normalized[] = {4};
+ CHECK(!check(tokenizer, "e\xcc\x81", normalized, 1, "\xc3\xa9"));
+ const uint32_t added[] = {10, 1};
+ CHECK(!check(tokenizer, "A", added, 2, "A"));
+ uint32_t *ids = NULL; size_t count = 99;
+ CHECK(h3_tokenizer_encode(tokenizer, "", 1, &ids, &count,
+ error, sizeof(error)));
+ CHECK(count == 1 && ids[0] == H3_PAD_TOKEN_ID);
+ h3_tokenizer_ids_free(ids); h3_tokenizer_free(tokenizer);
+ puts("ok: portable ICU byte-level BPE tokenizer");
+ return 0;
+}
diff --git a/tests/tokenizer_portable_fixture.json b/tests/tokenizer_portable_fixture.json
new file mode 100644
index 00000000..7eb5c7ea
--- /dev/null
+++ b/tests/tokenizer_portable_fixture.json
@@ -0,0 +1,13 @@
+{
+ "model": {
+ "type": "BPE",
+ "unk_token": null,
+ "vocab": {"A": 1, "Ġ": 2, "ĠA": 3, "é": 4, "Ã": 5, "©": 6},
+ "merges": ["Ġ A", ["Ã", "©"]]
+ },
+ "normalizer": {"type": "NFC"},
+ "added_tokens": [
+ {"id": 10, "content": "", "single_word": false,
+ "lstrip": false, "rstrip": false, "normalized": false}
+ ]
+}
diff --git a/webui/backend/app/__init__.py b/webui/backend/app/__init__.py
new file mode 100644
index 00000000..ec4695fe
--- /dev/null
+++ b/webui/backend/app/__init__.py
@@ -0,0 +1 @@
+"""h3.c web UI backend."""
diff --git a/webui/backend/app/argv.py b/webui/backend/app/argv.py
new file mode 100644
index 00000000..75d97359
--- /dev/null
+++ b/webui/backend/app/argv.py
@@ -0,0 +1,78 @@
+"""Build the exact argv for one `./h3` run.
+
+An argv list, never a shell string: prompts and file names come from the
+browser and must never be parsed by a shell. Durations are resolved to an
+explicit frame count here, so the number shown in the UI is the number h3 runs.
+"""
+
+from pathlib import Path
+
+from .jobspec import JobSpec, Reference
+
+_REFERENCE_FLAG = {
+ "image": "--ref-image",
+ "video": "--ref-video",
+ "silent_video": "--ref-silent-video",
+ "video_audio": "--ref-video-audio",
+ "audio": "--ref-audio",
+}
+
+
+def build_argv(
+ spec: JobSpec,
+ binary: Path,
+ model_dir: Path,
+ output: Path | None,
+ frames_dir: Path | None = None,
+ preview_dir: Path | None = None,
+) -> list[str]:
+ argv = [str(binary), "-d", str(model_dir), "-p", spec.prompt]
+ argv += ["-o", str(output) if output else ""]
+ argv += ["--width", str(spec.width), "--height", str(spec.height)]
+ if spec.render_width and spec.render_height:
+ argv += [
+ "--render-width",
+ str(spec.render_width),
+ "--render-height",
+ str(spec.render_height),
+ ]
+ argv += ["--frames", str(spec.resolved_frames())]
+ argv += ["--steps", str(spec.steps)]
+ argv += ["--layers", str(spec.dit_layers)]
+ if spec.core_reuse > 1:
+ argv += ["--core-reuse", str(spec.core_reuse)]
+ else:
+ argv += ["--reuse", str(spec.denoise_reuse)]
+ if spec.token_reduction:
+ argv.append("--token-reduction")
+ if spec.ssd_streaming:
+ argv.append("--ssd-streaming")
+ if spec.use_int8_row_fc2:
+ argv.append("--use-int8-row-fc2")
+ if spec.use_reference_rope:
+ argv.append("--use-reference-rope")
+ for flag in spec.slower:
+ argv.append(f"--{flag}")
+ argv += ["--seed", str(spec.seed)]
+ if spec.first_frame:
+ argv += ["--first-frame", spec.first_frame]
+ if spec.last_frame:
+ argv += ["--last-frame", spec.last_frame]
+ if any(reference.kind == "image" for reference in spec.references):
+ argv += ["--ref-image-size", spec.reference_image_size]
+ for reference in spec.references:
+ argv += _reference_argv(reference)
+ if frames_dir is not None:
+ argv += ["--frames-dir", str(frames_dir)]
+ if preview_dir is not None:
+ argv += ["--preview-dir", str(preview_dir)]
+ if spec.profile:
+ argv.append("--profile")
+ return argv
+
+
+def _reference_argv(reference: Reference) -> list[str]:
+ flag = _REFERENCE_FLAG[reference.kind]
+ if reference.kind == "video_audio":
+ return [flag, reference.path, reference.audio_path or ""]
+ return [flag, reference.path]
diff --git a/webui/backend/app/assets.py b/webui/backend/app/assets.py
new file mode 100644
index 00000000..ac5000ba
--- /dev/null
+++ b/webui/backend/app/assets.py
@@ -0,0 +1,207 @@
+"""Uploaded images, clips and soundtracks.
+
+Files are stored by content hash, so re-uploading the same photo reuses the
+existing entry and the library stays free of duplicates. Extensions are
+whitelisted and every file is probed with ffprobe: what the browser calls a
+PNG is only accepted if ffprobe agrees.
+"""
+
+import hashlib
+import json
+import shutil
+import sqlite3
+import subprocess
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+from .db import Database
+
+IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
+VIDEO_SUFFIXES = {".mp4", ".mov", ".mkv", ".webm", ".avi"}
+AUDIO_SUFFIXES = {".wav", ".mp3", ".flac", ".m4a", ".aac", ".ogg"}
+ALLOWED_SUFFIXES = IMAGE_SUFFIXES | VIDEO_SUFFIXES | AUDIO_SUFFIXES
+
+# h3 accepts reference audio between 2 and 15 seconds.
+MIN_AUDIO_SECONDS = 2.0
+MAX_AUDIO_SECONDS = 15.0
+
+
+class AssetError(ValueError):
+ """The upload cannot be stored, with a reason meant for the user."""
+
+
+@dataclass
+class Probe:
+ kind: str
+ seconds: float | None
+ width: int | None
+ height: int | None
+ has_audio: bool
+
+
+def kind_from_suffix(suffix: str) -> str:
+ lowered = suffix.lower()
+ if lowered in IMAGE_SUFFIXES:
+ return "image"
+ if lowered in VIDEO_SUFFIXES:
+ return "video"
+ if lowered in AUDIO_SUFFIXES:
+ return "audio"
+ raise AssetError(f"unsupported file type: {suffix or 'no extension'}")
+
+
+def probe(path: Path, ffprobe: str = "ffprobe") -> Probe:
+ """Ask ffprobe what this file really is."""
+ try:
+ done = subprocess.run( # noqa: S603 - fixed argv, no shell
+ [
+ ffprobe,
+ "-v",
+ "error",
+ "-show_entries",
+ "stream=codec_type,width,height:format=duration",
+ "-of",
+ "json",
+ str(path),
+ ],
+ capture_output=True,
+ text=True,
+ timeout=60,
+ check=False,
+ )
+ except (OSError, subprocess.TimeoutExpired) as error:
+ raise AssetError(f"cannot probe the upload: {error}") from error
+ if done.returncode != 0:
+ raise AssetError("the file is not a readable image, video or audio track")
+ report = json.loads(done.stdout or "{}")
+ streams = report.get("streams", [])
+ if not streams:
+ raise AssetError("the file has no decodable stream")
+ video = next((s for s in streams if s.get("codec_type") == "video"), None)
+ has_audio = any(s.get("codec_type") == "audio" for s in streams)
+ duration = report.get("format", {}).get("duration")
+ seconds = float(duration) if duration not in (None, "N/A") else None
+ if video is None:
+ kind = "audio"
+ elif seconds is None or seconds == 0 or _is_still(seconds, video):
+ kind = "image"
+ else:
+ kind = "video"
+ return Probe(
+ kind=kind,
+ seconds=seconds,
+ width=video.get("width") if video else None,
+ height=video.get("height") if video else None,
+ has_audio=has_audio,
+ )
+
+
+def _is_still(seconds: float, video: dict[str, Any]) -> bool:
+ # ffprobe reports a tiny synthetic duration for single-frame images.
+ return seconds < 0.1 and not video.get("nb_frames", "").isdigit()
+
+
+def store(
+ database: Database,
+ source: Path,
+ filename: str,
+ root: Path,
+ max_bytes: int,
+ ffprobe: str = "ffprobe",
+ owner: int | None = None,
+) -> dict[str, Any]:
+ """Validate, deduplicate and record one upload.
+
+ Deduplication is per owner (R30): the same bytes may be held by two
+ people, each seeing it in their own library, while the file on disk —
+ named by its hash — stays shared.
+ """
+ declared = kind_from_suffix(Path(filename).suffix)
+ size = source.stat().st_size
+ if size == 0:
+ raise AssetError("the upload is empty")
+ if size > max_bytes:
+ raise AssetError(
+ f"the upload is {size / 1e6:.1f} MB, over the "
+ f"{max_bytes / 1e6:.0f} MB limit"
+ )
+ detected = probe(source, ffprobe)
+ if detected.kind != declared:
+ raise AssetError(
+ f"the extension says {declared} but the file is {detected.kind}"
+ )
+
+ digest = _sha256(source)
+ existing = database.query_one(
+ "SELECT * FROM assets WHERE sha256 = ? AND owner IS ?", (digest, owner)
+ )
+ if existing:
+ return _row_to_dict(existing) | {"duplicate": True}
+
+ target = root / digest[:2] / f"{digest}{Path(filename).suffix.lower()}"
+ target.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copyfile(source, target)
+ metadata = {
+ "seconds": detected.seconds,
+ "width": detected.width,
+ "height": detected.height,
+ "has_audio": detected.has_audio,
+ "notes": _notes(detected),
+ }
+ asset_id = database.run(
+ "INSERT INTO assets (sha256, kind, filename, path, bytes, metadata, owner) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
+ (
+ digest,
+ detected.kind,
+ filename,
+ str(target),
+ size,
+ json.dumps(metadata),
+ owner,
+ ),
+ )
+ row = database.query_one("SELECT * FROM assets WHERE id = ?", (asset_id,))
+ return _row_to_dict(row) | {"duplicate": False}
+
+
+def listing(database: Database, owner: int | None = None) -> list[dict[str, Any]]:
+ if owner is None:
+ rows = database.query_all("SELECT * FROM assets ORDER BY id DESC")
+ else:
+ rows = database.query_all(
+ "SELECT * FROM assets WHERE owner = ? ORDER BY id DESC", (owner,)
+ )
+ return [_row_to_dict(row) for row in rows]
+
+
+def _notes(detected: Probe) -> list[str]:
+ """Usage limits worth showing next to the file, not reasons to reject it."""
+ notes: list[str] = []
+ if detected.seconds is None:
+ return notes
+ if detected.kind == "audio" and detected.seconds < MIN_AUDIO_SECONDS:
+ notes.append("shorter than the 2 s minimum for a reference audio track")
+ elif detected.kind == "audio" and detected.seconds > MAX_AUDIO_SECONDS:
+ notes.append(
+ "longer than the 15 s total budget for reference audio; "
+ "h3 will use the first 15 s"
+ )
+ elif detected.kind == "video" and detected.seconds < MIN_AUDIO_SECONDS:
+ notes.append("shorter than 2 s: usable only as a silent video reference")
+ return notes
+
+
+def _sha256(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as handle:
+ for chunk in iter(lambda: handle.read(1 << 20), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def _row_to_dict(row: sqlite3.Row) -> dict[str, Any]:
+ item = dict(row)
+ item["metadata"] = json.loads(item["metadata"] or "{}")
+ return item
diff --git a/webui/backend/app/auth.py b/webui/backend/app/auth.py
new file mode 100644
index 00000000..e79ff812
--- /dev/null
+++ b/webui/backend/app/auth.py
@@ -0,0 +1,191 @@
+"""Accounts, sessions, invites and login rate limiting for h3.c Studio (R30).
+
+Passwords are hashed with argon2id (memory-hard, OWASP's current pick); the
+hash string carries its parameters, so a future re-tune keeps reading old
+hashes. Sessions live in a table, not in a JWT: the only client is the UI's
+browser, and a table means a logout or a deleted account invalidates its
+sessions immediately.
+
+The administrator does not come from the door: it is declared by the
+deployment (R33) — `H3_ADMIN_USERNAME` and `H3_ADMIN_PASSWORD` — and is
+created once, on the first start of an empty database. After that the
+environment is ignored and passwords are managed from the People tab.
+"""
+
+import logging
+import re
+import secrets
+import time
+
+from argon2 import PasswordHasher
+from argon2.exceptions import InvalidHashError, VerificationError
+
+from .db import Database
+
+log = logging.getLogger(__name__)
+
+_hasher = PasswordHasher()
+
+USERNAME_RE = re.compile(r"^[A-Za-z0-9_.-]{1,32}$")
+MIN_PASSWORD_LENGTH = 8
+MAX_PASSWORD_LENGTH = 128
+
+SESSION_COOKIE = "h3_session"
+SESSION_TTL_SECONDS = 7 * 24 * 3600
+
+RATE_LIMIT_WINDOW_SECONDS = 15 * 60
+RATE_LIMIT_MAX_FAILURES = 5
+
+
+def hash_password(password: str) -> str:
+ return _hasher.hash(password)
+
+
+def verify_password(password_hash: str, password: str) -> bool:
+ """A malformed or mismatched hash is simply not a match."""
+ try:
+ return _hasher.verify(password_hash, password)
+ except (InvalidHashError, VerificationError):
+ return False
+
+
+def validate_credentials(username: str, password: str) -> list[str]:
+ errors = []
+ if not USERNAME_RE.match(username):
+ errors.append(
+ "a username is at most 32 of these characters: letters, "
+ "digits, dots, dashes and underscores"
+ )
+ if len(password) < MIN_PASSWORD_LENGTH or len(password) > MAX_PASSWORD_LENGTH:
+ errors.append(
+ f"a password is between {MIN_PASSWORD_LENGTH} and "
+ f"{MAX_PASSWORD_LENGTH} characters"
+ )
+ return errors
+
+
+# ── users ──────────────────────────────────────────────────────────────────
+
+def user_count(db: Database) -> int:
+ row = db.query_one("SELECT COUNT(*) AS n FROM users")
+ return int(row["n"]) if row else 0
+
+
+def get_user_by_username(db: Database, username: str):
+ return db.query_one("SELECT * FROM users WHERE username = ?", (username,))
+
+
+def create_user(
+ db: Database, username: str, password: str, role: str = "user"
+) -> int:
+ return db.run(
+ "INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)",
+ (username, hash_password(password), role),
+ )
+
+
+def backfill_ownerless_rows(db: Database, user_id: int) -> None:
+ """Whatever existed before accounts did belongs to the first admin (D30.4)."""
+ db.run("UPDATE jobs SET owner = ? WHERE owner IS NULL", (user_id,))
+ db.run("UPDATE assets SET owner = ? WHERE owner IS NULL", (user_id,))
+
+
+def bootstrap_admin(db: Database, username: str, password: str) -> int | None:
+ """Create the administrator from the deployment configuration (R33).
+
+ Runs on an empty users table only: afterwards the environment has no say,
+ and the account is managed like any other (People tab). Returns the id of
+ the account it made, or None.
+ """
+ if user_count(db) > 0:
+ return None
+ if not password:
+ log.warning(
+ "no accounts exist and H3_ADMIN_PASSWORD is not set: "
+ "nobody can sign in until one is configured"
+ )
+ return None
+ user_id = create_user(db, username, password, "admin")
+ backfill_ownerless_rows(db, user_id)
+ return user_id
+
+
+# ── sessions ───────────────────────────────────────────────────────────────
+
+def create_session(db: Database, user_id: int) -> str:
+ token = secrets.token_urlsafe(32)
+ db.run(
+ "INSERT INTO sessions (token, user_id, expires_at)"
+ " VALUES (?, ?, datetime('now', '+' || ? || ' seconds'))",
+ (token, user_id, SESSION_TTL_SECONDS),
+ )
+ # Housekeeping while we are here: expired sessions do not linger.
+ db.run("DELETE FROM sessions WHERE expires_at <= datetime('now')")
+ return token
+
+
+def session_user(db: Database, token: str | None):
+ """The user a session token belongs to, or None if it is not valid."""
+ if not token:
+ return None
+ return db.query_one(
+ "SELECT u.* FROM sessions s JOIN users u ON u.id = s.user_id"
+ " WHERE s.token = ? AND s.expires_at > datetime('now')",
+ (token,),
+ )
+
+
+def delete_session(db: Database, token: str) -> None:
+ db.run("DELETE FROM sessions WHERE token = ?", (token,))
+
+
+def delete_sessions_for_user(db: Database, user_id: int) -> None:
+ db.run("DELETE FROM sessions WHERE user_id = ?", (user_id,))
+
+
+# ── invites ────────────────────────────────────────────────────────────────
+
+def create_invite(db: Database, admin_id: int) -> str:
+ code = secrets.token_urlsafe(9)
+ db.run(
+ "INSERT INTO invites (code, created_by) VALUES (?, ?)",
+ (code, admin_id),
+ )
+ return code
+
+
+def consume_invite(db: Database, code: str, user_id: int) -> bool:
+ """Marks an unused invite as used; False if it does not exist or is gone."""
+ row = db.query_one(
+ "SELECT code FROM invites WHERE code = ? AND used_at IS NULL", (code,)
+ )
+ if row is None:
+ return False
+ db.run(
+ "UPDATE invites SET used_by = ?, used_at = datetime('now')"
+ " WHERE code = ? AND used_at IS NULL",
+ (user_id, code),
+ )
+ return True
+
+
+# ── login rate limiting ────────────────────────────────────────────────────
+
+def record_failed_login(db: Database, username: str) -> None:
+ db.run(
+ "INSERT INTO login_attempts (username, at) VALUES (?, ?)",
+ (username, int(time.time())),
+ )
+
+
+def clear_failed_logins(db: Database, username: str) -> None:
+ db.run("DELETE FROM login_attempts WHERE username = ?", (username,))
+
+
+def login_blocked(db: Database, username: str) -> bool:
+ row = db.query_one(
+ "SELECT COUNT(*) AS n FROM login_attempts"
+ " WHERE username = ? AND at >= ?",
+ (username, int(time.time()) - RATE_LIMIT_WINDOW_SECONDS),
+ )
+ return bool(row and row["n"] >= RATE_LIMIT_MAX_FAILURES)
diff --git a/webui/backend/app/capabilities.py b/webui/backend/app/capabilities.py
new file mode 100644
index 00000000..b62a46cd
--- /dev/null
+++ b/webui/backend/app/capabilities.py
@@ -0,0 +1,11 @@
+"""Serve the canonical option inventory to the frontend."""
+
+import json
+from functools import lru_cache
+from pathlib import Path
+from typing import Any
+
+
+@lru_cache
+def load_schema(path: Path) -> dict[str, Any]:
+ return json.loads(path.read_text())
diff --git a/webui/backend/app/config.py b/webui/backend/app/config.py
new file mode 100644
index 00000000..ca961703
--- /dev/null
+++ b/webui/backend/app/config.py
@@ -0,0 +1,45 @@
+"""Runtime configuration, read once from the environment."""
+
+from functools import lru_cache
+from pathlib import Path
+
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+REPO_ROOT = Path(__file__).resolve().parents[3]
+
+
+class Settings(BaseSettings):
+ """Every value can be overridden with an H3_ prefixed environment variable."""
+
+ model_config = SettingsConfigDict(env_prefix="H3_", extra="ignore")
+
+ # Path to the h3 binary and to the MiniMax-H3 checkpoint directory.
+ binary: Path = REPO_ROOT / "h3"
+ model_dir: Path = REPO_ROOT / "MiniMax-H3"
+ # Where jobs, uploads and generated media are written.
+ data_dir: Path = REPO_ROOT / "webui/backend/data"
+ # Canonical option inventory shared with the frontend.
+ schema_path: Path = REPO_ROOT / "webui/shared/options.schema.json"
+ # Measured phase durations behind the weighted progress bar.
+ progress_weights_path: Path = REPO_ROOT / "webui/shared/progress_weights.json"
+ # Seconds allowed for `h3 --info`, which only reads checkpoint headers.
+ info_timeout: float = 120.0
+ # Largest accepted upload, in bytes.
+ max_upload_bytes: int = 512 * 1024 * 1024
+ ffprobe: str = "ffprobe"
+ ffmpeg: str = "ffmpeg"
+ # Seconds between SIGTERM and SIGKILL when a job is cancelled.
+ kill_grace: float = 10.0
+ # Post-processing plugins: an executable path enables the plugin.
+ # Nothing is installed or downloaded by this repository.
+ faceswap_cmd: str = ""
+ # The administrator account, defined by the deployment (R33). An empty
+ # password means "not configured": the app starts with no administrator
+ # and says so in the log.
+ admin_username: str = "admin"
+ admin_password: str = ""
+
+
+@lru_cache
+def settings() -> Settings:
+ return Settings()
diff --git a/webui/backend/app/db.py b/webui/backend/app/db.py
new file mode 100644
index 00000000..608cdf36
--- /dev/null
+++ b/webui/backend/app/db.py
@@ -0,0 +1,207 @@
+"""SQLite storage for jobs, assets, users and sessions.
+
+The worker thread and the request handlers share one connection, so every
+statement goes through a lock: sqlite3 allows cross-thread use but not
+concurrent use. One writer, one job at a time — no server is needed.
+
+The schema is versioned. Version 1 is the original storage (jobs, assets);
+later versions are additive migrations applied in order at open time, so an
+existing database is never recreated and never loses rows (R30, T120).
+"""
+
+import sqlite3
+import threading
+from pathlib import Path
+from typing import Any
+
+MIGRATIONS: list[str] = [
+ # Version 1 — the original storage.
+ """
+ CREATE TABLE IF NOT EXISTS jobs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ state TEXT NOT NULL DEFAULT 'queued',
+ prompt TEXT NOT NULL DEFAULT '',
+ params TEXT NOT NULL,
+ argv TEXT,
+ phase TEXT,
+ completed INTEGER NOT NULL DEFAULT 0,
+ total INTEGER NOT NULL DEFAULT 0,
+ progress REAL NOT NULL DEFAULT 0.0,
+ error TEXT,
+ output_path TEXT,
+ log_path TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ started_at TEXT,
+ finished_at TEXT
+ );
+
+ CREATE TABLE IF NOT EXISTS assets (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ sha256 TEXT NOT NULL UNIQUE,
+ kind TEXT NOT NULL,
+ filename TEXT NOT NULL,
+ path TEXT NOT NULL,
+ bytes INTEGER NOT NULL,
+ metadata TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+ """,
+ # Version 2 — users, sessions, invites, and an owner on what exists (T120).
+ # Rows created before R30 stay ownerless until the first admin exists.
+ """
+ CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT NOT NULL UNIQUE,
+ password_hash TEXT NOT NULL,
+ role TEXT NOT NULL DEFAULT 'user'
+ CHECK (role IN ('admin', 'user')),
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+
+ CREATE TABLE IF NOT EXISTS sessions (
+ token TEXT PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ expires_at TEXT NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions (user_id);
+
+ CREATE TABLE IF NOT EXISTS invites (
+ code TEXT PRIMARY KEY,
+ created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ used_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ used_at TEXT
+ );
+
+ ALTER TABLE jobs ADD COLUMN owner INTEGER REFERENCES users(id);
+ ALTER TABLE assets ADD COLUMN owner INTEGER REFERENCES users(id);
+ """,
+ # Version 3 — a login-attempt counter, so rate limiting needs no external
+ # service (R30, T121). `at` is unix seconds.
+ """
+ CREATE TABLE IF NOT EXISTS login_attempts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT NOT NULL,
+ at INTEGER NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_attempts_username
+ ON login_attempts (username, at);
+ """,
+ # Version 4 — content dedup becomes per-owner (R30, T122): two people may
+ # hold the same file, and neither must see the other's library. The table
+ # is rebuilt because SQLite cannot drop the old UNIQUE column constraint;
+ # rows are copied across untouched.
+ """
+ CREATE TABLE assets_r30 (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ sha256 TEXT NOT NULL,
+ kind TEXT NOT NULL,
+ filename TEXT NOT NULL,
+ path TEXT NOT NULL,
+ bytes INTEGER NOT NULL,
+ metadata TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ owner INTEGER REFERENCES users(id)
+ );
+ INSERT INTO assets_r30 SELECT * FROM assets;
+ DROP TABLE assets;
+ ALTER TABLE assets_r30 RENAME TO assets;
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_assets_sha_owner
+ ON assets (sha256, owner);
+ """,
+ # Version 5 — the restart sweep must know whether a job's h3 survived the
+ # crash: jobs record the pid of the process running them (R27, T105).
+ """
+ ALTER TABLE jobs ADD COLUMN pid INTEGER;
+ """,
+]
+
+LATEST_VERSION = len(MIGRATIONS)
+
+
+class Closed(RuntimeError):
+ """The database was closed while a background thread was still writing."""
+
+
+class Database:
+ """Every access is serialized and returns plain rows, never live cursors."""
+
+ def __init__(self, path: Path) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ self._lock = threading.RLock()
+ self._open = True
+ self._connection = sqlite3.connect(path, check_same_thread=False)
+ self._connection.row_factory = sqlite3.Row
+ self._connection.execute("PRAGMA journal_mode=WAL")
+ self._connection.execute("PRAGMA foreign_keys=ON")
+ with self._lock:
+ self._migrate()
+
+ @property
+ def version(self) -> int:
+ return self.schema_version()
+
+ def schema_version(self) -> int:
+ with self._guard() as connection:
+ row = connection.execute("PRAGMA user_version").fetchone()
+ return int(row[0])
+
+ def _migrate(self) -> None:
+ """Apply every migration after the recorded version, in one commit.
+
+ A database without the user_version stamp but with tables already
+ present is a version-1 database: stamp it, do not replay it.
+ """
+ connection = self._connection
+ version = int(connection.execute("PRAGMA user_version").fetchone()[0])
+ if version == 0:
+ has_jobs = connection.execute(
+ "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'jobs'"
+ ).fetchone()
+ if has_jobs:
+ version = 1
+ if version >= LATEST_VERSION:
+ connection.execute(f"PRAGMA user_version = {LATEST_VERSION}")
+ return
+ for migration in MIGRATIONS[version:]:
+ connection.executescript(migration)
+ connection.execute(f"PRAGMA user_version = {LATEST_VERSION}")
+ connection.commit()
+
+ def query_one(self, sql: str, params: tuple[Any, ...] = ()) -> sqlite3.Row | None:
+ with self._guard() as connection:
+ return connection.execute(sql, params).fetchone()
+
+ def query_all(self, sql: str, params: tuple[Any, ...] = ()) -> list[sqlite3.Row]:
+ with self._guard() as connection:
+ return connection.execute(sql, params).fetchall()
+
+ def run(self, sql: str, params: tuple[Any, ...] = ()) -> int:
+ with self._guard() as connection:
+ cursor = connection.execute(sql, params)
+ connection.commit()
+ return int(cursor.lastrowid or 0)
+
+ def close(self) -> None:
+ with self._lock:
+ if self._open:
+ self._open = False
+ self._connection.close()
+
+ def _guard(self):
+ database = self
+
+ class _Guard:
+ def __enter__(self) -> sqlite3.Connection:
+ database._lock.acquire()
+ if not database._open:
+ database._lock.release()
+ raise Closed("the database is closed")
+ return database._connection
+
+ def __exit__(self, *_: object) -> bool:
+ database._lock.release()
+ return False
+
+ return _Guard()
diff --git a/webui/backend/app/events.py b/webui/backend/app/events.py
new file mode 100644
index 00000000..3d11e1ba
--- /dev/null
+++ b/webui/backend/app/events.py
@@ -0,0 +1,51 @@
+"""Server-sent events for one job.
+
+The runner emits from its worker thread; each subscriber owns an asyncio queue
+fed through the event loop. The stream ends when the job reaches a terminal
+state, so the browser does not need to poll or to guess when to stop.
+"""
+
+import asyncio
+import json
+from collections.abc import AsyncIterator
+from typing import Any
+
+from .runner import TERMINAL_STATES, JobRunner
+
+HEARTBEAT_SECONDS = 15.0
+
+
+async def job_events(runner: JobRunner, job_id: int) -> AsyncIterator[str]:
+ loop = asyncio.get_running_loop()
+ queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
+
+ def listener(job: dict[str, Any]) -> None:
+ if job["id"] == job_id:
+ loop.call_soon_threadsafe(queue.put_nowait, job)
+
+ snapshot = runner.get(job_id)
+ if snapshot is None:
+ yield _event("error", {"detail": "unknown job"})
+ return
+
+ runner.add_listener(listener)
+ try:
+ yield _event("job", snapshot)
+ if snapshot["state"] in TERMINAL_STATES:
+ return
+ while True:
+ try:
+ job = await asyncio.wait_for(queue.get(), timeout=HEARTBEAT_SECONDS)
+ except TimeoutError:
+ # Keeps proxies from closing an idle stream during a long phase.
+ yield ": keep-alive\n\n"
+ continue
+ yield _event("job", job)
+ if job["state"] in TERMINAL_STATES:
+ return
+ finally:
+ runner.remove_listener(listener)
+
+
+def _event(name: str, payload: dict[str, Any]) -> str:
+ return f"event: {name}\ndata: {json.dumps(payload)}\n\n"
diff --git a/webui/backend/app/jobspec.py b/webui/backend/app/jobspec.py
new file mode 100644
index 00000000..d7071cdc
--- /dev/null
+++ b/webui/backend/app/jobspec.py
@@ -0,0 +1,237 @@
+"""Job specification and the validation h3 would otherwise refuse at runtime.
+
+Every message here is copied verbatim from h3.c or main.c, so what the browser
+shows before submitting is what the engine would have said afterwards.
+"""
+
+from typing import Any, Literal
+
+from pydantic import BaseModel, Field
+
+FPS = 24
+CANVAS_MULTIPLE = 32
+MAX_PIXELS = 768 * 1344
+MIN_FRAMES_GENERATION = 22
+MAX_FRAMES = 362
+# A truncated video soundtrack needs 2 s, and 39 frames is only 1.625 s.
+MIN_FRAMES_FOR_SOUNDTRACK = 56
+MAX_REFERENCE_AUDIO_SECONDS = 15.0
+MIN_REFERENCE_AUDIO_SECONDS = 2.0
+
+ReferenceKind = Literal["image", "video", "silent_video", "video_audio", "audio"]
+
+
+def align_frames(requested: int) -> int:
+ """Mirror of h3_align_frame_count: legal shapes are 5 + 17n."""
+ value = max(requested, 5)
+ remainder = (value - 5) % 17
+ return value if remainder == 0 else value + 17 - remainder
+
+
+def frames_from_seconds(seconds: float) -> int:
+ return round(seconds * FPS)
+
+
+class Reference(BaseModel):
+ kind: ReferenceKind
+ path: str
+ # Only for video_audio: the replacement soundtrack.
+ audio_path: str | None = None
+ # Filled from the asset store; used for the audio duration rules.
+ seconds: float | None = None
+
+
+class JobSpec(BaseModel):
+ prompt: str = ""
+ width: int = 864
+ height: int = 480
+ render_width: int = 0
+ render_height: int = 0
+ frames: int | None = None
+ seconds: float | None = None
+ steps: int = 20
+ denoise_reuse: int = 1
+ dit_layers: int = 50
+ core_reuse: int = 1
+ token_reduction: bool = False
+ ssd_streaming: bool = False
+ use_int8_row_fc2: bool = False
+ use_reference_rope: bool = False
+ seed: int = 42
+ first_frame: str | None = None
+ last_frame: str | None = None
+ references: list[Reference] = Field(default_factory=list)
+ reference_image_size: Literal["match", "max"] = "match"
+ write_frames: bool = False
+ profile: bool = False
+ preview: bool = False
+ slower: list[str] = Field(default_factory=list)
+ postprocess: list[str] = Field(default_factory=list)
+
+ def resolved_frames(self) -> int:
+ """The frame count h3 will actually generate."""
+ requested = (
+ self.frames
+ if self.frames is not None
+ else frames_from_seconds(self.seconds)
+ if self.seconds is not None
+ else 56
+ )
+ return align_frames(max(requested, 1))
+
+
+class EstimateRequest(BaseModel):
+ """A job, plus the alternatives the interface wants labelled with a time."""
+
+ spec: JobSpec
+ variants: list[dict[str, Any]] = Field(default_factory=list)
+
+
+def validate(spec: JobSpec, backend: str = "cuda") -> tuple[list[str], list[str]]:
+ """Return (errors, warnings). Errors mean h3 would refuse the job."""
+ errors: list[str] = []
+ warnings: list[str] = []
+
+ if not spec.prompt.strip():
+ errors.append("a prompt is required")
+
+ _check_canvas(spec, errors)
+ _check_duration(spec, errors)
+ _check_sampler(spec, errors)
+ _check_backend_flags(spec, backend, errors, warnings)
+ _check_references(spec, errors)
+ return errors, warnings
+
+
+def _check_canvas(spec: JobSpec, errors: list[str]) -> None:
+ if (
+ spec.width < CANVAS_MULTIPLE
+ or spec.height < CANVAS_MULTIPLE
+ or spec.width % CANVAS_MULTIPLE
+ or spec.height % CANVAS_MULTIPLE
+ ):
+ errors.append("width and height must be multiples of 32 and at least 32")
+ if spec.width * spec.height > MAX_PIXELS:
+ errors.append("canvas exceeds the released 768*1344 pixel limit")
+ if (spec.render_width == 0) != (spec.render_height == 0):
+ errors.append("render width and height must be set together")
+ elif spec.render_width and (
+ spec.render_width < CANVAS_MULTIPLE
+ or spec.render_height < CANVAS_MULTIPLE
+ or spec.render_width % CANVAS_MULTIPLE
+ or spec.render_height % CANVAS_MULTIPLE
+ or spec.render_width > spec.width
+ or spec.render_height > spec.height
+ or spec.render_width * spec.height != spec.render_height * spec.width
+ ):
+ errors.append(
+ "internal render canvas must be same-aspect multiples of 32 "
+ "no larger than the output canvas"
+ )
+
+
+def _check_duration(spec: JobSpec, errors: list[str]) -> None:
+ if spec.frames is not None and spec.seconds is not None:
+ errors.append("--seconds and --frames are mutually exclusive")
+ if spec.seconds is not None and spec.seconds <= 0:
+ errors.append("invalid seconds")
+ return
+ requested = spec.frames if spec.frames is not None else None
+ if requested is not None and requested < 5:
+ errors.append("frames must align within the released 5..362 range")
+ return
+ aligned = spec.resolved_frames()
+ if aligned > MAX_FRAMES:
+ errors.append("frames must align within the released 5..362 range")
+ elif aligned < MIN_FRAMES_GENERATION:
+ errors.append("generation requires at least one trained 22-frame decoder chunk")
+
+
+def _check_sampler(spec: JobSpec, errors: list[str]) -> None:
+ if not 2 <= spec.steps <= 1000:
+ errors.append("denoising steps must be in [2, 1000]")
+ if not 1 <= spec.denoise_reuse <= 3:
+ errors.append("denoise reuse must be in [1, 3]")
+ if not 35 <= spec.dit_layers <= 50:
+ errors.append("DiT layers must be in [35, 50]")
+ if not 1 <= spec.core_reuse <= 6:
+ errors.append("core reuse must be in [1, 6]")
+ if spec.core_reuse > 1 and spec.denoise_reuse > 1:
+ errors.append("core reuse and denoiser reuse cannot be combined")
+
+
+def _check_backend_flags(
+ spec: JobSpec, backend: str, errors: list[str], warnings: list[str]
+) -> None:
+ if spec.ssd_streaming and spec.use_int8_row_fc2:
+ errors.append(
+ "SSD streaming uses original BF16 weights and cannot be combined "
+ "with int8 row FC2"
+ )
+ if spec.use_int8_row_fc2 and "use-slower-bf16-mlp" in spec.slower:
+ errors.append("int8 row FC2 cannot be combined with the BF16 MLP")
+ if spec.use_int8_row_fc2 and backend == "cuda":
+ warnings.append(
+ "--use-int8-row-fc2 is a Metal/M5 specialization and a measured "
+ "no-op on this CUDA backend"
+ )
+
+
+def _check_references(spec: JobSpec, errors: list[str]) -> None:
+ references = spec.references
+ if not references:
+ return
+ if spec.first_frame or spec.last_frame:
+ errors.append("full references cannot be combined with frame anchors")
+ if len(references) > 12:
+ errors.append("Ref2VA supports at most 12 references")
+
+ video_kinds = ("video", "silent_video", "video_audio")
+ images = sum(1 for r in references if r.kind == "image")
+ videos = sum(1 for r in references if r.kind in video_kinds)
+ # A plain --ref-video keeps its embedded audio, so it counts as an input;
+ # --ref-silent-video does not.
+ audio_inputs = sum(
+ 1 for r in references if r.kind in ("audio", "video", "video_audio")
+ )
+ if images > 9 or videos > 3 or audio_inputs > 3:
+ errors.append("Ref2VA limits are 9 images, 3 videos, and 3 audio inputs")
+ if not any(r.kind != "audio" for r in references):
+ errors.append("reference audio requires an image or video reference")
+
+ for index, reference in enumerate(references, start=1):
+ if reference.kind == "video_audio" and not reference.audio_path:
+ errors.append(f"video+audio reference {index} has no soundtrack path")
+
+ # A video soundtrack is truncated to min(clip length, output length), and
+ # h3 refuses anything shorter than two seconds.
+ output_seconds = spec.resolved_frames() / FPS
+ has_soundtrack = any(r.kind in ("video", "video_audio") for r in references)
+ if has_soundtrack and output_seconds < MIN_REFERENCE_AUDIO_SECONDS:
+ errors.append(
+ "a video soundtrack requires at least 2 seconds; "
+ "request at least 56 output frames"
+ )
+ for index, reference in enumerate(references, start=1):
+ if reference.kind not in ("video", "video_audio"):
+ continue
+ if (
+ reference.seconds is not None
+ and reference.seconds < MIN_REFERENCE_AUDIO_SECONDS
+ ):
+ errors.append(
+ f"video soundtrack {index} requires at least 2 seconds: "
+ f"the clip is only {reference.seconds:g} s"
+ )
+
+ total = 0.0
+ for reference in references:
+ if reference.kind != "audio":
+ continue
+ if reference.seconds is None:
+ continue
+ if reference.seconds < MIN_REFERENCE_AUDIO_SECONDS:
+ errors.append("reference audio requires at least 2 seconds at 32 kHz")
+ total += reference.seconds
+ if total > MAX_REFERENCE_AUDIO_SECONDS:
+ errors.append("ordered reference audio exceeds 15 seconds in total")
diff --git a/webui/backend/app/main.py b/webui/backend/app/main.py
new file mode 100644
index 00000000..1d4c3b45
--- /dev/null
+++ b/webui/backend/app/main.py
@@ -0,0 +1,458 @@
+"""FastAPI application: health, capabilities and system inventory.
+
+Every `/api/*` route requires a session cookie (R30); the only exceptions
+are health and the two account endpoints. See the security note in the
+README before exposing the service anywhere: it serves plain HTTP, and TLS
+is the reverse proxy's job.
+"""
+
+import shutil
+import tempfile
+from contextlib import asynccontextmanager
+from pathlib import Path
+from typing import Any
+
+from fastapi import FastAPI, HTTPException, Request, Response, UploadFile
+from fastapi.responses import (
+ FileResponse,
+ JSONResponse,
+ PlainTextResponse,
+ StreamingResponse,
+)
+from pydantic import BaseModel, ValidationError
+
+from . import assets, auth, media
+from .capabilities import load_schema
+from .config import Settings, settings
+from .db import Database
+from .events import job_events
+from .jobspec import EstimateRequest, JobSpec, validate
+from .postprocess import registry
+from .progress import observed_correction
+from .runner import JobRunner
+from .system import read_system
+
+# Routes that must answer before anyone has an account.
+PUBLIC_PATHS = {"/api/health", "/api/auth/login", "/api/auth/register"}
+
+
+class RegisterRequest(BaseModel):
+ username: str
+ password: str
+ invite: str | None = None
+
+
+class LoginRequest(BaseModel):
+ username: str
+ password: str
+
+
+class PasswordResetRequest(BaseModel):
+ password: str
+
+
+def create_app(config: Settings | None = None) -> FastAPI:
+ config = config or settings()
+
+ @asynccontextmanager
+ async def lifespan(app: FastAPI):
+ app.state.config = config
+ app.state.db = Database(config.data_dir / "h3.sqlite3")
+ auth.bootstrap_admin(
+ app.state.db, config.admin_username, config.admin_password
+ )
+ app.state.runner = JobRunner(app.state.db, config)
+ app.state.runner.start()
+ yield
+ app.state.runner.shutdown()
+ app.state.db.close()
+
+ app = FastAPI(title="h3c studio", version="0.1.0", lifespan=lifespan)
+
+ @app.middleware("http")
+ async def require_session(request: Request, call_next):
+ """One door for the whole API: a valid session cookie, or 401.
+
+ The SSE and media routes go through it too — they are just GETs the
+ browser sends with the same cookie.
+ """
+ path = request.url.path
+ if path.startswith("/api/") and path not in PUBLIC_PATHS:
+ user = auth.session_user(
+ app.state.db, request.cookies.get(auth.SESSION_COOKIE)
+ )
+ if user is None:
+ return JSONResponse(
+ status_code=401,
+ content={"detail": "authentication required"},
+ )
+ request.state.user = user
+ return await call_next(request)
+
+ @app.post("/api/auth/register", status_code=201)
+ def register(payload: RegisterRequest) -> dict[str, Any]:
+ db = app.state.db
+ errors = auth.validate_credentials(payload.username, payload.password)
+ if auth.get_user_by_username(db, payload.username) is not None:
+ errors.append("that username is taken")
+ if errors:
+ raise HTTPException(status_code=422, detail={"errors": errors})
+
+ # Accounts are made with invites, full stop (R33): the administrator
+ # comes from the server configuration, not from the door.
+ invite = db.query_one(
+ "SELECT 1 FROM invites WHERE code = ? AND used_at IS NULL",
+ (payload.invite or "",),
+ )
+ if invite is None:
+ raise HTTPException(
+ status_code=400,
+ detail="registration needs an invite from the administrator",
+ )
+
+ user_id = auth.create_user(db, payload.username, payload.password)
+ auth.consume_invite(db, payload.invite or "", user_id)
+ user = db.query_one("SELECT * FROM users WHERE id = ?", (user_id,))
+ return {"username": user["username"], "role": user["role"]}
+
+ @app.post("/api/auth/login")
+ def login(payload: LoginRequest, response: Response) -> dict[str, Any]:
+ db = app.state.db
+ if auth.login_blocked(db, payload.username):
+ raise HTTPException(
+ status_code=429,
+ detail="too many wrong passwords: wait a few minutes",
+ )
+ user = auth.get_user_by_username(db, payload.username)
+ if user is None or not auth.verify_password(
+ user["password_hash"], payload.password
+ ):
+ auth.record_failed_login(db, payload.username)
+ raise HTTPException(status_code=401, detail="wrong username or password")
+ auth.clear_failed_logins(db, payload.username)
+ token = auth.create_session(db, user["id"])
+ response.set_cookie(
+ auth.SESSION_COOKIE,
+ token,
+ max_age=auth.SESSION_TTL_SECONDS,
+ httponly=True,
+ samesite="lax",
+ path="/",
+ )
+ return {"username": user["username"], "role": user["role"]}
+
+ @app.post("/api/auth/logout", status_code=204)
+ def logout(request: Request) -> Response:
+ token = request.cookies.get(auth.SESSION_COOKIE)
+ if token:
+ auth.delete_session(app.state.db, token)
+ response = Response(status_code=204)
+ response.delete_cookie(auth.SESSION_COOKIE, path="/")
+ return response
+
+ @app.get("/api/auth/me")
+ def me(request: Request) -> dict[str, Any]:
+ user = request.state.user
+ return {"username": user["username"], "role": user["role"]}
+
+ @app.get("/api/health")
+ def health() -> dict[str, Any]:
+ return {"status": "ok", "version": app.version}
+
+ @app.get("/api/capabilities")
+ def capabilities() -> dict[str, Any]:
+ return load_schema(config.schema_path) | {
+ "plugins": [plugin.as_dict() for plugin in registry(config)]
+ }
+
+ @app.get("/api/system")
+ def system() -> dict[str, Any]:
+ return read_system(config.binary, config.model_dir, config.info_timeout)
+
+ @app.post("/api/jobs", status_code=201)
+ def create_job(spec: JobSpec, request: Request) -> dict[str, Any]:
+ errors, warnings = validate(spec)
+ if errors:
+ raise HTTPException(status_code=422, detail={"errors": errors})
+ job = app.state.runner.submit(spec, owner=request.state.user["id"])
+ return job | {"warnings": warnings}
+
+ @app.post("/api/jobs/validate")
+ def validate_job(spec: JobSpec) -> dict[str, Any]:
+ errors, warnings = validate(spec)
+ model = app.state.runner.model
+ correction, learned_from = observed_correction(app.state.db, model)
+ return {
+ "errors": errors,
+ "warnings": warnings,
+ "frames": spec.resolved_frames(),
+ "seconds": round(spec.resolved_frames() / 24, 3),
+ "estimate_seconds": round(
+ sum(seconds for _, seconds in model.plan(spec)) * correction, 1
+ ),
+ "learned_from": learned_from,
+ }
+
+ @app.post("/api/jobs/estimate")
+ def estimate(request: EstimateRequest) -> dict[str, Any]:
+ """How long this job would take, and how long each alternative would.
+
+ One request labels every choice on screen, so a card can say what it
+ costs before it is picked.
+ """
+ model = app.state.runner.model
+ correction, learned_from = observed_correction(app.state.db, model)
+
+ def total(candidate: JobSpec) -> float:
+ plan = model.plan(candidate)
+ return round(sum(seconds for _, seconds in plan) * correction, 1)
+
+ spec = request.spec
+ answered = []
+ for override in request.variants:
+ try:
+ candidate = JobSpec.model_validate(spec.model_dump() | override)
+ except ValidationError as error:
+ answered.append({"override": override, "error": error.error_count()})
+ continue
+ answered.append({"override": override, "seconds": total(candidate)})
+ return {
+ "seconds": total(spec),
+ "variants": answered,
+ "learned_from": learned_from,
+ }
+
+ @app.get("/api/jobs")
+ def list_jobs(request: Request, limit: int = 100) -> list[dict[str, Any]]:
+ user = request.state.user
+ owner = None if user["role"] == "admin" else user["id"]
+ return app.state.runner.listing(limit, owner=owner)
+
+ @app.get("/api/jobs/{job_id}")
+ def read_job(job_id: int, request: Request) -> dict[str, Any]:
+ job = _visible_job_or_404(app, job_id, request)
+ return job
+
+ @app.post("/api/jobs/{job_id}/cancel")
+ def cancel_job(job_id: int, request: Request) -> dict[str, Any]:
+ _visible_job_or_404(app, job_id, request)
+ job = app.state.runner.cancel(job_id)
+ if job is None:
+ raise HTTPException(status_code=404, detail="unknown job")
+ return job
+
+ @app.delete("/api/jobs/{job_id}", status_code=204)
+ def delete_job(job_id: int, request: Request) -> Response:
+ _visible_job_or_404(app, job_id, request)
+ try:
+ outcome = app.state.runner.delete(job_id)
+ except OSError as failure:
+ raise HTTPException(
+ status_code=500,
+ detail=f"the files of this video could not be removed: {failure}",
+ ) from failure
+ if outcome is None:
+ raise HTTPException(status_code=404, detail="unknown job")
+ if outcome == "unfinished":
+ raise HTTPException(
+ status_code=409, detail="stop this video before deleting it"
+ )
+ return Response(status_code=204)
+
+ @app.get("/api/jobs/{job_id}/events")
+ async def job_stream(job_id: int, request: Request) -> StreamingResponse:
+ _visible_job_or_404(app, job_id, request)
+ return StreamingResponse(
+ job_events(app.state.runner, job_id),
+ media_type="text/event-stream",
+ headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
+ )
+
+ @app.get("/api/jobs/{job_id}/video")
+ def job_video(job_id: int, request: Request) -> FileResponse:
+ job = _visible_job_or_404(app, job_id, request)
+ path = Path(job["output_path"] or "")
+ if not path.is_file():
+ raise HTTPException(status_code=404, detail="this job has no video")
+ return FileResponse(path, media_type="video/mp4", filename=f"h3-{job_id}.mp4")
+
+ @app.get("/api/jobs/{job_id}/poster")
+ def job_poster(job_id: int, request: Request) -> FileResponse:
+ job = _visible_job_or_404(app, job_id, request)
+ video = Path(job["output_path"] or "")
+ if not video.is_file():
+ raise HTTPException(status_code=404, detail="this job has no video")
+ poster = video.with_name("poster.jpg")
+ if not poster.is_file() and not media.extract_poster(video, poster, config):
+ raise HTTPException(status_code=404, detail="cannot build a poster")
+ return FileResponse(poster, media_type="image/jpeg")
+
+ @app.get("/api/jobs/{job_id}/preview")
+ def job_preview(job_id: int, request: Request) -> FileResponse:
+ _visible_job_or_404(app, job_id, request)
+ jpeg = media.preview_jpeg(app.state.runner.preview_dir(job_id), config)
+ if jpeg is None:
+ raise HTTPException(status_code=404, detail="no preview yet")
+ return FileResponse(
+ jpeg, media_type="image/jpeg", headers={"Cache-Control": "no-store"}
+ )
+
+ @app.get("/api/jobs/{job_id}/log", response_class=PlainTextResponse)
+ def job_log(job_id: int, request: Request) -> str:
+ job = _visible_job_or_404(app, job_id, request)
+ path = Path(job["log_path"] or "")
+ if not path.is_file():
+ raise HTTPException(status_code=404, detail="this job has no log")
+ return path.read_text(errors="replace")
+
+ @app.get("/api/assets")
+ def list_assets(request: Request) -> list[dict[str, Any]]:
+ user = request.state.user
+ owner = None if user["role"] == "admin" else user["id"]
+ return assets.listing(app.state.db, owner=owner)
+
+ @app.post("/api/assets", status_code=201)
+ async def upload_asset(file: UploadFile, request: Request) -> dict[str, Any]:
+ filename = Path(file.filename or "").name
+ if not filename:
+ raise HTTPException(status_code=400, detail="the upload has no file name")
+ with tempfile.TemporaryDirectory() as staging:
+ staged = Path(staging) / filename
+ with staged.open("wb") as handle:
+ shutil.copyfileobj(file.file, handle)
+ try:
+ return assets.store(
+ app.state.db,
+ staged,
+ filename,
+ config.data_dir / "assets",
+ config.max_upload_bytes,
+ config.ffprobe,
+ owner=request.state.user["id"],
+ )
+ except assets.AssetError as error:
+ raise HTTPException(status_code=400, detail=str(error)) from error
+
+ @app.get("/api/assets/{asset_id}/file")
+ def asset_file(asset_id: int, request: Request) -> FileResponse:
+ row = _visible_asset_or_404(app, asset_id, request)
+ return FileResponse(row["path"], filename=row["filename"])
+
+ # ── account administration (admin only, R30) ────────────────────────
+
+ @app.get("/api/users")
+ def list_users(request: Request) -> list[dict[str, Any]]:
+ _require_admin(request)
+ return [
+ {
+ "id": row["id"],
+ "username": row["username"],
+ "role": row["role"],
+ "created_at": row["created_at"],
+ }
+ for row in app.state.db.query_all("SELECT * FROM users ORDER BY id")
+ ]
+
+ @app.get("/api/invites")
+ def list_invites(request: Request) -> list[dict[str, Any]]:
+ _require_admin(request)
+ return [
+ {
+ "code": row["code"],
+ "created_at": row["created_at"],
+ "used": row["used_at"] is not None,
+ }
+ for row in app.state.db.query_all(
+ "SELECT * FROM invites ORDER BY rowid DESC"
+ )
+ ]
+
+ @app.post("/api/invites", status_code=201)
+ def create_invite(request: Request) -> dict[str, Any]:
+ _require_admin(request)
+ code = auth.create_invite(app.state.db, request.state.user["id"])
+ return {"code": code}
+
+ @app.delete("/api/users/{user_id}", status_code=204)
+ def delete_user(user_id: int, request: Request) -> Response:
+ _require_admin(request)
+ db = app.state.db
+ if user_id == request.state.user["id"]:
+ raise HTTPException(status_code=409, detail="you cannot delete yourself")
+ user = db.query_one("SELECT * FROM users WHERE id = ?", (user_id,))
+ if user is None:
+ raise HTTPException(status_code=404, detail="unknown user")
+ jobs = db.query_one(
+ "SELECT COUNT(*) AS n FROM jobs WHERE owner = ?", (user_id,)
+ )["n"]
+ owned_assets = db.query_one(
+ "SELECT COUNT(*) AS n FROM assets WHERE owner = ?", (user_id,)
+ )["n"]
+ if jobs or owned_assets:
+ raise HTTPException(
+ status_code=409,
+ detail="this account still has videos or uploads",
+ )
+ # Sessions die with the user (ON DELETE CASCADE); the invite trail
+ # keeps the name of who used it (ON DELETE SET NULL).
+ db.run("DELETE FROM users WHERE id = ?", (user_id,))
+ return Response(status_code=204)
+
+ @app.post("/api/users/{user_id}/password")
+ def reset_password(
+ user_id: int, payload: PasswordResetRequest, request: Request
+ ) -> dict[str, Any]:
+ _require_admin(request)
+ db = app.state.db
+ user = db.query_one("SELECT * FROM users WHERE id = ?", (user_id,))
+ if user is None:
+ raise HTTPException(status_code=404, detail="unknown user")
+ errors = []
+ if not (
+ auth.MIN_PASSWORD_LENGTH
+ <= len(payload.password)
+ <= auth.MAX_PASSWORD_LENGTH
+ ):
+ errors.append(
+ f"a password is between {auth.MIN_PASSWORD_LENGTH} and "
+ f"{auth.MAX_PASSWORD_LENGTH} characters"
+ )
+ if errors:
+ raise HTTPException(status_code=422, detail={"errors": errors})
+ db.run(
+ "UPDATE users SET password_hash = ? WHERE id = ?",
+ (auth.hash_password(payload.password), user_id),
+ )
+ # Old sessions belong to the old secret.
+ auth.delete_sessions_for_user(db, user_id)
+ return {"username": user["username"]}
+
+ return app
+
+
+def _visible_job_or_404(app: FastAPI, job_id: int, request: Request) -> dict[str, Any]:
+ """A job the caller may not see is indistinguishable from one that does
+ not exist: 404, not 403, so a foreign id reveals nothing (R30)."""
+ user = request.state.user
+ job = app.state.runner.get(job_id)
+ if job is None or (user["role"] != "admin" and job["owner"] != user["id"]):
+ raise HTTPException(status_code=404, detail="unknown job")
+ return job
+
+
+def _visible_asset_or_404(app: FastAPI, asset_id: int, request: Request):
+ user = request.state.user
+ row = app.state.db.query_one(
+ "SELECT * FROM assets WHERE id = ?", (asset_id,)
+ )
+ if row is None or (user["role"] != "admin" and row["owner"] != user["id"]):
+ raise HTTPException(status_code=404, detail="unknown asset")
+ return row
+
+
+def _require_admin(request: Request) -> None:
+ if request.state.user["role"] != "admin":
+ raise HTTPException(status_code=403, detail="only the administrator can")
+
+
+app = create_app()
diff --git a/webui/backend/app/media.py b/webui/backend/app/media.py
new file mode 100644
index 00000000..05184b44
--- /dev/null
+++ b/webui/backend/app/media.py
@@ -0,0 +1,70 @@
+"""Small FFmpeg helpers for the gallery."""
+
+import re
+import subprocess
+from pathlib import Path
+
+from .config import Settings
+
+_STEP = re.compile(r"^step-(\d+)\.ppm$")
+
+
+def extract_poster(video: Path, poster: Path, config: Settings) -> bool:
+ """Grab the first frame as a JPEG thumbnail. Best effort."""
+ try:
+ done = subprocess.run( # noqa: S603 - fixed argv, no shell
+ [
+ config.ffmpeg,
+ "-y",
+ "-loglevel",
+ "error",
+ "-i",
+ str(video),
+ "-frames:v",
+ "1",
+ str(poster),
+ ],
+ capture_output=True,
+ timeout=120,
+ check=False,
+ )
+ except (OSError, subprocess.TimeoutExpired):
+ return False
+ return done.returncode == 0 and poster.is_file()
+
+
+def latest_preview(directory: Path) -> tuple[int, Path] | None:
+ """Newest complete denoising preview, or None if there is not one yet.
+
+ h3 writes to a staging name and renames, so every step-*.ppm here is whole.
+ """
+ if not directory.is_dir():
+ return None
+ best: tuple[int, Path] | None = None
+ for entry in directory.iterdir():
+ match = _STEP.match(entry.name)
+ if match and (best is None or int(match.group(1)) > best[0]):
+ best = (int(match.group(1)), entry)
+ return best
+
+
+def preview_jpeg(directory: Path, config: Settings) -> Path | None:
+ """Convert the newest preview to JPEG once, then reuse it."""
+ newest = latest_preview(directory)
+ if newest is None:
+ return None
+ step, source = newest
+ target = directory / f"step-{step:04d}.jpg"
+ if target.is_file():
+ return target
+ try:
+ done = subprocess.run( # noqa: S603 - fixed argv, no shell
+ [config.ffmpeg, "-y", "-loglevel", "error", "-i", str(source),
+ "-q:v", "3", str(target)],
+ capture_output=True,
+ timeout=60,
+ check=False,
+ )
+ except (OSError, subprocess.TimeoutExpired):
+ return None
+ return target if done.returncode == 0 and target.is_file() else None
diff --git a/webui/backend/app/postprocess.py b/webui/backend/app/postprocess.py
new file mode 100644
index 00000000..ea2bc7e8
--- /dev/null
+++ b/webui/backend/app/postprocess.py
@@ -0,0 +1,139 @@
+"""Optional post-processing stage: an extension point, not an integration.
+
+A plugin is an external executable, not a Python import: FaceFusion and its
+kin live in their own virtualenv with pinned onnxruntime builds, and importing
+them here would inherit those constraints.
+
+Contract, so a third party can implement one without reading this file:
+
+ $H3__CMD --input IN.mp4 --output OUT.mp4 [--param value ...]
+
+ exit 0 and OUT.mp4 written -> the job's video is replaced
+ exit != 0 -> the job fails, IN.mp4 is kept
+
+This repository ships no models, no weights and no download URLs. Plugins are
+unavailable until the operator points the environment variable at an
+executable they installed themselves.
+"""
+
+import contextlib
+import os
+import shutil
+import signal
+import subprocess
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+from .config import Settings
+
+
+@dataclass
+class Plugin:
+ name: str
+ label: str
+ description: str
+ env_var: str
+ command: str
+ notice: str | None = None
+
+ @property
+ def available(self) -> bool:
+ return bool(self.command) and (
+ Path(self.command).is_file() or shutil.which(self.command) is not None
+ )
+
+ @property
+ def reason(self) -> str | None:
+ if self.available:
+ return None
+ if not self.command:
+ return (
+ f"no model and no runtime installed: set {self.env_var} to an "
+ "executable to enable it"
+ )
+ return f"{self.env_var} points at {self.command}, which is not executable"
+
+ def as_dict(self) -> dict[str, Any]:
+ return {
+ "name": self.name,
+ "label": self.label,
+ "description": self.description,
+ "env_var": self.env_var,
+ "available": self.available,
+ "reason": self.reason,
+ "notice": self.notice,
+ }
+
+
+def registry(config: Settings) -> list[Plugin]:
+ return [
+ Plugin(
+ name="faceswap",
+ label="Face replacement",
+ description=(
+ "Replaces faces in the generated video using an external "
+ "face-swapping runtime."
+ ),
+ env_var="H3_FACESWAP_CMD",
+ command=config.faceswap_cmd,
+ notice=(
+ "This repository ships neither models nor download URLs. Known "
+ "model licences are non-commercial/research only, and the "
+ "operator is responsible for checking them. Do not use it on "
+ "images of real people without their consent."
+ ),
+ )
+ ]
+
+
+def by_name(config: Settings, name: str) -> Plugin | None:
+ return next((plugin for plugin in registry(config) if plugin.name == name), None)
+
+
+class PluginError(RuntimeError):
+ pass
+
+
+def run_stage(
+ config: Settings, video: Path, requested: list[str], timeout: float = 3600.0
+) -> Path:
+ """Run each requested plugin in order, replacing the video each time."""
+ for name in requested:
+ plugin = by_name(config, name)
+ if plugin is None:
+ raise PluginError(f"unknown post-processing plugin: {name}")
+ if not plugin.available:
+ raise PluginError(f"post-processing plugin {name} is unavailable")
+ produced = video.with_name(f"{video.stem}-{name}{video.suffix}")
+ try:
+ process = subprocess.Popen( # noqa: S603 - argv list, no shell
+ [plugin.command, "--input", str(video), "--output", str(produced)],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ start_new_session=True,
+ )
+ except OSError as error:
+ raise PluginError(
+ f"post-processing {name} could not run: {error}"
+ ) from error
+ try:
+ out, err = process.communicate(timeout=timeout)
+ except subprocess.TimeoutExpired:
+ # Kill the whole session, not just the plugin: it may have
+ # spawned helpers of its own, and an orphan would keep working
+ # on the video after the job is declared failed (T106).
+ with contextlib.suppress(OSError):
+ os.killpg(process.pid, signal.SIGKILL)
+ with contextlib.suppress(OSError):
+ process.wait(timeout=10)
+ raise PluginError(
+ f"post-processing {name} timed out after {timeout:.0f} s"
+ ) from None
+ if process.returncode != 0 or not produced.is_file():
+ detail = (err or out or "").strip().splitlines()
+ last = detail[-1] if detail else "no output"
+ raise PluginError(f"post-processing {name} failed: {last}")
+ produced.replace(video)
+ return video
diff --git a/webui/backend/app/progress.py b/webui/backend/app/progress.py
new file mode 100644
index 00000000..583a2b94
--- /dev/null
+++ b/webui/backend/app/progress.py
@@ -0,0 +1,247 @@
+"""Weighted progress and ETA.
+
+A naive bar would jump: on the calibration run `load transformer core` took
+40.9 s and `denoise` 5.1 s, so counting phases equally is misleading. Weights
+come from a real measured run (webui/shared/progress_weights.json, produced by
+tools/calibrate_progress.py) and are scaled to the job's own settings. The ETA
+then corrects itself with what the current run has actually taken so far.
+"""
+
+import json
+from functools import lru_cache
+from pathlib import Path
+from statistics import median
+from typing import Any
+
+from .db import Database
+from .jobspec import JobSpec
+
+# What each phase's cost is proportional to. A phase not listed here is a fixed
+# cost: loading the text encoder takes the same time whatever the video is.
+SCALES: dict[str, tuple[str, ...]] = {
+ "denoise": ("steps", "pixels", "frames"),
+ "denoise enqueue": ("steps", "pixels", "frames"),
+ # The decode and the mux happen inside this phase's wall time.
+ "video VAE load": ("pixels", "frames"),
+ "FFmpeg": ("frames",),
+}
+UNKNOWN_PHASE_SECONDS = 1.0
+
+
+@lru_cache
+def load_weights(path: Path) -> dict[str, Any]:
+ return json.loads(path.read_text())
+
+
+def _work(phase: str, spec_like: dict[str, Any]) -> float:
+ """Absolute amount of work a phase does for one configuration.
+
+ Pixels use the internal canvas when there is one, because that is what the
+ model and the VAE actually run at.
+ """
+ width = spec_like.get("render_width") or spec_like["width"]
+ height = spec_like.get("render_height") or spec_like["height"]
+ amount = {
+ "steps": spec_like["steps"],
+ "pixels": width * height / 1e6,
+ "frames": spec_like["frames"],
+ }
+ work = 1.0
+ for dimension in SCALES.get(phase, ()):
+ work *= amount[dimension]
+ return work
+
+
+class ProgressModel:
+ def __init__(self, weights: dict[str, Any]) -> None:
+ self.reference = weights["reference"]
+ self.phase_seconds: dict[str, float] = weights["phase_seconds"]
+ self.order = list(self.phase_seconds)
+ self.factors: dict[str, Any] = weights.get("factors", {})
+ self.fit = _fit(weights.get("samples", []))
+
+ def plan(self, spec: JobSpec) -> list[tuple[str, float]]:
+ """Expected seconds per phase for this job, in order."""
+ ratios = self._ratios(spec)
+ shape = {
+ "width": spec.width,
+ "height": spec.height,
+ "render_width": spec.render_width,
+ "render_height": spec.render_height,
+ "steps": spec.steps,
+ "frames": spec.resolved_frames(),
+ }
+ plan = []
+ for phase in self.order:
+ if phase in self.fit:
+ fixed, variable = self.fit[phase]
+ seconds = fixed + variable * _work(phase, shape)
+ else:
+ seconds = self.phase_seconds[phase]
+ for dimension in SCALES.get(phase, ()):
+ seconds *= ratios[dimension]
+ if phase.startswith("denoise"):
+ seconds *= self._denoise_factor(spec)
+ seconds += self._streaming_seconds(spec)
+ elif phase == "load transformer core" and spec.ssd_streaming:
+ seconds *= self._factor("ssd_streaming", "load_factor", 1.0)
+ plan.append((phase, seconds))
+ if spec.preview and "preview VAE load" not in self.phase_seconds:
+ # Enabling the preview adds a VAE load; charge it like the decoder.
+ index = next(
+ (i for i, (name, _) in enumerate(plan) if name == "denoise"), len(plan)
+ )
+ plan.insert(index, ("preview VAE load", self._preview_seconds(spec)))
+ return plan
+
+ def fraction(self, spec: JobSpec, phase: str | None, completed: int, total: int
+ ) -> float:
+ plan = self.plan(spec)
+ budget = sum(seconds for _, seconds in plan)
+ if budget <= 0:
+ return 0.0
+ done = 0.0
+ for name, seconds in plan:
+ if name == phase:
+ share = completed / total if total else 0.0
+ return min(1.0, (done + seconds * min(max(share, 0.0), 1.0)) / budget)
+ done += seconds
+ # An unknown phase carries no information about position: report
+ # nothing and let the caller keep the highest value seen so far.
+ return 0.0
+
+ def remaining_seconds(
+ self, spec: JobSpec, phase: str | None, completed: int, total: int,
+ elapsed: float
+ ) -> float | None:
+ """Estimate what is left, corrected by how this run is actually going."""
+ share = self.fraction(spec, phase, completed, total)
+ if share <= 0.02 or elapsed <= 0:
+ budget = sum(seconds for _, seconds in self.plan(spec))
+ return max(budget - elapsed, 0.0) if budget else None
+ return max(elapsed / share - elapsed, 0.0)
+
+ def _denoise_factor(self, spec: JobSpec) -> float:
+ """What the sampler settings do to the cost of one pass.
+
+ Steps, pixels and frames are already in the ratios; this is everything
+ else the quality presets change, which is most of what they change.
+ """
+ reference_layers = self._factor("dit_layers", "reference", 50) or 50
+ factor = spec.dit_layers / reference_layers
+ if spec.core_reuse > 1:
+ heads = self._factor("core_reuse", "head_share", 0.3)
+ factor *= heads + (1 - heads) / spec.core_reuse
+ else:
+ reuse = self.factors.get("denoise_reuse", {})
+ factor *= float(reuse.get(str(spec.denoise_reuse), 1.0))
+ if spec.token_reduction:
+ factor *= self._factor("token_reduction", "factor", 1.0)
+ return factor
+
+ def _streaming_seconds(self, spec: JobSpec) -> float:
+ """Streaming the weights from disk costs the same on every step."""
+ if not spec.ssd_streaming:
+ return 0.0
+ return self._factor("ssd_streaming", "added_seconds_per_step", 0.0) * spec.steps
+
+ def _factor(self, group: str, key: str, fallback: float) -> float:
+ return float(self.factors.get(group, {}).get(key, fallback))
+
+ def _preview_seconds(self, spec: JobSpec) -> float:
+ return self.phase_seconds.get("video VAE load", 10.0) * 0.5 * self._ratios(
+ spec
+ )["pixels"]
+
+ def _ratios(self, spec: JobSpec) -> dict[str, float]:
+ reference = self.reference
+ width = spec.render_width or spec.width
+ height = spec.render_height or spec.height
+ return {
+ "steps": spec.steps / max(reference["steps"], 1),
+ "pixels": (width * height)
+ / max(reference["width"] * reference["height"], 1),
+ "frames": spec.resolved_frames() / max(reference["frames"], 1),
+ }
+
+
+def _fit(samples: list[dict[str, Any]]) -> dict[str, tuple[float, float]]:
+ """Split each phase into a fixed and a per-unit-of-work cost.
+
+ One calibration run cannot tell the two apart: with a single sample every
+ phase looks purely proportional, and the quality presets then all cost the
+ same. Two runs at different sizes separate them.
+ """
+ if len(samples) < 2:
+ return {}
+ first, last = samples[0], samples[-1]
+ fitted: dict[str, tuple[float, float]] = {}
+ for phase in first["phase_seconds"]:
+ if phase not in last["phase_seconds"]:
+ continue
+ w1 = _work(phase, first["reference"])
+ w2 = _work(phase, last["reference"])
+ s1 = first["phase_seconds"][phase]
+ s2 = last["phase_seconds"][phase]
+ if abs(w2 - w1) < 1e-9:
+ fitted[phase] = (min(s1, s2), 0.0)
+ continue
+ variable = (s2 - s1) / (w2 - w1)
+ fixed = s1 - variable * w1
+ if variable < 0:
+ # Noise, not a real saving: treat the phase as a fixed cost.
+ fitted[phase] = (min(s1, s2), 0.0)
+ elif fixed < 0:
+ # All of it scales; anchor on the larger, more reliable sample.
+ fitted[phase] = (0.0, s2 / w2)
+ else:
+ fitted[phase] = (fixed, variable)
+ return fitted
+
+
+# How far a learned correction may pull the estimate. Beyond this the history
+# is telling us something the model cannot express, and a wrong number with
+# confidence is worse than a rough one.
+CORRECTION_RANGE = (0.25, 4.0)
+CORRECTION_SAMPLE = 12
+
+
+def observed_correction(
+ database: Database, model: "ProgressModel"
+) -> tuple[float, int]:
+ """How wrong the estimate has been lately, as a single factor.
+
+ Two calibration runs fix the shape of the model, not its accuracy across
+ every size: the cost of drawing does not grow linearly forever. Rather than
+ pretend otherwise, the estimate is scaled by what recent jobs on this
+ machine actually took.
+ """
+ rows = database.query_all(
+ "SELECT params, started_at, finished_at FROM jobs "
+ "WHERE state = 'completed' AND started_at IS NOT NULL "
+ "AND finished_at IS NOT NULL ORDER BY id DESC LIMIT ?",
+ (CORRECTION_SAMPLE,),
+ )
+ ratios: list[float] = []
+ for row in rows:
+ try:
+ spec = JobSpec.model_validate(json.loads(row["params"]))
+ except (ValueError, TypeError):
+ continue
+ predicted = sum(seconds for _, seconds in model.plan(spec))
+ actual = _seconds_between(row["started_at"], row["finished_at"])
+ if predicted > 1 and actual > 1:
+ ratios.append(actual / predicted)
+ if len(ratios) < 2:
+ return 1.0, len(ratios)
+ low, high = CORRECTION_RANGE
+ return min(max(median(ratios), low), high), len(ratios)
+
+
+def _seconds_between(started: str, finished: str) -> float:
+ from datetime import UTC, datetime
+
+ fmt = "%Y-%m-%d %H:%M:%S"
+ begin = datetime.strptime(started, fmt).replace(tzinfo=UTC)
+ end = datetime.strptime(finished, fmt).replace(tzinfo=UTC)
+ return (end - begin).total_seconds()
diff --git a/webui/backend/app/runner.py b/webui/backend/app/runner.py
new file mode 100644
index 00000000..a6045e6d
--- /dev/null
+++ b/webui/backend/app/runner.py
@@ -0,0 +1,469 @@
+"""Serial job queue over the h3 CLI.
+
+One GPU, one job at a time. A worker thread pulls queued jobs, runs `./h3` as
+a subprocess and mirrors its stderr progress into SQLite. h3 rewrites the
+current progress line with a carriage return, so the reader splits on both
+CR and LF instead of iterating over lines.
+"""
+
+import contextlib
+import json
+import os
+import re
+import shutil
+import signal
+import sqlite3
+import subprocess
+import threading
+from collections.abc import Callable
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import Any
+
+from .argv import build_argv
+from .config import Settings
+from .db import Closed, Database
+from .jobspec import JobSpec
+from .media import latest_preview
+from .postprocess import PluginError, run_stage
+from .progress import ProgressModel, load_weights
+
+# "denoise 7/20 "
+PROGRESS = re.compile(r"^(?P\S.*?)\s{2,}(?P\d+)/(?P\d+)\s*$")
+
+TERMINAL_STATES = {"completed", "failed", "cancelled"}
+Listener = Callable[[dict[str, Any]], None]
+
+
+class JobRunner:
+ """Owns the worker thread, the current process and the event listeners."""
+
+ def __init__(self, database: Database, config: Settings) -> None:
+ self.db = database
+ self.config = config
+ self.model = ProgressModel(load_weights(config.progress_weights_path))
+ self._lock = threading.Lock()
+ self._wake = threading.Event()
+ self._stop = threading.Event()
+ self._process: subprocess.Popen[str] | None = None
+ # start_new_session makes the child its own group leader, so the group
+ # id equals its pid and stays valid even after the leader is reaped.
+ self._pgid: int | None = None
+ self._current: int | None = None
+ # Cancellation is recorded, not inferred: a killed child may still
+ # exit with a normal status if it traps the signal.
+ self._cancelled: set[int] = set()
+ self._listeners: list[Listener] = []
+ self._thread = threading.Thread(target=self._loop, daemon=True)
+
+ # ── lifecycle ────────────────────────────────────────────────────────
+ def start(self) -> None:
+ # A previous backend may have died mid-job, but h3 is born with
+ # start_new_session and outlives it: before declaring anything,
+ # check the recorded pid and stop whatever is still alive (T105).
+ for row in self.db.query_all(
+ "SELECT id, pid FROM jobs WHERE state = 'running'"
+ ):
+ pid = row["pid"]
+ if pid is not None and _process_alive(pid):
+ # The pid can in principle have been reused since the crash;
+ # that risk is accepted, because leaving a live writer on a
+ # directory the UI may delete is worse.
+ _signal_group(pid, signal.SIGKILL)
+ error = (
+ "interrupted by a backend restart "
+ "(its process was still running, and was stopped)"
+ )
+ else:
+ error = "interrupted by a backend restart"
+ self.db.run(
+ "UPDATE jobs SET state='failed', finished_at=datetime('now'), "
+ "error=?, pid=NULL WHERE id=?",
+ (error, row["id"]),
+ )
+ self._thread.start()
+ self._wake.set()
+
+ def shutdown(self) -> None:
+ self._stop.set()
+ self._wake.set()
+ with self._lock:
+ if self._current is not None:
+ self._cancelled.add(self._current)
+ self.cancel_current()
+ self._thread.join(timeout=30)
+ # If the worker is still winding down, record the outcome here: the
+ # database is about to close and a job must never stay 'running'.
+ with self._lock:
+ pending = self._current
+ if pending is not None:
+ with contextlib.suppress(Closed):
+ self._finish(pending, "cancelled", error="backend shutting down")
+
+ # ── public API ───────────────────────────────────────────────────────
+ def submit(self, spec: JobSpec, owner: int | None = None) -> dict[str, Any]:
+ job_id = self.db.run(
+ "INSERT INTO jobs (state, prompt, params, owner)"
+ " VALUES ('queued', ?, ?, ?)",
+ (spec.prompt, spec.model_dump_json(), owner),
+ )
+ self._wake.set()
+ job = self.get(job_id)
+ self._emit(job)
+ return job
+
+ def get(self, job_id: int) -> dict[str, Any] | None:
+ row = self.db.query_one("SELECT * FROM jobs WHERE id = ?", (job_id,))
+ return self._decorate(_row(row)) if row else None
+
+ def job_dir(self, job_id: int) -> Path:
+ return self.config.data_dir / "jobs" / str(job_id)
+
+ def preview_dir(self, job_id: int) -> Path:
+ return self.job_dir(job_id) / "preview"
+
+ def delete(self, job_id: int) -> str | None:
+ """Remove a finished job and everything it wrote to disk.
+
+ Only a job in a terminal state can be deleted, which keeps the worker
+ from writing into a directory that is being removed. That is a guard,
+ not a guarantee: a job the restart sweep declared failed may still have
+ a live h3 of its own, because h3 outlives a crash of this service.
+
+ The directory goes first. If it cannot be removed the row stays and the
+ video is still listed: a visible remnant that can be deleted again is
+ better than gigabytes nothing points at any more. The path is derived
+ from the job id, never from anything the client sent.
+ """
+ job = self.get(job_id)
+ if job is None:
+ return None
+ if job["state"] not in TERMINAL_STATES:
+ return "unfinished"
+ with contextlib.suppress(FileNotFoundError):
+ shutil.rmtree(self.job_dir(job_id))
+ self.db.run("DELETE FROM jobs WHERE id = ?", (job_id,))
+ return "deleted"
+
+ def _decorate(self, job: dict[str, Any]) -> dict[str, Any]:
+ """Attach the newest preview and the weighted progress estimate."""
+ newest = (
+ latest_preview(self.preview_dir(job["id"]))
+ if job["params"].get("preview")
+ else None
+ )
+ job["preview_step"] = newest[0] if newest else None
+ job["elapsed"] = _elapsed(job)
+ job["remaining"] = (
+ _remaining(job["progress"], job["elapsed"])
+ if job["state"] == "running"
+ else None
+ )
+ return job
+
+ def listing(
+ self, limit: int = 100, owner: int | None = None
+ ) -> list[dict[str, Any]]:
+ """Newest first; `owner` filters to one person's takes (R30)."""
+ if owner is None:
+ rows = self.db.query_all(
+ "SELECT * FROM jobs ORDER BY id DESC LIMIT ?", (limit,)
+ )
+ else:
+ rows = self.db.query_all(
+ "SELECT * FROM jobs WHERE owner = ? ORDER BY id DESC LIMIT ?",
+ (owner, limit),
+ )
+ return [self._decorate(_row(row)) for row in rows]
+
+ def cancel(self, job_id: int) -> dict[str, Any] | None:
+ job = self.get(job_id)
+ if job is None or job["state"] in TERMINAL_STATES:
+ return job
+ with self._lock:
+ # Recorded first: a job claimed but not yet spawned would otherwise
+ # slip through and leave an orphan process behind.
+ self._cancelled.add(job_id)
+ is_current = self._current == job_id and self._process is not None
+ if is_current:
+ self.cancel_current()
+ elif job["state"] == "queued":
+ # The request stays recorded: the worker may already be claiming
+ # this job, and it checks the set right after spawning.
+ self._finish(job_id, "cancelled", error="cancelled before it started")
+ return self.get(job_id)
+
+ def cancel_current(self) -> None:
+ """Signal the child and return: the worker thread owns its lifecycle."""
+ with self._lock:
+ process = self._process
+ if process is None or process.poll() is not None:
+ return
+ with self._lock:
+ pgid = self._pgid
+ if pgid is None:
+ return
+ _signal_group(pgid, signal.SIGTERM)
+ killer = threading.Timer(
+ self.config.kill_grace, _signal_group, args=(pgid, signal.SIGKILL)
+ )
+ killer.daemon = True
+ killer.start()
+
+ def add_listener(self, listener: Listener) -> None:
+ self._listeners.append(listener)
+
+ def remove_listener(self, listener: Listener) -> None:
+ if listener in self._listeners:
+ self._listeners.remove(listener)
+
+ # ── worker ───────────────────────────────────────────────────────────
+ def _loop(self) -> None:
+ while not self._stop.is_set():
+ self._wake.wait(timeout=1.0)
+ self._wake.clear()
+ while not self._stop.is_set():
+ try:
+ row = self.db.query_one(
+ "SELECT * FROM jobs WHERE state = 'queued' ORDER BY id LIMIT 1"
+ )
+ except Closed:
+ return
+ if row is None:
+ break
+ try:
+ self._run(_row(row))
+ except Closed:
+ return
+
+ def _run(self, job: dict[str, Any]) -> None:
+ job_id = job["id"]
+ spec = JobSpec.model_validate(job["params"])
+ directory = self.config.data_dir / "jobs" / str(job_id)
+ directory.mkdir(parents=True, exist_ok=True)
+ output = directory / "out.mp4"
+ preview_dir = directory / "preview" if spec.preview else None
+ frames_dir = directory / "frames" if spec.write_frames else None
+ for extra in (preview_dir, frames_dir):
+ if extra is not None:
+ extra.mkdir(parents=True, exist_ok=True)
+ argv = build_argv(
+ spec,
+ self.config.binary,
+ self.config.model_dir,
+ output,
+ frames_dir=frames_dir,
+ preview_dir=preview_dir,
+ )
+ log_path = directory / "job.log"
+ self.db.run(
+ "UPDATE jobs SET state='running', started_at=datetime('now'), "
+ "argv=?, output_path=?, log_path=?, phase=NULL, completed=0, total=0, "
+ "progress=0.0 WHERE id=?",
+ (json.dumps(argv), str(output), str(log_path), job_id),
+ )
+ self._emit(self.get(job_id))
+
+ try:
+ process = subprocess.Popen( # noqa: S603 - argv list, no shell
+ argv,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.PIPE,
+ text=True,
+ errors="replace",
+ start_new_session=True,
+ )
+ except OSError as error:
+ self._finish(job_id, "failed", error=f"cannot start h3: {error}")
+ return
+
+ # Recorded for the restart sweep (T105): h3 runs in its own session,
+ # so the pid doubles as the group id.
+ self.db.run("UPDATE jobs SET pid = ? WHERE id = ?", (process.pid, job_id))
+
+ with self._lock:
+ self._process = process
+ self._pgid = process.pid
+ self._current = job_id
+ # A shutdown that lands between the claim and this point must not
+ # leave the child running: treat it as a cancellation.
+ if self._stop.is_set():
+ self._cancelled.add(job_id)
+ cancel_pending = job_id in self._cancelled
+ if cancel_pending:
+ self.cancel_current()
+
+ # h3 spawns FFmpeg, which inherits stderr: a lingering grandchild would
+ # keep the pipe open forever, so the reader lives in its own thread and
+ # the pipe is closed from this side if it outlives the process.
+ tail: list[str] = []
+ reader = threading.Thread(
+ target=self._pump,
+ args=(process, job_id, log_path, tail, spec),
+ daemon=True,
+ )
+ reader.start()
+ code = process.wait()
+ # Reap anything h3 left behind (FFmpeg, in practice) before waiting on
+ # the reader: an orphan would otherwise hold the stderr pipe open.
+ _signal_group(process.pid, signal.SIGKILL)
+ reader.join(timeout=5)
+ if reader.is_alive() and process.stderr is not None:
+ with contextlib.suppress(OSError, ValueError):
+ process.stderr.close()
+ reader.join(timeout=5)
+ with self._lock:
+ self._process = None
+ self._pgid = None
+ self._current = None
+
+ with self._lock:
+ was_cancelled = job_id in self._cancelled
+ self._cancelled.discard(job_id)
+
+ if code == 0 and not was_cancelled:
+ try:
+ if spec.postprocess and output.is_file():
+ run_stage(self.config, output, spec.postprocess)
+ except PluginError as error:
+ # The raw video stays where it is: the generation succeeded.
+ self._finish(job_id, "failed", error=str(error))
+ return
+ self._finish(job_id, "completed")
+ elif was_cancelled:
+ self._finish(job_id, "cancelled", error="cancelled")
+ else:
+ self._finish(job_id, "failed", error=_reason(tail, code))
+
+ def _pump(
+ self,
+ process: subprocess.Popen[str],
+ job_id: int,
+ log_path: Path,
+ tail: list[str],
+ spec: JobSpec,
+ ) -> None:
+ """Mirror stderr into the log and turn progress lines into updates."""
+ buffer = ""
+ if process.stderr is None:
+ return
+ with log_path.open("w", encoding="utf-8") as log:
+ while True:
+ try:
+ chunk = process.stderr.read(1)
+ except (OSError, ValueError):
+ break
+ if not chunk:
+ break
+ log.write(chunk)
+ if chunk in "\r\n":
+ line, buffer = buffer, ""
+ if line.strip():
+ self._consume(job_id, line, tail, spec)
+ log.flush()
+ else:
+ buffer += chunk
+ if buffer.strip():
+ self._consume(job_id, buffer, tail, spec)
+
+ def _consume(
+ self, job_id: int, line: str, tail: list[str], spec: JobSpec
+ ) -> None:
+ match = PROGRESS.match(line.strip("\r\n"))
+ if match:
+ phase = match["phase"].strip()
+ completed = int(match["completed"])
+ total = int(match["total"])
+ # max(): an unknown phase reports 0, and the bar must never regress.
+ self.db.run(
+ "UPDATE jobs SET phase=?, completed=?, total=?, "
+ "progress=max(progress, ?) WHERE id=?",
+ (
+ phase,
+ completed,
+ total,
+ self.model.fraction(spec, phase, completed, total),
+ job_id,
+ ),
+ )
+ self._emit(self.get(job_id))
+ return
+ tail.append(line.strip())
+ del tail[:-20]
+
+ def _finish(self, job_id: int, state: str, error: str | None = None) -> None:
+ # progress is NOT NULL: a cancelled or failed job keeps what it reached.
+ if state == "completed":
+ self.db.run(
+ "UPDATE jobs SET state=?, error=?, finished_at=datetime('now'), "
+ "progress=1.0, pid=NULL WHERE id=?",
+ (state, error, job_id),
+ )
+ else:
+ self.db.run(
+ "UPDATE jobs SET state=?, error=?, finished_at=datetime('now'), "
+ "pid=NULL WHERE id=?",
+ (state, error, job_id),
+ )
+ self._emit(self.get(job_id))
+
+ def _emit(self, job: dict[str, Any] | None) -> None:
+ if job is None:
+ return
+ for listener in list(self._listeners):
+ try:
+ listener(job)
+ except Exception: # noqa: BLE001 - a broken listener must not stop a job
+ self.remove_listener(listener)
+
+
+def _signal_group(pgid: int, number: int) -> None:
+ """Signal the whole group, including children the leader left behind."""
+ with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
+ os.killpg(pgid, number)
+
+
+def _process_alive(pid: int) -> bool:
+ try:
+ os.kill(pid, 0)
+ except ProcessLookupError:
+ return False
+ except OSError:
+ # Exists but belongs to someone else: not ours to stop.
+ return False
+ return True
+
+
+def _reason(tail: list[str], code: int) -> str:
+ for line in reversed(tail):
+ if line.startswith("h3:"):
+ return line
+ return tail[-1] if tail else f"h3 exited with code {code}"
+
+
+def _remaining(progress: float, elapsed: float | None) -> float | None:
+ """Correct the estimate with the pace this run is actually keeping."""
+ if not elapsed or progress <= 0.02:
+ return None
+ return max(elapsed / progress - elapsed, 0.0)
+
+
+def _elapsed(job: dict[str, Any]) -> float | None:
+ started = job.get("started_at")
+ if not started:
+ return None
+ ended = job.get("finished_at")
+ start = datetime.strptime(started, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC)
+ stop = (
+ datetime.strptime(ended, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC)
+ if ended
+ else datetime.now(UTC)
+ )
+ return max((stop - start).total_seconds(), 0.0)
+
+
+def _row(row: sqlite3.Row) -> dict[str, Any]:
+ job = dict(row)
+ job["params"] = json.loads(job["params"]) if job["params"] else {}
+ job["argv"] = json.loads(job["argv"]) if job["argv"] else None
+ return job
diff --git a/webui/backend/app/system.py b/webui/backend/app/system.py
new file mode 100644
index 00000000..6f23226a
--- /dev/null
+++ b/webui/backend/app/system.py
@@ -0,0 +1,88 @@
+"""Device and checkpoint inventory, read from `h3 --info`.
+
+`h3 --info` only reads checkpoint headers, so it is cheap enough to call on
+demand and cache. Everything degrades to `available: false` with a reason
+instead of raising, so the UI can explain what is missing.
+"""
+
+import re
+import subprocess
+from pathlib import Path
+from typing import Any
+
+_COMPONENT = re.compile(
+ r"^\s{2}(?P.+?)\s{2,}(?P\d+) files\s+(?P\d+) tensors"
+ r"\s+(?P[\d.]+) GiB\s*$"
+)
+_MEMORY = re.compile(r"^\s{2}(?P[a-zA-Z0-9 ]+?)\s{2,}(?P.+?)\s*$")
+
+
+def parse_info(text: str) -> dict[str, Any]:
+ """Turn the plain-text `h3 --info` report into structured data."""
+ info: dict[str, Any] = {"device": {}, "components": {}}
+ in_inventory = False
+ for line in text.splitlines():
+ if line.startswith("h3-"):
+ engine, _, version = line.partition(" ")
+ info["engine"] = engine
+ info["version"] = version.strip()
+ continue
+ if line.startswith("Device:"):
+ device = line[len("Device:") :].strip()
+ match = re.match(r"^(?P.*?)\s*\((?P[^)]*)\)$", device)
+ info["device"] = (
+ match.groupdict() if match else {"name": device, "architecture": ""}
+ )
+ continue
+ if line.startswith("Native checkpoint inventory"):
+ in_inventory = True
+ continue
+ if in_inventory:
+ match = _COMPONENT.match(line)
+ if match:
+ info["components"][match["label"].strip()] = {
+ "files": int(match["files"]),
+ "tensors": int(match["tensors"]),
+ "gib": float(match["gib"]),
+ }
+ continue
+ match = _MEMORY.match(line)
+ if match:
+ key = match["label"].strip().replace(" ", "_")
+ info["device"][key] = match["value"].strip()
+ return info
+
+
+def read_system(binary: Path, model_dir: Path, timeout: float) -> dict[str, Any]:
+ """Run `h3 --info`, or explain why it could not run."""
+ if not binary.exists():
+ return _unavailable(f"h3 binary not found at {binary}")
+ if not model_dir.is_dir():
+ return _unavailable(f"model directory not found at {model_dir}")
+ try:
+ done = subprocess.run( # noqa: S603 - fixed argv, no shell
+ [str(binary), "--info", "-d", str(model_dir)],
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ check=False,
+ )
+ except subprocess.TimeoutExpired:
+ return _unavailable(f"h3 --info timed out after {timeout:g}s")
+ except OSError as error:
+ return _unavailable(f"cannot run h3 --info: {error}")
+ if done.returncode != 0:
+ detail = (done.stderr or done.stdout).strip().splitlines()
+ return _unavailable(detail[-1] if detail else "h3 --info failed")
+ info = parse_info(done.stdout)
+ info["available"] = True
+ info["model_dir"] = str(model_dir)
+ info["has_ref2va"] = any(
+ "Ref2VA" in label and entry["files"] > 0
+ for label, entry in info["components"].items()
+ )
+ return info
+
+
+def _unavailable(reason: str) -> dict[str, Any]:
+ return {"available": False, "reason": reason, "device": {}, "components": {}}
diff --git a/webui/backend/pyproject.toml b/webui/backend/pyproject.toml
new file mode 100644
index 00000000..56526cb5
--- /dev/null
+++ b/webui/backend/pyproject.toml
@@ -0,0 +1,27 @@
+[project]
+name = "h3c-webui-backend"
+version = "0.1.0"
+description = "Web UI backend for h3.c: job queue, uploads and progress over the h3 CLI."
+requires-python = ">=3.12"
+dependencies = [
+ "fastapi>=0.115",
+ "uvicorn[standard]>=0.34",
+ "pydantic-settings>=2.6",
+ "argon2-cffi>=23.1",
+ "python-multipart",
+]
+
+[project.optional-dependencies]
+dev = ["pytest>=8", "httpx>=0.27", "ruff>=0.6"]
+
+[tool.setuptools.packages.find]
+include = ["app*"]
+
+[tool.ruff]
+line-length = 88
+
+[tool.ruff.lint]
+select = ["E", "F", "I", "UP", "B", "SIM"]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
diff --git a/webui/backend/tests/conftest.py b/webui/backend/tests/conftest.py
new file mode 100644
index 00000000..fb956a0e
--- /dev/null
+++ b/webui/backend/tests/conftest.py
@@ -0,0 +1,35 @@
+import os
+import sys
+from contextlib import contextmanager
+from pathlib import Path
+
+BACKEND = Path(__file__).resolve().parents[1]
+if str(BACKEND) not in sys.path:
+ sys.path.insert(0, str(BACKEND))
+
+# The administrator is declared by the deployment (R33): in the tests, the
+# deployment is this environment, so every app the tests build bootstraps
+# the same admin account at startup.
+os.environ.setdefault("H3_ADMIN_USERNAME", "admin")
+os.environ.setdefault("H3_ADMIN_PASSWORD", "correct-horse-9")
+
+
+@contextmanager
+def authed_client(config, username="admin", password="correct-horse-9"):
+ """The app under test, signed in as its administrator.
+
+ Production requires a session for every API call (R30), so the tests
+ enter through the same door the browser does: the administrator comes
+ from the configuration (R33), and the client logs in with it.
+ """
+ from fastapi.testclient import TestClient
+
+ from app.main import create_app
+
+ with TestClient(create_app(config)) as client:
+ response = client.post(
+ "/api/auth/login",
+ json={"username": username, "password": password},
+ )
+ assert response.status_code == 200, response.text
+ yield client
diff --git a/webui/backend/tests/test_api_basics.py b/webui/backend/tests/test_api_basics.py
new file mode 100644
index 00000000..fa2bddf6
--- /dev/null
+++ b/webui/backend/tests/test_api_basics.py
@@ -0,0 +1,115 @@
+import json
+import os
+import stat
+
+import pytest
+from conftest import authed_client
+
+from app.config import Settings
+from app.system import parse_info
+
+REAL_INFO = """h3-metal 0.1.0-dev
+Device: NVIDIA GB10 (sm_121)
+ physical memory 121.7 GiB
+ recommended GPU set 121.7 GiB
+ max GPU buffer 121.7 GiB
+ unified memory yes
+Native checkpoint inventory (header-only):
+ Qwen3-VL encoder 9 files 398 tensors 15.918 GiB
+ FL2VA DiT 42 files 1204 tensors 54.063 GiB
+ Ref2VA DiT 42 files 1204 tensors 54.063 GiB
+ video VAE 1 files 402 tensors 0.749 GiB
+ audio VAE 1 files 190 tensors 0.312 GiB
+"""
+
+
+def _fake_h3(tmp_path, stdout=REAL_INFO, code=0):
+ binary = tmp_path / "h3"
+ binary.write_text(f"#!/bin/sh\ncat <<'EOF'\n{stdout}EOF\nexit {code}\n")
+ binary.chmod(binary.stat().st_mode | stat.S_IEXEC)
+ return binary
+
+
+@pytest.fixture
+def client(tmp_path):
+ config = Settings(
+ binary=_fake_h3(tmp_path),
+ model_dir=tmp_path / "model",
+ data_dir=tmp_path / "data",
+ )
+ (tmp_path / "model").mkdir()
+ with authed_client(config) as client:
+ yield client
+
+
+def test_health(client):
+ assert client.get("/api/health").json()["status"] == "ok"
+
+
+def test_capabilities_serves_the_shared_schema_and_the_plugins(client):
+ payload = client.get("/api/capabilities").json()
+ shared = json.loads(Settings().schema_path.read_text())
+ assert {key: payload[key] for key in shared} == shared
+ assert [plugin["name"] for plugin in payload["plugins"]] == ["faceswap"]
+
+
+def test_system_reports_device_and_components(client):
+ payload = client.get("/api/system").json()
+ assert payload["available"] is True
+ assert payload["device"]["name"] == "NVIDIA GB10"
+ assert payload["device"]["architecture"] == "sm_121"
+ assert payload["device"]["physical_memory"] == "121.7 GiB"
+ assert payload["components"]["FL2VA DiT"]["files"] == 42
+ assert payload["components"]["audio VAE"]["gib"] == 0.312
+ assert payload["has_ref2va"] is True
+
+
+def test_system_degrades_when_the_binary_is_missing(tmp_path):
+ config = Settings(
+ binary=tmp_path / "absent",
+ model_dir=tmp_path,
+ data_dir=tmp_path / "data",
+ )
+ with authed_client(config) as client:
+ payload = client.get("/api/system").json()
+ assert payload["available"] is False
+ assert "not found" in payload["reason"]
+
+
+def test_system_degrades_when_h3_fails(tmp_path):
+ config = Settings(
+ binary=_fake_h3(tmp_path, stdout="h3: cannot map checkpoint\n", code=1),
+ model_dir=tmp_path,
+ data_dir=tmp_path / "data",
+ )
+ with authed_client(config) as client:
+ payload = client.get("/api/system").json()
+ assert payload["available"] is False
+ assert payload["reason"] == "h3: cannot map checkpoint"
+
+
+def test_the_database_is_created_with_both_tables(tmp_path, client):
+ tables = {
+ row["name"]
+ for row in client.app.state.db.query_all(
+ "SELECT name FROM sqlite_master WHERE type='table'"
+ )
+ }
+ assert {"jobs", "assets"} <= tables
+
+
+def test_parse_info_tolerates_the_apple_extra_lines():
+ apple = REAL_INFO.replace(
+ " unified memory yes",
+ " Apple GPU family 9\n Metal 4 no\n"
+ " unified memory yes",
+ )
+ info = parse_info(apple)
+ assert info["device"]["Apple_GPU_family"] == "9"
+ assert len(info["components"]) == 5
+
+
+def test_settings_read_h3_prefixed_environment(monkeypatch, tmp_path):
+ monkeypatch.setenv("H3_MODEL_DIR", str(tmp_path / "elsewhere"))
+ assert Settings().model_dir == tmp_path / "elsewhere"
+ os.environ.pop("H3_MODEL_DIR", None)
diff --git a/webui/backend/tests/test_assets.py b/webui/backend/tests/test_assets.py
new file mode 100644
index 00000000..4d6a85ef
--- /dev/null
+++ b/webui/backend/tests/test_assets.py
@@ -0,0 +1,148 @@
+"""Uploads: whitelist, size cap, real ffprobe validation and deduplication."""
+
+import shutil
+import subprocess
+
+import pytest
+from conftest import authed_client
+
+from app.assets import AssetError, kind_from_suffix
+from app.config import Settings
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None,
+ reason="FFmpeg and FFprobe are required to build and probe the fixtures",
+)
+
+
+def _ffmpeg(*args: str) -> None:
+ subprocess.run(
+ ["ffmpeg", "-y", "-loglevel", "error", *args], check=True, timeout=120
+ )
+
+
+@pytest.fixture(scope="module")
+def media(tmp_path_factory):
+ """One real file of each kind, plus an audio track that is too short."""
+ root = tmp_path_factory.mktemp("media")
+ image = root / "fox.png"
+ _ffmpeg(
+ "-f", "lavfi", "-i", "color=c=red:s=64x64:d=1", "-frames:v", "1", str(image)
+ )
+ video = root / "clip.mp4"
+ _ffmpeg(
+ "-f", "lavfi", "-i", "testsrc=size=64x64:rate=24:duration=3",
+ "-f", "lavfi", "-i", "sine=frequency=440:duration=3",
+ "-shortest", "-pix_fmt", "yuv420p", str(video),
+ )
+ audio = root / "music.wav"
+ _ffmpeg("-f", "lavfi", "-i", "sine=frequency=440:duration=6", str(audio))
+ short_audio = root / "blip.wav"
+ _ffmpeg("-f", "lavfi", "-i", "sine=frequency=440:duration=1", str(short_audio))
+ return {
+ "image": image,
+ "video": video,
+ "audio": audio,
+ "short_audio": short_audio,
+ "mislabelled": _copy(audio, root / "actually_audio.png"),
+ "not_media": _write(root / "notes.txt", "hello"),
+ }
+
+
+def _copy(source, target):
+ shutil.copyfile(source, target)
+ return target
+
+
+def _write(path, text):
+ path.write_text(text)
+ return path
+
+
+@pytest.fixture
+def client(tmp_path):
+ config = Settings(data_dir=tmp_path / "data", model_dir=tmp_path)
+ with authed_client(config) as client:
+ yield client
+
+
+def _post(client, path, name=None):
+ with path.open("rb") as handle:
+ return client.post("/api/assets", files={"file": (name or path.name, handle)})
+
+
+def test_suffix_whitelist():
+ assert kind_from_suffix(".PNG") == "image"
+ assert kind_from_suffix(".mov") == "video"
+ assert kind_from_suffix(".flac") == "audio"
+ with pytest.raises(AssetError):
+ kind_from_suffix(".exe")
+
+
+def test_upload_image_records_its_geometry(client, media):
+ response = _post(client, media["image"])
+ assert response.status_code == 201
+ body = response.json()
+ assert body["kind"] == "image"
+ assert body["metadata"]["width"] == 64
+ assert body["duplicate"] is False
+
+
+def test_upload_video_records_duration_and_audio_presence(client, media):
+ body = _post(client, media["video"]).json()
+ assert body["kind"] == "video"
+ assert body["metadata"]["has_audio"] is True
+ assert 2.9 <= body["metadata"]["seconds"] <= 3.2
+
+
+def test_upload_audio_within_the_usable_range_has_no_notes(client, media):
+ body = _post(client, media["audio"]).json()
+ assert body["kind"] == "audio"
+ assert body["metadata"]["notes"] == []
+
+
+def test_audio_shorter_than_two_seconds_is_stored_but_flagged(client, media):
+ body = _post(client, media["short_audio"]).json()
+ assert body["kind"] == "audio"
+ assert "shorter than the 2 s minimum" in body["metadata"]["notes"][0]
+
+
+def test_uploading_the_same_bytes_twice_deduplicates(client, media):
+ first = _post(client, media["image"]).json()
+ second = _post(client, media["image"], name="copy.png").json()
+ assert second["duplicate"] is True
+ assert second["id"] == first["id"]
+ assert len(client.get("/api/assets").json()) == 1
+
+
+def test_extension_that_lies_about_the_content_is_refused(client, media):
+ response = _post(client, media["mislabelled"])
+ assert response.status_code == 400
+ assert response.json()["detail"] == "the extension says image but the file is audio"
+
+
+def test_unsupported_extension_is_refused(client, media):
+ response = _post(client, media["not_media"])
+ assert response.status_code == 400
+ assert "unsupported file type" in response.json()["detail"]
+
+
+def test_oversized_upload_is_refused(tmp_path, media):
+ config = Settings(
+ data_dir=tmp_path / "data", model_dir=tmp_path, max_upload_bytes=64
+ )
+ with authed_client(config) as client:
+ response = _post(client, media["audio"])
+ assert response.status_code == 400
+ assert "over the" in response.json()["detail"]
+
+
+def test_stored_file_can_be_downloaded_again(client, media):
+ asset = _post(client, media["image"]).json()
+ response = client.get(f"/api/assets/{asset['id']}/file")
+ assert response.status_code == 200
+ assert response.content[:8] == b"\x89PNG\r\n\x1a\n"
+
+
+def test_unknown_asset_is_a_404(client):
+ assert client.get("/api/assets/999/file").status_code == 404
diff --git a/webui/backend/tests/test_auth.py b/webui/backend/tests/test_auth.py
new file mode 100644
index 00000000..6737051f
--- /dev/null
+++ b/webui/backend/tests/test_auth.py
@@ -0,0 +1,213 @@
+"""T121/T128 (R30/R33): the auth endpoints and the door they guard.
+
+What is covered, end to end through the HTTP surface:
+
+- the administrator comes from the deployment configuration, not the door;
+- every other account exists only through a single-use invite;
+- login sets the cookie, logout kills the session, `me` names the user;
+- everything under `/api/*` — lists, SSE streams and media included —
+ answers 401 without a valid session;
+- five wrong passwords buy a pause, not a lockout forever.
+"""
+
+import sqlite3
+
+from conftest import authed_client
+
+from app.config import Settings
+
+PASSWORD = "correct-horse-9"
+
+
+def _config(tmp_path):
+ return Settings(
+ binary=tmp_path / "absent", model_dir=tmp_path, data_dir=tmp_path / "data"
+ )
+
+
+def _register(client, username="someone", password=PASSWORD, invite=None):
+ payload = {"username": username, "password": password}
+ if invite is not None:
+ payload["invite"] = invite
+ return client.post("/api/auth/register", json=payload)
+
+
+def _login(client, username="someone", password=PASSWORD):
+ return client.post(
+ "/api/auth/login", json={"username": username, "password": password}
+ )
+
+
+def _invite(client):
+ from app import auth
+
+ db = client.app.state.db
+ admin = db.query_one("SELECT id FROM users WHERE username = 'admin'")
+ return auth.create_invite(db, admin["id"])
+
+
+def test_the_administrator_comes_from_the_configuration(tmp_path):
+ # The test environment carries H3_ADMIN_USERNAME/H3_ADMIN_PASSWORD, as a
+ # .env would in production: the account exists as soon as the app starts.
+ with authed_client(_config(tmp_path)) as client:
+ response = client.get("/api/auth/me")
+ assert response.status_code == 200
+ assert response.json() == {"username": "admin", "role": "admin"}
+
+
+def test_without_a_configured_admin_there_is_no_door_in(tmp_path):
+ config = _config(tmp_path)
+ config.admin_password = ""
+ with _raw_client(config) as client:
+ assert _login(client, username="admin").status_code == 401
+ # And nobody can invite themselves in: registration needs an invite.
+ assert _register(client).status_code == 400
+
+
+def _raw_client(config):
+ from fastapi.testclient import TestClient
+
+ from app.main import create_app
+
+ return TestClient(create_app(config))
+
+
+def test_registration_needs_an_invite_even_for_the_first_try(tmp_path):
+ with authed_client(_config(tmp_path)) as client:
+ assert _register(client).status_code == 400
+ assert _register(client, invite="not-a-real-code").status_code == 400
+
+ invite = _invite(client)
+ response = _register(client, invite=invite)
+ assert response.status_code == 201
+ assert response.json() == {"username": "someone", "role": "user"}
+ # An invite is single-use.
+ assert _register(client, username="third", invite=invite).status_code == 400
+
+
+def test_register_rejects_bad_input(tmp_path):
+ with authed_client(_config(tmp_path)) as client:
+ invite = _invite(client)
+ response = _register(client, username="has spaces", invite=invite)
+ assert response.status_code == 422
+ assert "errors" in response.json()["detail"]
+
+ response = _register(client, password="short", invite=invite)
+ assert response.status_code == 422
+
+ response = _register(client, username="admin", invite=invite)
+ assert response.status_code == 422
+ assert "taken" in response.text
+ # The rejected attempts did not burn the invite.
+ assert _register(client, invite=invite).status_code == 201
+
+
+def test_login_and_logout_lifecycle(tmp_path):
+ with authed_client(_config(tmp_path)) as client:
+ invite = _invite(client)
+ assert _register(client, username="solo", invite=invite).status_code == 201
+
+ response = _login(client, username="solo", password="the wrong one")
+ assert response.status_code == 401
+
+ response = _login(client, username="solo")
+ assert response.status_code == 200
+ assert client.cookies.get("h3_session")
+
+ me = client.get("/api/auth/me")
+ assert me.status_code == 200
+ assert me.json()["username"] == "solo"
+
+ assert client.post("/api/auth/logout").status_code == 204
+ # The session row is gone: the same cookie buys nothing now.
+ assert client.get("/api/auth/me").status_code == 401
+
+
+def test_the_whole_api_is_behind_the_door(tmp_path):
+ with _raw_client(_config(tmp_path)) as anonymous:
+ assert anonymous.get("/api/jobs").status_code == 401
+ assert anonymous.get("/api/assets").status_code == 401
+ assert anonymous.get("/api/system").status_code == 401
+ assert anonymous.get("/api/jobs/1/events").status_code == 401
+ assert anonymous.get("/api/jobs/1/video").status_code == 401
+ assert anonymous.get("/api/jobs/1/poster").status_code == 401
+ assert anonymous.get("/api/jobs/1/log").status_code == 401
+ assert anonymous.post("/api/jobs", json={}).status_code == 401
+ # Health stays open: monitoring must not need an account.
+ assert anonymous.get("/api/health").status_code == 200
+
+
+def test_five_wrong_passwords_buy_a_pause(tmp_path):
+ with authed_client(_config(tmp_path)) as client:
+ invite = _invite(client)
+ assert _register(client, username="solo", invite=invite).status_code == 201
+ for _ in range(5):
+ bad = _login(client, username="solo", password="wrong one")
+ assert bad.status_code == 401
+ # Even the right password now has to wait out the window.
+ assert _login(client, username="solo").status_code == 429
+
+
+def test_a_successful_login_clears_the_counter(tmp_path):
+ with authed_client(_config(tmp_path)) as client:
+ invite = _invite(client)
+ assert _register(client, username="solo", invite=invite).status_code == 201
+ for _ in range(4):
+ bad = _login(client, username="solo", password="wrong one")
+ assert bad.status_code == 401
+ assert _login(client, username="solo").status_code == 200
+ # The slate is clean: four more misses are allowed, not one.
+ for _ in range(4):
+ bad = _login(client, username="solo", password="wrong one")
+ assert bad.status_code == 401
+ assert _login(client, username="solo").status_code == 200
+
+
+def test_what_existed_before_accounts_goes_to_the_configured_admin(tmp_path):
+ config = _config(tmp_path)
+ config.data_dir.mkdir(parents=True, exist_ok=True)
+ database = config.data_dir / "h3.sqlite3"
+ connection = sqlite3.connect(database)
+ connection.executescript(
+ """
+ CREATE TABLE jobs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ state TEXT NOT NULL DEFAULT 'queued',
+ prompt TEXT NOT NULL DEFAULT '',
+ params TEXT NOT NULL,
+ argv TEXT,
+ phase TEXT,
+ completed INTEGER NOT NULL DEFAULT 0,
+ total INTEGER NOT NULL DEFAULT 0,
+ progress REAL NOT NULL DEFAULT 0.0,
+ error TEXT,
+ output_path TEXT,
+ log_path TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ started_at TEXT,
+ finished_at TEXT
+ );
+ CREATE TABLE assets (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ sha256 TEXT NOT NULL UNIQUE,
+ kind TEXT NOT NULL,
+ filename TEXT NOT NULL,
+ path TEXT NOT NULL,
+ bytes INTEGER NOT NULL,
+ metadata TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+ INSERT INTO jobs (state, prompt, params) VALUES ('completed', 'x', '{}');
+ """
+ )
+ connection.commit()
+ connection.close()
+
+ # The backfill happens with the bootstrap, before anyone signs in.
+ with authed_client(config) as client:
+ rows = client.app.state.db.query_all("SELECT owner FROM jobs")
+ assert len(rows) == 1
+ admin = client.app.state.db.query_one(
+ "SELECT id FROM users WHERE username = 'admin'"
+ )
+ assert rows[0]["owner"] == admin["id"]
diff --git a/webui/backend/tests/test_copy.py b/webui/backend/tests/test_copy.py
new file mode 100644
index 00000000..9d40a163
--- /dev/null
+++ b/webui/backend/tests/test_copy.py
@@ -0,0 +1,95 @@
+"""The plain-language layer: complete, jargon-free, and with no orphans.
+
+`copy.json` is what the interface says; `options.schema.json` is what h3
+accepts. These tests keep the first honest about the second.
+"""
+
+import json
+import re
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[3]
+COPY = json.loads((ROOT / "webui/shared/copy.json").read_text())
+SCHEMA = json.loads((ROOT / "webui/shared/options.schema.json").read_text())
+JOBSPEC = (ROOT / "webui/backend/app/jobspec.py").read_text()
+
+# Words that describe how the engine is built, not what a person controls.
+JARGON = {
+ "dit", "denoise", "denoising", "denoiser", "reuse", "token", "tokens",
+ "rope", "int8", "bf16", "vae", "latent", "canvas", "sampler", "inference",
+ "checkpoint", "transformer", "quantization", "ref2va", "fl2va", "adaln",
+}
+
+
+def _engine_errors() -> set[str]:
+ """Every message the validator can produce, as written in jobspec.py."""
+ found = set()
+ for match in re.finditer(r"errors\.append\(\s*(.*?)\s*\)\n", JOBSPEC, re.DOTALL):
+ raw = match.group(1)
+ parts = re.findall(r'f?"([^"]*)"', raw)
+ if parts:
+ found.add("".join(parts))
+ return found
+
+
+def test_every_option_a_person_can_set_has_a_plain_name():
+ server_side = {"show", "zoom", "info", "help"}
+ for option in SCHEMA["options"]:
+ key = option["key"]
+ if key in server_side:
+ continue
+ if option["group"] == "parity":
+ assert "slower" in COPY["options"], "the parity flags share one entry"
+ continue
+ if option["type"] == "reference":
+ assert key in COPY["options"], key
+ continue
+ assert key in COPY["options"], key
+ assert COPY["options"][key]["help"], key
+
+
+def test_no_plain_name_contains_engine_jargon():
+ for key, entry in COPY["options"].items():
+ words = {word.strip(",.()").lower() for word in entry["name"].split()}
+ assert not words & JARGON, f"{key}: {entry['name']}"
+
+
+def test_every_engine_error_has_a_plain_translation():
+ translations = COPY["errors"]
+ for message in _engine_errors():
+ assert any(entry["match"] in message for entry in translations), message
+
+
+def test_no_translation_is_orphaned():
+ engine = _engine_errors()
+ for entry in COPY["errors"]:
+ assert any(entry["match"] in message for message in engine), entry["match"]
+
+
+def test_every_translation_says_what_to_do():
+ for entry in COPY["errors"]:
+ assert entry["title"].endswith((".", "?")), entry["match"]
+ assert len(entry["fix"].split()) >= 6, entry["match"]
+ # An instruction, not an apology.
+ assert "sorry" not in entry["fix"].lower()
+
+
+def test_every_progress_phase_has_a_plain_name():
+ """The phases h3 actually emits, read from the C sources."""
+ emitted = set()
+ for source in ("h3.c", "h3_dit.c"):
+ text = (ROOT / source).read_text()
+ emitted |= set(re.findall(r'progress[^"]*"([a-zA-Z0-9 ]+)", ', text))
+ known = {phase for phase in COPY["phases"]}
+ missing = {
+ phase for phase in emitted
+ if phase not in known and len(phase.split()) <= 4
+ }
+ assert not missing, sorted(missing)
+
+
+def test_job_states_are_named_for_people():
+ from app.runner import TERMINAL_STATES
+
+ for state in TERMINAL_STATES | {"queued", "running"}:
+ assert state in COPY["states"], state
diff --git a/webui/backend/tests/test_estimate.py b/webui/backend/tests/test_estimate.py
new file mode 100644
index 00000000..1bb78aa4
--- /dev/null
+++ b/webui/backend/tests/test_estimate.py
@@ -0,0 +1,149 @@
+"""Time estimates: one request labels every choice on screen."""
+
+import pytest
+from conftest import authed_client
+
+from app.config import Settings
+
+SPEC = {
+ "prompt": "a fox",
+ "width": 512,
+ "height": 512,
+ "frames": 22,
+ "steps": 20,
+}
+
+
+@pytest.fixture
+def client(tmp_path):
+ config = Settings(model_dir=tmp_path, data_dir=tmp_path / "data")
+ with authed_client(config) as client:
+ yield client
+
+
+def estimate(client, **body):
+ return client.post("/api/jobs/estimate", json=body).json()
+
+
+def test_a_job_is_estimated_in_seconds(client):
+ body = estimate(client, spec=SPEC)
+ assert body["seconds"] > 0
+ assert body["variants"] == []
+
+
+def test_more_detail_passes_take_longer(client):
+ body = estimate(
+ client,
+ spec=SPEC,
+ variants=[{"steps": 4}, {"steps": 20}, {"steps": 50}],
+ )
+ times = [variant["seconds"] for variant in body["variants"]]
+ assert times == sorted(times)
+ assert times[0] < times[-1]
+
+
+def test_a_bigger_picture_takes_longer(client):
+ body = estimate(
+ client,
+ spec=SPEC,
+ variants=[
+ {"width": 256, "height": 256},
+ {"width": 512, "height": 512},
+ {"width": 768, "height": 768},
+ ],
+ )
+ times = [variant["seconds"] for variant in body["variants"]]
+ assert times == sorted(times)
+
+
+def test_a_longer_video_takes_longer(client):
+ body = estimate(
+ client, spec=SPEC, variants=[{"frames": 22}, {"frames": 107}, {"frames": 243}]
+ )
+ times = [variant["seconds"] for variant in body["variants"]]
+ assert times == sorted(times)
+
+
+def test_working_smaller_is_faster_than_the_output_size(client):
+ body = estimate(
+ client,
+ spec={**SPEC, "width": 512, "height": 512},
+ variants=[{"render_width": 256, "render_height": 256}],
+ )
+ assert body["variants"][0]["seconds"] < body["seconds"]
+
+
+def test_the_preview_costs_something_but_not_much(client):
+ body = estimate(client, spec=SPEC, variants=[{"preview": True}])
+ with_preview = body["variants"][0]["seconds"]
+ assert with_preview > body["seconds"]
+ assert with_preview < body["seconds"] * 1.5
+
+
+def test_an_impossible_variant_is_reported_not_guessed(client):
+ body = estimate(client, spec=SPEC, variants=[{"steps": "many"}])
+ assert "seconds" not in body["variants"][0]
+ assert body["variants"][0]["error"] >= 1
+
+
+def test_validation_carries_the_estimate_so_one_call_is_enough(client):
+ body = client.post("/api/jobs/validate", json=SPEC).json()
+ assert body["estimate_seconds"] > 0
+ assert body["frames"] == 22
+
+
+# ── the estimate learns from what this machine actually did ─────────────────
+
+def _finished_job(client, spec, seconds):
+ """Insert a completed job that took a known amount of time."""
+ import json
+
+ db = client.app.state.db
+ db.run(
+ "INSERT INTO jobs (state, prompt, params, started_at, finished_at, progress) "
+ "VALUES ('completed', 'x', ?, datetime('now'), "
+ "datetime('now', ?), 1.0)",
+ (json.dumps(spec), f"+{seconds} seconds"),
+ )
+
+
+def test_with_no_history_the_estimate_is_the_model_alone(client):
+ body = estimate(client, spec=SPEC)
+ assert body["learned_from"] == 0
+
+
+def test_jobs_that_took_longer_push_the_estimate_up(client):
+ before = estimate(client, spec=SPEC)["seconds"]
+ for _ in range(3):
+ _finished_job(client, SPEC, int(before * 2))
+ after = estimate(client, spec=SPEC)
+ assert after["learned_from"] == 3
+ assert 1.6 * before < after["seconds"] < 2.4 * before
+
+
+def test_one_job_is_not_enough_to_learn_from(client):
+ before = estimate(client, spec=SPEC)["seconds"]
+ _finished_job(client, SPEC, 10_000)
+ after = estimate(client, spec=SPEC)
+ assert after["learned_from"] == 1
+ assert after["seconds"] == before
+
+
+def test_a_wild_history_cannot_pull_the_estimate_beyond_reason(client):
+ plain = estimate(client, spec=SPEC)["seconds"]
+ for _ in range(4):
+ _finished_job(client, SPEC, 100_000)
+ assert estimate(client, spec=SPEC)["seconds"] <= plain * 4.0 + 1
+
+
+def test_failed_and_cancelled_jobs_teach_nothing(client):
+ import json
+
+ before = estimate(client, spec=SPEC)["seconds"]
+ for state in ("failed", "cancelled", "failed"):
+ client.app.state.db.run(
+ "INSERT INTO jobs (state, prompt, params, started_at, finished_at) "
+ "VALUES (?, 'x', ?, datetime('now'), datetime('now', '+9000 seconds'))",
+ (state, json.dumps(SPEC)),
+ )
+ assert estimate(client, spec=SPEC)["seconds"] == before
diff --git a/webui/backend/tests/test_events_and_media.py b/webui/backend/tests/test_events_and_media.py
new file mode 100644
index 00000000..5e45068f
--- /dev/null
+++ b/webui/backend/tests/test_events_and_media.py
@@ -0,0 +1,136 @@
+"""SSE progress stream and the media endpoints."""
+
+import json
+import shutil
+import stat
+import time
+
+import pytest
+from conftest import authed_client
+
+from app.config import Settings
+
+JOB = {"prompt": "a fox", "width": 512, "height": 512, "frames": 22, "steps": 2}
+
+PROGRESS = r"""#!/bin/sh
+out=""
+while [ $# -gt 0 ]; do
+ case "$1" in -o) out="$2"; shift;; esac
+ shift
+done
+printf '\rtext encoder 50/50 ' >&2
+printf '\rdenoise 1/2 ' >&2
+sleep 0.2
+printf '\rdenoise 2/2 \n' >&2
+printf 'h3: done\n' >&2
+if [ -n "$out" ]; then
+ ffmpeg -y -loglevel error -f lavfi -i testsrc=size=64x64:rate=24:duration=1 \
+ -pix_fmt yuv420p "$out"
+fi
+exit 0
+"""
+
+
+def _client(tmp_path, script=PROGRESS):
+ binary = tmp_path / "h3"
+ binary.write_text(script)
+ binary.chmod(binary.stat().st_mode | stat.S_IEXEC)
+ config = Settings(
+ binary=binary, model_dir=tmp_path, data_dir=tmp_path / "data"
+ )
+ return authed_client(config)
+
+
+def _parse(stream_text: str) -> list[dict]:
+ events = []
+ for block in stream_text.split("\n\n"):
+ for line in block.splitlines():
+ if line.startswith("data: "):
+ events.append(json.loads(line[6:]))
+ return events
+
+
+def _wait(client, job_id, states, timeout=20.0):
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ job = client.get(f"/api/jobs/{job_id}").json()
+ if job["state"] in states:
+ return job
+ time.sleep(0.05)
+ pytest.fail("job never reached a terminal state")
+
+
+needs_ffmpeg = pytest.mark.skipif(
+ shutil.which("ffmpeg") is None, reason="FFmpeg is required for the fixture"
+)
+
+
+@needs_ffmpeg
+def test_the_stream_reports_progress_and_closes_on_completion(tmp_path):
+ with _client(tmp_path) as client:
+ job_id = client.post("/api/jobs", json=JOB).json()["id"]
+ with client.stream("GET", f"/api/jobs/{job_id}/events") as response:
+ assert response.headers["content-type"].startswith("text/event-stream")
+ body = "".join(response.iter_text())
+ events = _parse(body)
+ assert events[0]["id"] == job_id
+ assert events[-1]["state"] == "completed"
+ phases = [(e["phase"], e["completed"], e["total"]) for e in events]
+ assert ("denoise", 2, 2) in phases
+
+
+@needs_ffmpeg
+def test_streaming_a_finished_job_returns_one_snapshot(tmp_path):
+ with _client(tmp_path) as client:
+ job_id = client.post("/api/jobs", json=JOB).json()["id"]
+ _wait(client, job_id, {"completed", "failed"})
+ with client.stream("GET", f"/api/jobs/{job_id}/events") as response:
+ events = _parse("".join(response.iter_text()))
+ assert len(events) == 1
+ assert events[0]["state"] == "completed"
+
+
+def test_streaming_an_unknown_job_reports_an_error(tmp_path):
+ with (
+ _client(tmp_path) as client,
+ client.stream("GET", "/api/jobs/404/events") as response,
+ ):
+ body = "".join(response.iter_text())
+ assert "unknown job" in body
+
+
+@needs_ffmpeg
+def test_video_poster_and_log_are_served(tmp_path):
+ with _client(tmp_path) as client:
+ job_id = client.post("/api/jobs", json=JOB).json()["id"]
+ _wait(client, job_id, {"completed", "failed"})
+ video = client.get(f"/api/jobs/{job_id}/video")
+ poster = client.get(f"/api/jobs/{job_id}/poster")
+ log = client.get(f"/api/jobs/{job_id}/log")
+ assert video.status_code == 200
+ assert video.headers["content-type"] == "video/mp4"
+ assert poster.status_code == 200
+ assert poster.content[:3] == b"\xff\xd8\xff"
+ assert "h3: done" in log.text
+
+
+def test_media_endpoints_are_404_before_the_job_produces_anything(tmp_path):
+ with _client(tmp_path, "#!/bin/sh\nexit 1\n") as client:
+ job_id = client.post("/api/jobs", json=JOB).json()["id"]
+ _wait(client, job_id, {"failed", "completed"})
+ assert client.get(f"/api/jobs/{job_id}/video").status_code == 404
+ assert client.get(f"/api/jobs/{job_id}/poster").status_code == 404
+ assert client.get("/api/jobs/999/log").status_code == 404
+
+
+@needs_ffmpeg
+def test_the_poster_is_built_once_and_reused(tmp_path):
+ with _client(tmp_path) as client:
+ job_id = client.post("/api/jobs", json=JOB).json()["id"]
+ _wait(client, job_id, {"completed", "failed"})
+ client.get(f"/api/jobs/{job_id}/poster")
+ poster = tmp_path / f"data/jobs/{job_id}/poster.jpg"
+ assert poster.is_file()
+ stamp = poster.stat().st_mtime_ns
+ client.get(f"/api/jobs/{job_id}/poster")
+ assert poster.stat().st_mtime_ns == stamp
diff --git a/webui/backend/tests/test_frontend_covers_schema.py b/webui/backend/tests/test_frontend_covers_schema.py
new file mode 100644
index 00000000..bb919d17
--- /dev/null
+++ b/webui/backend/tests/test_frontend_covers_schema.py
@@ -0,0 +1,144 @@
+"""Every CLI option must be reachable from the UI, and nothing may drift.
+
+The frontend has no test runner of its own: these checks read its sources, so
+a flag added to h3.c and to the schema cannot quietly miss a control.
+"""
+
+import json
+import re
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[3]
+SCHEMA = json.loads((ROOT / "webui/shared/options.schema.json").read_text())
+FRONTEND = ROOT / "webui/frontend/src"
+SOURCES = "\n".join(
+ path.read_text()
+ for path in FRONTEND.rglob("*.ts*")
+ if "generated" not in path.parts
+)
+GENERATED = (FRONTEND / "generated/options.ts").read_text()
+# JSX wraps prose across lines: compare on a single normalized line.
+FLAT = re.sub(r"\s+", " ", SOURCES)
+
+
+def _flags(text: str) -> set[str]:
+ return set(re.findall(r"--[a-z0-9-]+", text))
+
+
+def test_every_visible_option_has_a_control_in_the_ui():
+ """Named in a component, or rendered from the generated list (parity flags)."""
+ visible = {o["flag"] for o in SCHEMA["options"] if o["ui"] != "hidden"}
+ parity = {o["flag"] for o in SCHEMA["options"] if o["group"] == "parity"}
+ named = _flags(SOURCES)
+ assert visible - parity <= named, sorted(visible - parity - named)
+ # The parity checkboxes are rendered one per flag from the generated list.
+ assert "ALL_SLOWER_FLAGS.map" in FLAT
+ assert parity <= _flags(GENERATED), sorted(parity - _flags(GENERATED))
+
+
+def test_every_option_key_reaches_the_job_spec_type():
+ types = (FRONTEND / "types.ts").read_text()
+ server_side = {"model_dir", "output", "show", "zoom", "info", "help"}
+ # Flags whose directory the server assigns: the UI exposes a toggle.
+ toggles = {"frames_dir": "write_frames", "preview": "preview"}
+ for option in SCHEMA["options"]:
+ key = option["key"]
+ if key in server_side or option["type"] == "reference":
+ continue
+ if option["group"] == "parity":
+ # The ten --use-slower-* flags travel together in `slower`.
+ assert re.search(r"^ slower: string\[\];", types, re.MULTILINE)
+ continue
+ field = toggles.get(key, key)
+ assert re.search(rf"^ {field}[?:]", types, re.MULTILINE), key
+
+
+def test_the_generated_options_file_is_current():
+ for option in SCHEMA["options"]:
+ assert f'"flag": "{option["flag"]}"' in GENERATED, option["flag"]
+ assert '"max_pixels": ' + str(SCHEMA["constants"]["max_pixels"]) in GENERATED
+
+
+def test_the_parity_flags_are_listed_individually():
+ parity = [o["flag"][2:] for o in SCHEMA["options"] if o["group"] == "parity"]
+ for flag in parity:
+ assert flag in GENERATED, flag
+ assert len(parity) == 10
+
+
+def test_blocking_constraints_reach_the_person_in_plain_words():
+ """The engine's wording is translated, not repeated."""
+ app = (FRONTEND / "App.tsx").read_text()
+ # Whatever h3 refuses, the browser shows the translation first.
+ assert "explain(problems[0]).title" in app
+ assert "explain(problems[0]).fix" in app
+ assert "what h3 reported" in app, "the technical message stays available"
+ for needle in [
+ "fixed lengths", # frame alignment
+ "Multiples of 32", # canvas grid
+ "two different ways to work", # anchors versus references
+ "2 to 15 seconds", # reference audio
+ "at least 56 frames", # soundtrack length
+ ]:
+ assert needle in FLAT, needle
+
+
+def test_the_post_processing_section_is_driven_by_the_api():
+ """A plugin that becomes available must not need a UI change."""
+ expert = (FRONTEND / "components/Expert.tsx").read_text()
+ assert "plugins.map" in expert
+ assert "disabled={!plugin.available}" in expert
+ assert "plugin.reason" in expert and "plugin.notice" in expert
+ assert "faceswap" not in expert, "the plugin name must come from the API"
+
+
+def test_the_composing_view_says_nothing_in_the_engine_s_words():
+ """Rubric M5 criterion 1, on the code that actually ships.
+
+ R23 kept every CLI flag visible in Create as a secondary label. R28 moved
+ them out: composing shows a sentence about the shot, and the flags live
+ under Expert with the exact name they have on the command line.
+ """
+ create = (FRONTEND / "components/Create.tsx").read_text()
+ # The whole surface a person composes on, not only the first screen.
+ for name in ["Create", "FineTune", "References", "PhotoSlot"]:
+ source = (FRONTEND / f"components/{name}.tsx").read_text()
+ assert 'className="flag"' not in source, f"a flag chip is back in {name}"
+ assert "--" not in source, f"a flag is back in {name}"
+ named = re.search(r'["\'`][^"\'`]*--[a-z]', create)
+ assert not named, "a flag name is back in Create"
+
+ # Everything a person reads there, with the imports and the interpolations
+ # taken out.
+ prose = "\n".join(
+ line for line in create.splitlines() if not line.startswith("import ")
+ )
+ literals = re.findall(r'"([^"\n]{3,})"|\u0060([^\u0060]{3,})\u0060', prose)
+ words = set()
+ for double, backtick in literals:
+ text = re.sub(r"\$\{[^}]*\}", " ", double or backtick)
+ words |= {word.strip(",.:;()").lower() for word in text.split()}
+ jargon = {
+ "dit", "denoise", "reuse", "token", "rope", "int8", "canvas", "vae",
+ "latent", "sampler", "steps", "layers", "seed", "checkpoint",
+ "inference", "tensor",
+ # "frame" is not on this list: it is the subject's own word, and the
+ # first frame of a video is a thing anyone can picture.
+ }
+ assert not words & jargon, sorted(words & jargon)
+
+ # And the flags are somewhere: under Expert, named exactly.
+ expert = (FRONTEND / "components/Expert.tsx").read_text()
+ for flag in ["--steps", "--layers", "--seed", "--first-frame", "--last-frame"]:
+ assert flag in expert, flag
+
+
+def test_every_choice_that_changes_the_wait_shows_it():
+ """Rubric M4 criterion 2."""
+ create = (FRONTEND / "components/Create.tsx").read_text()
+ assert "shapeSeconds" in create and "qualitySeconds" in create
+ assert "totalSeconds" in create
+ app = (FRONTEND / "App.tsx").read_text()
+ assert "useEstimates" in app
+ fine = (FRONTEND / "components/FineTune.tsx").read_text()
+ assert "Delta" in fine and "saves" in fine and "adds" in fine
diff --git a/webui/backend/tests/test_isolation.py b/webui/backend/tests/test_isolation.py
new file mode 100644
index 00000000..e64c0f69
--- /dev/null
+++ b/webui/backend/tests/test_isolation.py
@@ -0,0 +1,180 @@
+"""T122 (R30): per-user isolation and account administration.
+
+The rule being verified: what you made is yours. Another person's takes and
+uploads answer 404 — not 403 — because a foreign id must reveal nothing.
+The administrator sees and manages everything.
+"""
+
+import base64
+import stat
+
+import pytest
+from conftest import authed_client
+
+from app.config import Settings
+
+PASSWORD = "correct-horse-9"
+
+# A real 1x1 PNG, so the upload goes through the same probe as a photo.
+_PNG_BYTES = base64.b64decode(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
+ "AAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
+)
+
+
+@pytest.fixture
+def png_file(tmp_path):
+ path = tmp_path / "a.png"
+ path.write_bytes(_PNG_BYTES)
+ return path
+
+SUCCESS = (
+ "#!/bin/sh\n"
+ "echo 'denoise 1/1' >&2\n"
+ "mkdir -p \"$(dirname \"$0\")/../nope\" 2>/dev/null || true\n"
+ "exit 0\n"
+)
+
+JOB = {"prompt": "x", "width": 256, "height": 256, "frames": 22, "steps": 2}
+
+
+def _config(tmp_path):
+ binary = tmp_path / "h3"
+ binary.write_text(SUCCESS)
+ binary.chmod(binary.stat().st_mode | stat.S_IEXEC)
+ return Settings(
+ binary=binary, model_dir=tmp_path, data_dir=tmp_path / "data"
+ )
+
+
+def _login_as(client, username, password=PASSWORD):
+ response = client.post(
+ "/api/auth/login", json={"username": username, "password": password}
+ )
+ assert response.status_code == 200, response.text
+ token = response.cookies.get("h3_session")
+ assert token
+ return token
+
+
+def _two_users(client):
+ """Register admin (done by the fixture) and a second user; returns
+ the session tokens of both, with the client wearing the user's."""
+ db = client.app.state.db
+ from app import auth
+
+ invite = auth.create_invite(db, 1)
+ response = client.post(
+ "/api/auth/register",
+ json={"username": "utente", "password": PASSWORD, "invite": invite},
+ )
+ assert response.status_code == 201, response.text
+ admin_token = client.cookies.get("h3_session")
+ user_token = _login_as(client, "utente")
+ return admin_token, user_token
+
+
+def test_takes_are_invisible_across_users(tmp_path):
+ with authed_client(_config(tmp_path)) as client:
+ admin_token, user_token = _two_users(client)
+
+ # The user makes a take.
+ client.cookies.set("h3_session", user_token)
+ job_id = client.post("/api/jobs", json=JOB).json()["id"]
+ listing = client.get("/api/jobs").json()
+ assert [job["id"] for job in listing] == [job_id]
+
+ # The admin sees it, with the owner attached.
+ client.cookies.set("h3_session", admin_token)
+ assert client.get("/api/jobs").json()[0]["owner"] is not None
+
+ # A third account — made through a fresh invite — sees nothing of it.
+ from app import auth
+
+ invite = auth.create_invite(client.app.state.db, 1)
+ client.post(
+ "/api/auth/register",
+ json={"username": "terzo", "password": PASSWORD, "invite": invite},
+ )
+ terzo_token = _login_as(client, "terzo")
+ client.cookies.set("h3_session", terzo_token)
+ assert client.get("/api/jobs").json() == []
+ for path in (
+ f"/api/jobs/{job_id}",
+ f"/api/jobs/{job_id}/video",
+ f"/api/jobs/{job_id}/poster",
+ f"/api/jobs/{job_id}/log",
+ f"/api/jobs/{job_id}/events",
+ ):
+ assert client.get(path).status_code == 404, path
+ assert client.post(f"/api/jobs/{job_id}/cancel").status_code == 404
+ assert client.delete(f"/api/jobs/{job_id}").status_code == 404
+
+
+def test_uploads_are_invisible_across_users(tmp_path, png_file):
+ with authed_client(_config(tmp_path)) as client:
+ admin_token, user_token = _two_users(client)
+
+ client.cookies.set("h3_session", user_token)
+ with png_file.open("rb") as handle:
+ created = client.post(
+ "/api/assets", files={"file": ("a.png", handle, "image/png")}
+ )
+ assert created.status_code == 201
+ asset_id = created.json()["id"]
+ assert [a["id"] for a in client.get("/api/assets").json()] == [asset_id]
+
+ client.cookies.set("h3_session", admin_token)
+ assert client.get(f"/api/assets/{asset_id}/file").status_code == 200
+ client.cookies.set("h3_session", _login_as(client, "utente"))
+
+ # Same bytes from another account: their own row, not the user's.
+ with png_file.open("rb") as handle:
+ again = client.post(
+ "/api/assets", files={"file": ("a.png", handle, "image/png")}
+ )
+ assert again.status_code == 201
+
+
+def test_admin_manages_accounts(tmp_path):
+ with authed_client(_config(tmp_path)) as client:
+ admin_token, user_token = _two_users(client)
+ db = client.app.state.db
+
+ # The list and a fresh invite are admin business.
+ client.cookies.set("h3_session", admin_token)
+ names = [u["username"] for u in client.get("/api/users").json()]
+ assert names == ["admin", "utente"]
+ code = client.post("/api/invites").json()["code"]
+ assert client.get("/api/invites").json()[0]["code"] == code
+
+ # A user gets a 403 on the same doors.
+ client.cookies.set("h3_session", user_token)
+ assert client.get("/api/users").status_code == 403
+ assert client.post("/api/invites").status_code == 403
+ assert client.delete("/api/users/1").status_code == 403
+
+ # Resetting a password kills the old sessions.
+ client.cookies.set("h3_session", admin_token)
+ response = client.post(
+ "/api/users/2/password", json={"password": "a new secret 1"}
+ )
+ assert response.status_code == 200
+ client.cookies.set("h3_session", user_token)
+ assert client.get("/api/auth/me").status_code == 401
+ new_token = _login_as(client, "utente", password="a new secret 1")
+ client.cookies.set("h3_session", new_token)
+ assert client.get("/api/auth/me").json()["username"] == "utente"
+
+ # Deleting an account refuses while it still owns things...
+ client.cookies.set("h3_session", admin_token)
+ db.run(
+ "INSERT INTO jobs (state, prompt, params, owner)"
+ " VALUES ('completed', 'x', '{}', 2)"
+ )
+ assert client.delete("/api/users/2").status_code == 409
+ db.run("DELETE FROM jobs WHERE owner = 2")
+ # ...and refuses self-deletion, then lets go of an empty account.
+ assert client.delete("/api/users/1").status_code == 409
+ assert client.delete("/api/users/2").status_code == 204
+ assert client.delete("/api/users/2").status_code == 404
diff --git a/webui/backend/tests/test_jobspec_validation.py b/webui/backend/tests/test_jobspec_validation.py
new file mode 100644
index 00000000..72326199
--- /dev/null
+++ b/webui/backend/tests/test_jobspec_validation.py
@@ -0,0 +1,251 @@
+"""Table-driven check that the validator refuses exactly what h3 refuses."""
+
+from pathlib import Path
+
+import pytest
+
+from app.argv import build_argv
+from app.jobspec import JobSpec, Reference, align_frames, validate
+
+BASE = {"prompt": "a fox", "width": 512, "height": 512, "frames": 22}
+
+
+def errors_for(**overrides) -> list[str]:
+ errors, _ = validate(JobSpec(**{**BASE, **overrides}))
+ return errors
+
+
+def test_a_plain_job_is_accepted():
+ assert errors_for() == []
+
+
+@pytest.mark.parametrize(
+ ("overrides", "message"),
+ [
+ ({"width": 500}, "width and height must be multiples of 32 and at least 32"),
+ ({"height": 16}, "width and height must be multiples of 32 and at least 32"),
+ (
+ {"width": 1344, "height": 800},
+ "canvas exceeds the released 768*1344 pixel limit",
+ ),
+ ({"render_width": 256}, "render width and height must be set together"),
+ (
+ {"render_width": 256, "render_height": 128},
+ "internal render canvas must be same-aspect multiples of 32 "
+ "no larger than the output canvas",
+ ),
+ (
+ {"render_width": 1024, "render_height": 1024},
+ "internal render canvas must be same-aspect multiples of 32 "
+ "no larger than the output canvas",
+ ),
+ ({"frames": 4}, "frames must align within the released 5..362 range"),
+ ({"frames": 363}, "frames must align within the released 5..362 range"),
+ (
+ {"frames": 5},
+ "generation requires at least one trained 22-frame decoder chunk",
+ ),
+ ({"steps": 1}, "denoising steps must be in [2, 1000]"),
+ ({"steps": 1001}, "denoising steps must be in [2, 1000]"),
+ ({"denoise_reuse": 4}, "denoise reuse must be in [1, 3]"),
+ ({"dit_layers": 34}, "DiT layers must be in [35, 50]"),
+ ({"dit_layers": 51}, "DiT layers must be in [35, 50]"),
+ ({"core_reuse": 7}, "core reuse must be in [1, 6]"),
+ (
+ {"core_reuse": 4, "denoise_reuse": 2},
+ "core reuse and denoiser reuse cannot be combined",
+ ),
+ (
+ {"frames": 22, "seconds": 1.0},
+ "--seconds and --frames are mutually exclusive",
+ ),
+ (
+ {"ssd_streaming": True, "use_int8_row_fc2": True},
+ "SSD streaming uses original BF16 weights and cannot be combined "
+ "with int8 row FC2",
+ ),
+ (
+ {"use_int8_row_fc2": True, "slower": ["use-slower-bf16-mlp"]},
+ "int8 row FC2 cannot be combined with the BF16 MLP",
+ ),
+ ({"prompt": " "}, "a prompt is required"),
+ ],
+)
+def test_rejected_jobs(overrides, message):
+ assert message in errors_for(**overrides)
+
+
+def test_int8_row_fc2_is_only_a_warning_on_cuda():
+ errors, warnings = validate(JobSpec(**BASE, use_int8_row_fc2=True))
+ assert errors == []
+ assert any("no-op on this CUDA backend" in w for w in warnings)
+ _, metal_warnings = validate(
+ JobSpec(**BASE, use_int8_row_fc2=True), backend="metal"
+ )
+ assert metal_warnings == []
+
+
+@pytest.mark.parametrize(
+ ("requested", "aligned"),
+ [(1, 5), (5, 5), (6, 22), (22, 22), (23, 39), (56, 56), (107, 107), (243, 243)],
+)
+def test_frame_alignment_matches_the_engine(requested, aligned):
+ assert align_frames(requested) == aligned
+
+
+def test_seconds_are_converted_and_rounded_up():
+ assert JobSpec(**{**BASE, "frames": None, "seconds": 10.0}).resolved_frames() == 243
+ assert JobSpec(**{**BASE, "frames": None, "seconds": 4.5}).resolved_frames() == 124
+ # 4.4 s is 105.6 frames, which rounds up to the next legal shape, 107.
+ assert JobSpec(**{**BASE, "frames": None, "seconds": 4.4}).resolved_frames() == 107
+
+
+# ─────────────────────────────── references ────────────────────────────────
+
+def image(name="fox.png") -> Reference:
+ return Reference(kind="image", path=name)
+
+
+def test_references_cannot_be_combined_with_anchors():
+ assert "full references cannot be combined with frame anchors" in errors_for(
+ references=[image()], first_frame="a.png"
+ )
+
+
+def test_at_most_twelve_references():
+ assert "Ref2VA supports at most 12 references" in errors_for(
+ references=[image(f"{n}.png") for n in range(13)]
+ )
+
+
+def test_per_kind_reference_limits():
+ message = "Ref2VA limits are 9 images, 3 videos, and 3 audio inputs"
+ assert message in errors_for(references=[image(f"{n}.png") for n in range(10)])
+ assert message in errors_for(
+ frames=56,
+ references=[
+ image(),
+ *[Reference(kind="video", path=f"{n}.mp4") for n in range(4)],
+ ],
+ )
+
+
+def test_audio_needs_a_visual_reference():
+ assert "reference audio requires an image or video reference" in errors_for(
+ references=[Reference(kind="audio", path="m.wav", seconds=6.0)]
+ )
+
+
+def test_audio_duration_rules():
+ short = errors_for(
+ references=[image(), Reference(kind="audio", path="m.wav", seconds=1.2)]
+ )
+ assert "reference audio requires at least 2 seconds at 32 kHz" in short
+ long_total = errors_for(
+ references=[
+ image(),
+ Reference(kind="audio", path="a.wav", seconds=8.0),
+ Reference(kind="audio", path="b.wav", seconds=8.0),
+ ]
+ )
+ assert "ordered reference audio exceeds 15 seconds in total" in long_total
+
+
+def test_video_soundtrack_requires_at_least_56_frames():
+ message = (
+ "a video soundtrack requires at least 2 seconds; "
+ "request at least 56 output frames"
+ )
+ assert message in errors_for(
+ frames=22, references=[Reference(kind="video", path="clip.mp4")]
+ )
+ assert message not in errors_for(
+ frames=56, references=[Reference(kind="video", path="clip.mp4")]
+ )
+ assert message not in errors_for(
+ frames=22, references=[Reference(kind="silent_video", path="clip.mp4")]
+ )
+
+
+def test_video_audio_reference_needs_a_soundtrack():
+ assert "video+audio reference 1 has no soundtrack path" in errors_for(
+ frames=56, references=[Reference(kind="video_audio", path="clip.mp4")]
+ )
+
+
+# ────────────────────────────────── argv ───────────────────────────────────
+
+def test_argv_is_a_list_and_never_a_shell_string():
+ spec = JobSpec(**{**BASE, "prompt": 'a "quoted" fox; rm -rf /'})
+ argv = build_argv(spec, Path("./h3"), Path("./MiniMax-H3"), Path("out.mp4"))
+ assert argv[argv.index("-p") + 1] == 'a "quoted" fox; rm -rf /'
+ assert all(isinstance(part, str) for part in argv)
+
+
+def test_argv_resolves_duration_to_frames():
+ spec = JobSpec(prompt="x", frames=None, seconds=4.5)
+ argv = build_argv(spec, Path("h3"), Path("m"), Path("o.mp4"))
+ assert "--seconds" not in argv
+ assert argv[argv.index("--frames") + 1] == "124"
+
+
+def test_argv_uses_core_reuse_instead_of_reuse_when_set():
+ argv = build_argv(
+ JobSpec(**BASE, core_reuse=4), Path("h3"), Path("m"), Path("o.mp4")
+ )
+ assert "--core-reuse" in argv and "--reuse" not in argv
+
+
+def test_argv_keeps_reference_order_and_pairs_video_audio():
+ spec = JobSpec(
+ **{**BASE, "frames": 56},
+ references=[
+ image("first.png"),
+ Reference(kind="video_audio", path="clip.mp4", audio_path="music.wav"),
+ Reference(kind="audio", path="extra.wav", seconds=3.0),
+ ],
+ )
+ argv = build_argv(spec, Path("h3"), Path("m"), Path("o.mp4"))
+ tail = argv[argv.index("--ref-image-size") :]
+ assert tail == [
+ "--ref-image-size",
+ "match",
+ "--ref-image",
+ "first.png",
+ "--ref-video-audio",
+ "clip.mp4",
+ "music.wav",
+ "--ref-audio",
+ "extra.wav",
+ ]
+
+
+def test_argv_adds_preview_and_frames_directories_only_when_asked():
+ plain = build_argv(JobSpec(**BASE), Path("h3"), Path("m"), Path("o.mp4"))
+ assert "--preview-dir" not in plain and "--frames-dir" not in plain
+ full = build_argv(
+ JobSpec(**BASE),
+ Path("h3"),
+ Path("m"),
+ Path("o.mp4"),
+ frames_dir=Path("frames"),
+ preview_dir=Path("preview"),
+ )
+ assert full[full.index("--preview-dir") + 1] == "preview"
+ assert full[full.index("--frames-dir") + 1] == "frames"
+
+
+def test_empty_output_disables_mp4_encoding():
+ argv = build_argv(JobSpec(**BASE), Path("h3"), Path("m"), None)
+ assert argv[argv.index("-o") + 1] == ""
+
+
+def test_a_reference_clip_shorter_than_two_seconds_is_refused():
+ message = "video soundtrack 1 requires at least 2 seconds: the clip is only 1.4 s"
+ assert message in errors_for(
+ frames=56, references=[Reference(kind="video", path="clip.mp4", seconds=1.4)]
+ )
+ assert message not in errors_for(
+ frames=56,
+ references=[Reference(kind="silent_video", path="clip.mp4", seconds=1.4)],
+ )
diff --git a/webui/backend/tests/test_mockup_covers_schema.py b/webui/backend/tests/test_mockup_covers_schema.py
new file mode 100644
index 00000000..4eea3a14
--- /dev/null
+++ b/webui/backend/tests/test_mockup_covers_schema.py
@@ -0,0 +1,82 @@
+"""The mockup must show every CLI option, so no flag is silently dropped.
+
+The covering rule (design rubric M1): every option of the schema that the UI
+does not exclude appears in a screen with its CLI flag visible, and every
+excluded one is listed as excluded.
+"""
+
+import json
+import re
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[3]
+SCHEMA = json.loads((ROOT / "webui/shared/options.schema.json").read_text())
+MOCKUP = (ROOT / "docs/mockup/index.html").read_text()
+MOCKUP_V2 = (ROOT / "docs/mockup/v2.html").read_text()
+
+
+def _flags_in(html: str) -> set[str]:
+ return set(re.findall(r"--[a-z0-9-]+", html))
+
+
+def test_every_visible_option_appears_with_its_flag():
+ visible = {o["flag"] for o in SCHEMA["options"] if o["ui"] != "hidden"}
+ assert visible <= _flags_in(MOCKUP), sorted(visible - _flags_in(MOCKUP))
+
+
+def test_the_redesign_still_reaches_every_option():
+ """Rubric M4 criterion 5: nothing disappears in the new design."""
+ visible = {o["flag"] for o in SCHEMA["options"] if o["ui"] != "hidden"}
+ assert visible <= _flags_in(MOCKUP_V2), sorted(visible - _flags_in(MOCKUP_V2))
+ for flag in ("--show", "--zoom"):
+ assert flag in MOCKUP_V2, f"{flag} must be listed as not exposed"
+
+
+def test_the_redesign_names_controls_in_plain_language():
+ """Rubric M4 criterion 1: no jargon as a primary label in Create."""
+ create = MOCKUP_V2[
+ MOCKUP_V2.index('') :
+ MOCKUP_V2.index('')
+ ]
+ headings = re.findall(r'([^<]+)', create)
+ jargon = ("dit", "denoise", "reuse", "token", "rope", "int8", "canvas", "vae",
+ "latent", "sampler", "inference")
+ for heading in headings:
+ words = heading.lower().split()
+ assert not any(word.strip(",.") in jargon for word in words), heading
+
+
+def test_hidden_options_are_declared_as_not_exposed():
+ section = re.search(
+ r"
Not exposed by this UI.*?", MOCKUP, re.DOTALL
+ )
+ assert section, "the 'Not exposed by this UI' section is missing"
+ hidden = {o["flag"] for o in SCHEMA["options"] if o["ui"] == "hidden"}
+ assert hidden <= _flags_in(section.group(0)), sorted(
+ hidden - _flags_in(section.group(0))
+ )
+
+
+def test_every_blocking_constraint_is_shown_before_submitting():
+ """Constraints that make h3 refuse a job must be visible in the form."""
+ for needle in [
+ "multiples of 32", # canvas_multiple
+ "768 × 1344", # max_pixels
+ "Set both or neither", # render_pair / render_shape
+ "5 + 17n", # frame_range
+ "22 frames", # frame_minimum
+ "2 … 1000", # steps_range
+ "35 … 50", # layers_range
+ "Mutually exclusive", # reuse vs core-reuse, seconds vs frames
+ "cannot be combined with ordered references", # anchors vs references
+ "at least 2 s at 32 kHz", # audio minimum
+ "≤ 15 s", # audio total
+ "at least 56 output frames", # soundtrack duration
+ "9 images, 3 videos, 3 audio inputs", # reference limits
+ ]:
+ assert needle in MOCKUP, needle
+
+
+def test_all_job_states_are_represented():
+ for state in ["running", "queued", "failed", "done"]:
+ assert f'class="st {state[:3]}"' in MOCKUP or f">{state}<" in MOCKUP, state
diff --git a/webui/backend/tests/test_postprocess.py b/webui/backend/tests/test_postprocess.py
new file mode 100644
index 00000000..99fe0ed9
--- /dev/null
+++ b/webui/backend/tests/test_postprocess.py
@@ -0,0 +1,177 @@
+"""The post-processing extension point: declared, disabled, and inert."""
+
+import stat
+import time
+
+import pytest
+from conftest import authed_client
+
+from app.config import Settings
+from app.postprocess import PluginError, registry, run_stage
+
+JOB = {"prompt": "a fox", "width": 256, "height": 256, "frames": 22, "steps": 2}
+
+MAKES_VIDEO = r"""#!/bin/sh
+out=""
+while [ $# -gt 0 ]; do
+ case "$1" in -o) out="$2"; shift;; esac
+ shift
+done
+[ -n "$out" ] && printf 'raw video' > "$out"
+exit 0
+"""
+
+# A stand-in plugin: reads --input, writes --output. No model, no runtime.
+SWAPPER = r"""#!/bin/sh
+input=""; output=""
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --input) input="$2"; shift;;
+ --output) output="$2"; shift;;
+ esac
+ shift
+done
+printf 'swapped(%s)' "$(cat "$input")" > "$output"
+exit 0
+"""
+
+BROKEN = "#!/bin/sh\nprintf 'faceswap: no model installed\\n' >&2\nexit 3\n"
+
+
+def _executable(path, script):
+ path.write_text(script)
+ path.chmod(path.stat().st_mode | stat.S_IEXEC)
+ return path
+
+
+def _client(tmp_path, faceswap_cmd=""):
+ config = Settings(
+ binary=_executable(tmp_path / "h3", MAKES_VIDEO),
+ model_dir=tmp_path,
+ data_dir=tmp_path / "data",
+ faceswap_cmd=faceswap_cmd,
+ )
+ return authed_client(config)
+
+
+def _wait(client, job_id, timeout=20.0):
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ job = client.get(f"/api/jobs/{job_id}").json()
+ if job["state"] in {"completed", "failed", "cancelled"}:
+ return job
+ time.sleep(0.05)
+ pytest.fail("job never finished")
+
+
+def test_the_repository_ships_no_model_and_the_plugin_is_unavailable(tmp_path):
+ plugins = registry(Settings(faceswap_cmd=""))
+ assert [plugin.name for plugin in plugins] == ["faceswap"]
+ faceswap = plugins[0]
+ assert faceswap.available is False
+ assert "no model and no runtime installed" in faceswap.reason
+ assert "non-commercial/research" in faceswap.notice
+
+
+def test_capabilities_shows_the_plugin_as_unavailable_with_a_reason(tmp_path):
+ with _client(tmp_path) as client:
+ plugin = client.get("/api/capabilities").json()["plugins"][0]
+ assert plugin["available"] is False
+ assert plugin["env_var"] == "H3_FACESWAP_CMD"
+ assert "set H3_FACESWAP_CMD" in plugin["reason"]
+
+
+def test_without_plugins_the_pipeline_is_exactly_what_it_was(tmp_path):
+ with _client(tmp_path) as client:
+ job = _wait(client, client.post("/api/jobs", json=JOB).json()["id"])
+ assert job["state"] == "completed"
+ assert (tmp_path / "data/jobs/1/out.mp4").read_text() == "raw video"
+
+
+def test_an_unavailable_plugin_cannot_be_requested(tmp_path):
+ with _client(tmp_path) as client:
+ job_id = client.post(
+ "/api/jobs", json={**JOB, "postprocess": ["faceswap"]}
+ ).json()["id"]
+ job = _wait(client, job_id)
+ assert job["state"] == "failed"
+ assert "unavailable" in job["error"]
+ # The generated video is kept: generation itself succeeded.
+ assert (tmp_path / "data/jobs/1/out.mp4").read_text() == "raw video"
+
+
+def test_an_installed_plugin_replaces_the_video(tmp_path):
+ command = _executable(tmp_path / "swapper", SWAPPER)
+ with _client(tmp_path, faceswap_cmd=str(command)) as client:
+ assert client.get("/api/capabilities").json()["plugins"][0]["available"] is True
+ job_id = client.post(
+ "/api/jobs", json={**JOB, "postprocess": ["faceswap"]}
+ ).json()["id"]
+ job = _wait(client, job_id)
+ assert job["state"] == "completed"
+ assert (tmp_path / "data/jobs/1/out.mp4").read_text() == "swapped(raw video)"
+
+
+def test_a_failing_plugin_fails_the_job_and_keeps_the_raw_video(tmp_path):
+ command = _executable(tmp_path / "swapper", BROKEN)
+ with _client(tmp_path, faceswap_cmd=str(command)) as client:
+ job_id = client.post(
+ "/api/jobs", json={**JOB, "postprocess": ["faceswap"]}
+ ).json()["id"]
+ job = _wait(client, job_id)
+ assert job["state"] == "failed"
+ assert job["error"] == (
+ "post-processing faceswap failed: faceswap: no model installed"
+ )
+ assert (tmp_path / "data/jobs/1/out.mp4").read_text() == "raw video"
+
+
+def test_an_unknown_plugin_name_is_refused(tmp_path):
+ with pytest.raises(PluginError, match="unknown post-processing plugin"):
+ run_stage(Settings(), tmp_path / "video.mp4", ["upscale"])
+
+
+# A plugin that leaves a grandchild behind: the timeout must take the whole
+# session down, not just the plugin (T106).
+SPAWNS_HELPER = r"""#!/bin/sh
+input=""; output=""
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --input) input="$2"; shift;;
+ --output) output="$2"; shift;;
+ esac
+ shift
+done
+sleep 30 &
+printf '%s' "$!" > "$(dirname "$0")/helper.pid"
+sleep 30
+"""
+
+
+def test_a_timed_out_plugin_takes_its_helpers_down_with_it(tmp_path):
+ import os
+
+ command = _executable(tmp_path / "spawner", SPAWNS_HELPER)
+ config = Settings(faceswap_cmd=str(command))
+ video = tmp_path / "video.mp4"
+ video.write_text("raw video")
+
+ with pytest.raises(PluginError, match="timed out"):
+ run_stage(config, video, ["faceswap"], timeout=1.0)
+
+ pid_file = tmp_path / "helper.pid"
+ deadline = time.time() + 5
+ while time.time() < deadline and not pid_file.is_file():
+ time.sleep(0.05)
+ assert pid_file.is_file(), "the plugin never spawned its helper"
+ helper_pid = int(pid_file.read_text())
+
+ deadline = time.time() + 10
+ while time.time() < deadline:
+ try:
+ os.kill(helper_pid, 0)
+ except OSError:
+ break
+ time.sleep(0.05)
+ else:
+ pytest.fail("the plugin's helper survived the timeout")
diff --git a/webui/backend/tests/test_preview.py b/webui/backend/tests/test_preview.py
new file mode 100644
index 00000000..7e574b4d
--- /dev/null
+++ b/webui/backend/tests/test_preview.py
@@ -0,0 +1,114 @@
+"""Live preview: the newest complete step reaches the browser as a JPEG."""
+
+import json
+import shutil
+import stat
+import time
+
+import pytest
+from conftest import authed_client
+
+from app.config import Settings
+
+JOB = {
+ "prompt": "a fox",
+ "width": 64,
+ "height": 64,
+ "frames": 22,
+ "steps": 3,
+ "preview": True,
+}
+
+# Writes one PPM per step into --preview-dir, the way h3 does.
+PREVIEWS = r"""#!/bin/sh
+dir=""
+out=""
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --preview-dir) dir="$2"; shift;;
+ -o) out="$2"; shift;;
+ esac
+ shift
+done
+step=0
+while [ $step -lt 3 ]; do
+ printf '\rdenoise %d/3 ' "$step" >&2
+ ffmpeg -y -loglevel error -f lavfi -i "color=c=blue:s=64x64:d=1" \
+ -frames:v 1 "$dir/.step.ppm"
+ mv "$dir/.step.ppm" "$(printf '%s/step-%04d.ppm' "$dir" "$step")"
+ step=$((step + 1))
+ sleep 0.15
+done
+printf '\rdenoise 3/3 \n' >&2
+[ -n "$out" ] && printf 'mp4' > "$out"
+exit 0
+"""
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("ffmpeg") is None, reason="FFmpeg is required for the fixture"
+)
+
+
+def _client(tmp_path):
+ binary = tmp_path / "h3"
+ binary.write_text(PREVIEWS)
+ binary.chmod(binary.stat().st_mode | stat.S_IEXEC)
+ config = Settings(binary=binary, model_dir=tmp_path, data_dir=tmp_path / "data")
+ return authed_client(config)
+
+
+def _wait(client, job_id, states, timeout=20.0):
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ job = client.get(f"/api/jobs/{job_id}").json()
+ if job["state"] in states:
+ return job
+ time.sleep(0.05)
+ pytest.fail("job never reached a terminal state")
+
+
+def test_the_newest_preview_is_served_as_a_jpeg(tmp_path):
+ with _client(tmp_path) as client:
+ job_id = client.post("/api/jobs", json=JOB).json()["id"]
+ job = _wait(client, job_id, {"completed", "failed"})
+ assert job["state"] == "completed"
+ assert job["preview_step"] == 2
+ response = client.get(f"/api/jobs/{job_id}/preview")
+ assert response.status_code == 200
+ assert response.headers["content-type"] == "image/jpeg"
+ assert response.content[:3] == b"\xff\xd8\xff"
+
+
+def test_the_stream_carries_the_preview_step(tmp_path):
+ with _client(tmp_path) as client:
+ job_id = client.post("/api/jobs", json=JOB).json()["id"]
+ with client.stream("GET", f"/api/jobs/{job_id}/events") as response:
+ body = "".join(response.iter_text())
+ steps = [
+ json.loads(line[6:])["preview_step"]
+ for line in body.splitlines()
+ if line.startswith("data: ")
+ ]
+ assert steps[0] is None
+ assert max(step for step in steps if step is not None) == 2
+
+
+def test_no_preview_before_the_first_step(tmp_path):
+ with _client(tmp_path) as client:
+ response = client.post("/api/jobs", json={**JOB, "preview": False})
+ job_id = response.json()["id"]
+ assert client.get(f"/api/jobs/{job_id}/preview").status_code == 404
+ _wait(client, job_id, {"completed", "failed"})
+ assert client.get(f"/api/jobs/{job_id}").json()["preview_step"] is None
+
+
+def test_a_partial_file_is_never_served(tmp_path):
+ """Only renamed step-*.ppm files are considered, never the staging name."""
+ from app.media import latest_preview
+
+ directory = tmp_path / "preview"
+ directory.mkdir()
+ (directory / ".step.ppm").write_bytes(b"partial")
+ assert latest_preview(directory) is None
+ (directory / "step-0007.ppm").write_bytes(b"whole")
+ assert latest_preview(directory)[0] == 7
diff --git a/webui/backend/tests/test_progress.py b/webui/backend/tests/test_progress.py
new file mode 100644
index 00000000..a37a8ab6
--- /dev/null
+++ b/webui/backend/tests/test_progress.py
@@ -0,0 +1,224 @@
+"""The weighted progress model: monotonic, scaled, and honest about gaps."""
+
+import pytest
+
+from app.config import Settings
+from app.jobspec import JobSpec
+from app.progress import ProgressModel, load_weights
+
+WEIGHTS = {
+ "reference": {"width": 256, "height": 256, "frames": 22, "steps": 8, "layers": 50},
+ "phase_seconds": {
+ "tokenizer": 2.0,
+ "text encoder": 16.0,
+ "load transformer core": 40.0,
+ "denoise": 8.0,
+ "video VAE load": 14.0,
+ },
+ "total_seconds": 80.0,
+}
+SPEC = JobSpec(prompt="x", width=256, height=256, frames=22, steps=8)
+
+
+@pytest.fixture
+def model():
+ return ProgressModel(WEIGHTS)
+
+
+def test_the_shipped_weights_load_and_cover_the_main_phases(model):
+ shipped = ProgressModel(load_weights(Settings().progress_weights_path))
+ phases = dict(shipped.plan(SPEC))
+ assert {"text encoder", "load transformer core", "denoise"} <= set(phases)
+ assert all(seconds > 0 for seconds in phases.values())
+
+
+def test_progress_never_goes_backwards_across_a_whole_run(model):
+ timeline = [
+ ("tokenizer", 0, 1), ("tokenizer", 1, 1),
+ ("text encoder", 0, 50), ("text encoder", 25, 50), ("text encoder", 50, 50),
+ ("load transformer core", 1, 50), ("load transformer core", 50, 50),
+ ("denoise", 0, 8), ("denoise", 4, 8), ("denoise", 8, 8),
+ ("video VAE load", 1, 36), ("video VAE load", 36, 36),
+ ]
+ seen = [model.fraction(SPEC, *point) for point in timeline]
+ assert seen == sorted(seen)
+ assert seen[0] == 0.0
+ assert seen[-1] == pytest.approx(1.0)
+
+
+def test_a_heavier_job_gives_denoise_a_bigger_share(model):
+ light = model.fraction(SPEC, "denoise", 4, 8)
+ heavy_spec = SPEC.model_copy(update={"steps": 50})
+ heavy = model.fraction(heavy_spec, "denoise", 25, 50)
+ # At the same relative point of denoising, more steps means the phases
+ # before it are worth proportionally less.
+ assert heavy < light
+
+
+def test_resolution_scales_the_denoise_and_decode_budget(model):
+ small = dict(model.plan(SPEC))
+ large = dict(model.plan(SPEC.model_copy(update={"width": 512, "height": 512})))
+ assert large["denoise"] == pytest.approx(small["denoise"] * 4)
+ assert large["video VAE load"] == pytest.approx(small["video VAE load"] * 4)
+ assert large["text encoder"] == small["text encoder"]
+
+
+def test_the_internal_canvas_is_what_counts_for_scaling(model):
+ spec = SPEC.model_copy(update={"width": 512, "height": 512,
+ "render_width": 256, "render_height": 256})
+ assert dict(model.plan(spec))["denoise"] == pytest.approx(
+ dict(model.plan(SPEC))["denoise"]
+ )
+
+
+def test_an_unknown_phase_reports_nothing_rather_than_guessing(model):
+ # The runner keeps the highest value seen, so 0 means "no news", not a
+ # regression. Reporting the sum of the known phases would claim 100%.
+ assert model.fraction(SPEC, "Qwen vision", 3, 10) == 0.0
+
+
+def test_missing_phases_in_the_weights_still_produce_a_full_bar():
+ sparse = ProgressModel(
+ {"reference": WEIGHTS["reference"], "phase_seconds": {"denoise": 1.0}}
+ )
+ assert sparse.fraction(SPEC, "denoise", 8, 8) == pytest.approx(1.0)
+ assert sparse.fraction(SPEC, "tokenizer", 1, 1) == 0.0
+
+
+def test_the_eta_corrects_itself_from_the_observed_pace(model):
+ # Half way through by weight, having taken 100 s: expect about 100 s left.
+ half = model.fraction(SPEC, "load transformer core", 33, 50)
+ assert 0.4 < half < 0.6
+ remaining = model.remaining_seconds(
+ SPEC, "load transformer core", 33, 50, elapsed=100.0
+ )
+ assert 60 < remaining < 160
+
+
+def test_the_eta_before_any_progress_falls_back_to_the_budget(model):
+ budget = sum(seconds for _, seconds in model.plan(SPEC))
+ assert model.remaining_seconds(SPEC, None, 0, 0, elapsed=0.0) == pytest.approx(
+ budget
+ )
+
+
+def test_enabling_the_preview_adds_a_load_phase(model):
+ plain = dict(model.plan(SPEC))
+ with_preview = dict(model.plan(SPEC.model_copy(update={"preview": True})))
+ assert "preview VAE load" not in plain
+ assert with_preview["preview VAE load"] > 0
+
+
+def test_the_stored_progress_only_ever_grows(tmp_path):
+ """End to end: the runner must not let the bar step back."""
+ import stat
+ import time
+
+ from conftest import authed_client
+
+ script = (
+ "#!/bin/sh\n"
+ "printf '\\rload transformer core 50/50 ' >&2\n"
+ "printf '\\rQwen vision 1/4 ' >&2\n"
+ "printf '\\rdenoise 1/2 ' >&2\n"
+ "printf '\\rdenoise 2/2 \\n' >&2\n"
+ "exit 0\n"
+ )
+ binary = tmp_path / "h3"
+ binary.write_text(script)
+ binary.chmod(binary.stat().st_mode | stat.S_IEXEC)
+ config = Settings(binary=binary, model_dir=tmp_path, data_dir=tmp_path / "data")
+ seen: list[float] = []
+ with authed_client(config) as client:
+ client.app.state.runner.add_listener(lambda job: seen.append(job["progress"]))
+ job_id = client.post(
+ "/api/jobs",
+ json={"prompt": "x", "width": 256, "height": 256, "frames": 22, "steps": 2},
+ ).json()["id"]
+ deadline = time.time() + 20
+ while time.time() < deadline:
+ if client.get(f"/api/jobs/{job_id}").json()["state"] not in (
+ "queued",
+ "running",
+ ):
+ break
+ time.sleep(0.05)
+ assert seen == sorted(seen)
+ assert seen[-1] == 1.0
+
+
+# ── fixed cost versus cost that scales ──────────────────────────────────────
+
+TWO_SAMPLES = {
+ "reference": {"width": 256, "height": 256, "frames": 22, "steps": 8, "layers": 50},
+ "phase_seconds": {"text encoder": 16.0, "denoise": 8.0, "video VAE load": 14.0},
+ "samples": [
+ {
+ "reference": {"width": 256, "height": 256, "frames": 22, "steps": 8},
+ "phase_seconds": {
+ "text encoder": 16.0, "denoise": 8.0, "video VAE load": 14.0
+ },
+ },
+ {
+ "reference": {"width": 512, "height": 512, "frames": 56, "steps": 20},
+ # text encoder does not depend on the video; denoise and the decoder do.
+ "phase_seconds": {
+ "text encoder": 16.4, "denoise": 130.0, "video VAE load": 40.0
+ },
+ },
+ ],
+}
+
+
+def test_a_second_sample_separates_fixed_cost_from_work():
+ model = ProgressModel(TWO_SAMPLES)
+ fixed, variable = model.fit["text encoder"]
+ assert variable == 0.0 and 15 < fixed < 17, model.fit["text encoder"]
+ assert model.fit["denoise"][1] > 0
+ assert model.fit["video VAE load"][1] > 0
+
+
+def test_with_one_sample_every_phase_still_scales():
+ model = ProgressModel(
+ {
+ "reference": TWO_SAMPLES["reference"],
+ "phase_seconds": TWO_SAMPLES["phase_seconds"],
+ }
+ )
+ assert model.fit == {}
+ assert dict(model.plan(SPEC))["text encoder"] == 16.0
+
+
+def test_the_quality_presets_do_not_all_cost_the_same():
+ model = ProgressModel(TWO_SAMPLES)
+ base = {"prompt": "x", "width": 512, "height": 512, "frames": 107}
+ quick = JobSpec(**base, steps=20, dit_layers=40, denoise_reuse=3)
+ balanced = JobSpec(
+ **base, steps=20, dit_layers=45, denoise_reuse=2, token_reduction=True
+ )
+ best = JobSpec(**base, steps=50, dit_layers=50, denoise_reuse=1)
+ times = [
+ sum(seconds for _, seconds in model.plan(spec))
+ for spec in (quick, balanced, best)
+ ]
+ assert times[0] < times[1] < times[2]
+ # A preset that skips work must be visibly cheaper, not a rounding away.
+ assert times[1] - times[0] > 20
+
+
+def test_reading_the_model_from_disk_costs_time_per_pass():
+ # The shipped weights carry the measured streaming penalty.
+ model = ProgressModel(load_weights(Settings().progress_weights_path))
+ base = {"prompt": "x", "width": 512, "height": 512, "frames": 107, "steps": 20}
+ resident = sum(s for _, s in model.plan(JobSpec(**base)))
+ streamed = sum(s for _, s in model.plan(JobSpec(**base, ssd_streaming=True)))
+ assert streamed > resident
+
+
+def test_the_internal_canvas_is_what_the_work_is_measured_on():
+ model = ProgressModel(TWO_SAMPLES)
+ full = JobSpec(prompt="x", width=512, height=512, frames=107, steps=20)
+ smaller = full.model_copy(update={"render_width": 256, "render_height": 256})
+ assert sum(s for _, s in model.plan(smaller)) < sum(
+ s for _, s in model.plan(full)
+ )
diff --git a/webui/backend/tests/test_runner.py b/webui/backend/tests/test_runner.py
new file mode 100644
index 00000000..8bba2e07
--- /dev/null
+++ b/webui/backend/tests/test_runner.py
@@ -0,0 +1,397 @@
+"""Queue and runner, driven by a stand-in for the h3 binary.
+
+The fake reproduces what matters about h3: progress written to stderr with a
+carriage return, an mp4 written where -o points, and h3-prefixed error lines.
+No GPU and no checkpoint are involved.
+"""
+
+import os
+import stat
+import time
+
+import pytest
+from conftest import authed_client
+
+from app.config import Settings
+
+JOB = {"prompt": "a fox", "width": 512, "height": 512, "frames": 22, "steps": 2}
+
+SUCCESS = r"""#!/bin/sh
+out=""
+while [ $# -gt 0 ]; do
+ case "$1" in -o) out="$2"; shift;; esac
+ shift
+done
+printf 'h3: starting\n' >&2
+printf '\rtext encoder %d/50 ' 50 >&2
+printf '\rdenoise 1/2 ' >&2
+printf '\rdenoise 2/2 ' >&2
+printf '\n' >&2
+[ -n "$out" ] && printf 'fake mp4' > "$out"
+exit 0
+"""
+
+FAILURE = r"""#!/bin/sh
+printf 'h3: canvas exceeds the released 768*1344 pixel limit\n' >&2
+exit 1
+"""
+
+SLOW = r"""#!/bin/sh
+trap 'exit 143' TERM
+printf '\rdenoise 1/900 ' >&2
+sleep 30 &
+wait $!
+"""
+
+
+def _binary(tmp_path, script):
+ path = tmp_path / "h3"
+ path.write_text(script)
+ path.chmod(path.stat().st_mode | stat.S_IEXEC)
+ return path
+
+
+def _client(tmp_path, script):
+ config = Settings(
+ binary=_binary(tmp_path, script),
+ model_dir=tmp_path,
+ data_dir=tmp_path / "data",
+ )
+ return authed_client(config)
+
+
+def _wait(client, job_id, states, timeout=20.0):
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ job = client.get(f"/api/jobs/{job_id}").json()
+ if job["state"] in states:
+ return job
+ time.sleep(0.05)
+ pytest.fail(f"job stayed in {job['state']}")
+
+
+def _wait_pid(client, job_id, timeout=20.0):
+ """The pid is recorded in the write right after the spawn (T105), so a
+ job can legitimately be 'running' with the pid still absent: wait it
+ out. The invariant the restart sweep relies on is that a running job
+ ends up with its process recorded, not that both flip in one write."""
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ pid = client.app.state.db.query_one(
+ "SELECT pid FROM jobs WHERE id = ?", (job_id,)
+ )["pid"]
+ if pid is not None:
+ return pid
+ time.sleep(0.05)
+ pytest.fail("the running job never recorded its pid")
+
+
+def test_a_job_runs_to_completion_and_keeps_its_artifacts(tmp_path):
+ with _client(tmp_path, SUCCESS) as client:
+ created = client.post("/api/jobs", json=JOB)
+ assert created.status_code == 201
+ job = _wait(client, created.json()["id"], {"completed", "failed"})
+ assert job["state"] == "completed"
+ assert job["progress"] == 1.0
+ assert (tmp_path / "data/jobs/1/out.mp4").read_text() == "fake mp4"
+ assert "h3: starting" in (tmp_path / "data/jobs/1/job.log").read_text()
+
+
+def test_progress_is_parsed_from_carriage_return_updates(tmp_path):
+ seen = []
+ with _client(tmp_path, SUCCESS) as client:
+ client.app.state.runner.add_listener(
+ lambda job: seen.append((job["phase"], job["completed"], job["total"]))
+ )
+ created = client.post("/api/jobs", json=JOB)
+ _wait(client, created.json()["id"], {"completed", "failed"})
+ assert ("text encoder", 50, 50) in seen
+ assert ("denoise", 1, 2) in seen
+ assert ("denoise", 2, 2) in seen
+
+
+def test_a_failing_run_keeps_the_h3_error_line(tmp_path):
+ with _client(tmp_path, FAILURE) as client:
+ created = client.post("/api/jobs", json=JOB)
+ job = _wait(client, created.json()["id"], {"completed", "failed"})
+ assert job["state"] == "failed"
+ assert job["error"] == "h3: canvas exceeds the released 768*1344 pixel limit"
+
+
+def test_a_running_job_can_be_cancelled(tmp_path):
+ with _client(tmp_path, SLOW) as client:
+ created = client.post("/api/jobs", json=JOB)
+ job_id = created.json()["id"]
+ _wait(client, job_id, {"running"})
+ cancelled = client.post(f"/api/jobs/{job_id}/cancel").json()
+ assert cancelled["state"] in {"cancelled", "running"}
+ job = _wait(client, job_id, {"cancelled", "failed", "completed"})
+ assert job["state"] == "cancelled"
+
+
+def test_a_queued_job_can_be_cancelled_before_it_starts(tmp_path):
+ with _client(tmp_path, SLOW) as client:
+ first = client.post("/api/jobs", json=JOB).json()["id"]
+ second = client.post("/api/jobs", json=JOB).json()["id"]
+ _wait(client, first, {"running"})
+ assert client.get(f"/api/jobs/{second}").json()["state"] == "queued"
+ cancelled = client.post(f"/api/jobs/{second}/cancel").json()
+ assert cancelled["state"] == "cancelled"
+ client.post(f"/api/jobs/{first}/cancel")
+ _wait(client, first, {"cancelled", "failed", "completed"})
+
+
+def test_jobs_run_one_at_a_time(tmp_path):
+ with _client(tmp_path, SLOW) as client:
+ ids = [client.post("/api/jobs", json=JOB).json()["id"] for _ in range(3)]
+ _wait(client, ids[0], {"running"})
+ states = [client.get(f"/api/jobs/{i}").json()["state"] for i in ids]
+ assert states.count("running") == 1
+ assert states.count("queued") == 2
+ for job_id in ids:
+ client.post(f"/api/jobs/{job_id}/cancel")
+ for job_id in ids:
+ _wait(client, job_id, {"cancelled", "failed", "completed"})
+
+
+def test_an_invalid_job_is_refused_before_it_is_queued(tmp_path):
+ with _client(tmp_path, SUCCESS) as client:
+ response = client.post("/api/jobs", json={**JOB, "width": 500})
+ assert response.status_code == 422
+ assert (
+ "width and height must be multiples of 32 and at least 32"
+ in response.json()["detail"]["errors"]
+ )
+ assert client.get("/api/jobs").json() == []
+
+
+def test_validate_endpoint_reports_the_resolved_duration(tmp_path):
+ with _client(tmp_path, SUCCESS) as client:
+ body = client.post(
+ "/api/jobs/validate", json={**JOB, "frames": None, "seconds": 10.0}
+ ).json()
+ assert body["errors"] == []
+ assert body["frames"] == 243
+ assert body["seconds"] == 10.125
+
+
+def test_the_recorded_argv_is_the_command_that_ran(tmp_path):
+ with _client(tmp_path, SUCCESS) as client:
+ created = client.post("/api/jobs", json=JOB)
+ job = _wait(client, created.json()["id"], {"completed", "failed"})
+ assert job["argv"][1:3] == ["-d", str(tmp_path)]
+ assert job["argv"][job["argv"].index("--frames") + 1] == "22"
+
+
+def test_missing_binary_fails_the_job_with_a_reason(tmp_path):
+ config = Settings(
+ binary=tmp_path / "absent", model_dir=tmp_path, data_dir=tmp_path / "data"
+ )
+ with authed_client(config) as client:
+ created = client.post("/api/jobs", json=JOB)
+ job = _wait(client, created.json()["id"], {"failed", "completed"})
+ assert job["state"] == "failed"
+ assert "cannot start h3" in job["error"]
+
+
+def test_unknown_job_is_a_404(tmp_path):
+ with _client(tmp_path, SUCCESS) as client:
+ assert client.get("/api/jobs/404").status_code == 404
+
+
+def test_a_job_interrupted_by_a_restart_is_marked_failed(tmp_path):
+ config = Settings(
+ binary=_binary(tmp_path, SUCCESS),
+ model_dir=tmp_path,
+ data_dir=tmp_path / "data",
+ )
+ with authed_client(config) as client:
+ created = client.post("/api/jobs", json=JOB)
+ _wait(client, created.json()["id"], {"completed", "failed"})
+ # Simulate a backend killed while a job was running.
+ client.app.state.db.run("UPDATE jobs SET state='running' WHERE id=1")
+ with authed_client(config) as client:
+ job = client.get("/api/jobs/1").json()
+ assert job["state"] == "failed"
+ assert job["error"] == "interrupted by a backend restart"
+
+
+def test_shutdown_cancels_the_running_job_instead_of_failing_it(tmp_path):
+ config = Settings(
+ binary=_binary(tmp_path, SLOW),
+ model_dir=tmp_path,
+ data_dir=tmp_path / "data",
+ )
+ with authed_client(config) as client:
+ job_id = client.post("/api/jobs", json=JOB).json()["id"]
+ _wait(client, job_id, {"running"})
+ with authed_client(config) as client:
+ job = client.get(f"/api/jobs/{job_id}").json()
+ assert job["state"] == "cancelled"
+
+
+def test_a_finished_job_can_be_deleted_with_everything_it_wrote(tmp_path):
+ with _client(tmp_path, SUCCESS) as client:
+ job_id = client.post("/api/jobs", json=JOB).json()["id"]
+ _wait(client, job_id, {"completed", "failed"})
+ directory = tmp_path / "data/jobs" / str(job_id)
+ assert (directory / "out.mp4").is_file()
+
+ assert client.delete(f"/api/jobs/{job_id}").status_code == 204
+
+ assert not directory.exists()
+ assert client.get(f"/api/jobs/{job_id}").status_code == 404
+ assert client.get(f"/api/jobs/{job_id}/video").status_code == 404
+ assert client.get(f"/api/jobs/{job_id}/log").status_code == 404
+ assert client.get("/api/jobs").json() == []
+
+
+def test_deleting_a_running_job_is_refused_and_keeps_its_files(tmp_path):
+ with _client(tmp_path, SLOW) as client:
+ job_id = client.post("/api/jobs", json=JOB).json()["id"]
+ _wait(client, job_id, {"running"})
+
+ # The row flips to running before h3 is even spawned, so the job's
+ # files are compared against themselves rather than against a name
+ # that may not have been written yet.
+ directory = tmp_path / "data/jobs" / str(job_id)
+ before = {entry.name for entry in directory.iterdir()}
+
+ refused = client.delete(f"/api/jobs/{job_id}")
+ assert refused.status_code == 409
+ assert refused.json()["detail"] == "stop this video before deleting it"
+ assert directory.is_dir()
+ assert before <= {entry.name for entry in directory.iterdir()}
+ assert client.get(f"/api/jobs/{job_id}").json()["state"] == "running"
+
+ client.post(f"/api/jobs/{job_id}/cancel")
+ _wait(client, job_id, {"cancelled", "failed", "completed"})
+
+
+def test_a_queued_job_is_removed_by_stopping_it_not_by_deleting_it(tmp_path):
+ with _client(tmp_path, SLOW) as client:
+ running = client.post("/api/jobs", json=JOB).json()["id"]
+ queued = client.post("/api/jobs", json=JOB).json()["id"]
+ _wait(client, running, {"running"})
+ assert client.get(f"/api/jobs/{queued}").json()["state"] == "queued"
+
+ assert client.delete(f"/api/jobs/{queued}").status_code == 409
+
+ client.post(f"/api/jobs/{queued}/cancel")
+ _wait(client, queued, {"cancelled", "failed", "completed"})
+ assert client.delete(f"/api/jobs/{queued}").status_code == 204
+ client.post(f"/api/jobs/{running}/cancel")
+ _wait(client, running, {"cancelled", "failed", "completed"})
+
+
+def test_deleting_an_unknown_job_is_a_404(tmp_path):
+ with _client(tmp_path, SUCCESS) as client:
+ assert client.delete("/api/jobs/404").status_code == 404
+
+
+def test_the_event_stream_of_a_deleted_job_ends_instead_of_hanging(tmp_path):
+ with _client(tmp_path, SUCCESS) as client:
+ job_id = client.post("/api/jobs", json=JOB).json()["id"]
+ _wait(client, job_id, {"completed", "failed"})
+ client.delete(f"/api/jobs/{job_id}")
+
+ # R30: an unknown job is a 404 on every endpoint, the stream too —
+ # a foreign id and a deleted one are indistinguishable on purpose.
+ assert client.get(f"/api/jobs/{job_id}/events").status_code == 404
+
+
+def test_a_running_job_records_its_process(tmp_path):
+ with _client(tmp_path, SLOW) as client:
+ job_id = client.post("/api/jobs", json=JOB).json()["id"]
+ _wait(client, job_id, {"running"})
+ pid = _wait_pid(client, job_id)
+ assert pid > 0
+ client.post(f"/api/jobs/{job_id}/cancel")
+ _wait(client, job_id, {"cancelled", "failed"})
+
+
+def test_a_restart_stops_a_live_h3_before_failing_its_job(tmp_path):
+ config = Settings(
+ binary=_binary(tmp_path, SLOW),
+ model_dir=tmp_path,
+ data_dir=tmp_path / "data",
+ )
+ with authed_client(config) as client:
+ job_id = client.post("/api/jobs", json=JOB).json()["id"]
+ _wait(client, job_id, {"running"})
+ pid = _wait_pid(client, job_id)
+
+ # A crash, not a shutdown: the first backend gets no graceful stop,
+ # and a second startup sweeps over the job while h3 is still alive.
+ second_config = Settings(
+ binary=tmp_path / "h3", model_dir=tmp_path, data_dir=tmp_path / "data"
+ )
+ with authed_client(second_config) as second:
+ job = second.get(f"/api/jobs/{job_id}").json()
+ assert job["state"] == "failed"
+
+ deadline = time.time() + 10
+ while time.time() < deadline:
+ try:
+ os.kill(pid, 0)
+ except OSError:
+ break
+ time.sleep(0.05)
+ else:
+ pytest.fail("the live h3 survived the restart sweep")
+
+
+def test_the_restart_sweep_names_a_live_process_it_stopped(tmp_path):
+ import subprocess
+
+ from app.db import Database
+
+ config = Settings(
+ binary=tmp_path / "absent", model_dir=tmp_path, data_dir=tmp_path / "data"
+ )
+ child = subprocess.Popen(
+ ["sleep", "30"], stdout=subprocess.DEVNULL, start_new_session=True
+ )
+ database = Database(config.data_dir / "h3.sqlite3")
+ database.run(
+ "INSERT INTO jobs (state, prompt, params, pid)"
+ " VALUES ('running', 'x', '{}', ?)",
+ (child.pid,),
+ )
+ database.close()
+
+ with authed_client(config) as client:
+ job = client.get("/api/jobs/1").json()
+ assert job["state"] == "failed"
+ assert "still running" in (job["error"] or "")
+
+ deadline = time.time() + 10
+ while time.time() < deadline:
+ if child.poll() is not None:
+ break
+ time.sleep(0.05)
+ assert child.poll() is not None, "the process survived the sweep"
+
+
+def test_the_restart_sweep_lets_a_dead_process_go(tmp_path):
+ import subprocess
+
+ from app.db import Database
+
+ config = Settings(
+ binary=tmp_path / "absent", model_dir=tmp_path, data_dir=tmp_path / "data"
+ )
+ child = subprocess.Popen(["true"], stdout=subprocess.DEVNULL)
+ child.wait()
+ database = Database(config.data_dir / "h3.sqlite3")
+ database.run(
+ "INSERT INTO jobs (state, prompt, params, pid)"
+ " VALUES ('running', 'x', '{}', ?)",
+ (child.pid,),
+ )
+ database.close()
+
+ with authed_client(config) as client:
+ job = client.get("/api/jobs/1").json()
+ assert job["state"] == "failed"
+ assert job["error"] == "interrupted by a backend restart"
diff --git a/webui/backend/tests/test_schema_matches_cli.py b/webui/backend/tests/test_schema_matches_cli.py
new file mode 100644
index 00000000..0fc786ec
--- /dev/null
+++ b/webui/backend/tests/test_schema_matches_cli.py
@@ -0,0 +1,125 @@
+"""Fail if webui/shared/options.schema.json drifts from the h3.c CLI.
+
+The schema is the single source of truth for the web UI. It is hand-maintained,
+so this test re-reads the C sources and compares what can be compared
+mechanically: the set of long options, the short options, and the numeric
+constants and defaults the UI depends on.
+"""
+
+import json
+import re
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[3]
+SCHEMA = json.loads((ROOT / "webui/shared/options.schema.json").read_text())
+
+
+def _cli_long_options() -> set[str]:
+ source = (ROOT / "main.c").read_text()
+ block = re.search(
+ r"static const struct option options\[\] = \{(.*?)\n \};",
+ source,
+ re.DOTALL,
+ )
+ assert block, "option table not found in main.c"
+ return {name for name in re.findall(r'\{"([a-z0-9-]+)",', block.group(1))}
+
+
+def _cli_short_options() -> set[str]:
+ source = (ROOT / "main.c").read_text()
+ spec = re.search(r'getopt_long\(argc, argv, "([^"]+)"', source)
+ assert spec, "getopt_long spec not found in main.c"
+ return {c for c in spec.group(1) if c.isalnum()}
+
+
+def _c_text(name: str) -> str:
+ """Source with adjacent C string literals joined, so split messages match."""
+ return re.sub(r'"\s*"', "", (ROOT / name).read_text())
+
+
+def _define(header: str, name: str) -> str:
+ source = (ROOT / header).read_text()
+ match = re.search(rf"^#define {name}\s+(.+)$", source, re.MULTILINE)
+ assert match, f"{name} not found in {header}"
+ return match.group(1).strip()
+
+
+def test_every_cli_long_option_is_in_the_schema():
+ assert _cli_long_options() == {o["flag"][2:] for o in SCHEMA["options"]}
+
+
+def test_short_options_match():
+ schema_short = {o["short"][1:] for o in SCHEMA["options"] if "short" in o}
+ assert schema_short == _cli_short_options()
+
+
+def test_keys_and_flags_are_unique():
+ flags = [o["flag"] for o in SCHEMA["options"]]
+ keys = [o["key"] for o in SCHEMA["options"]]
+ assert len(flags) == len(set(flags))
+ assert len(keys) == len(set(keys))
+
+
+def test_every_option_declares_a_group_that_exists():
+ groups = {g["id"] for g in SCHEMA["groups"]}
+ assert {o["group"] for o in SCHEMA["options"]} <= groups
+ assert {o["ui"] for o in SCHEMA["options"]} <= {"simple", "advanced", "hidden"}
+
+
+def test_constants_match_the_c_headers():
+ constants = SCHEMA["constants"]
+ assert constants["fps"] == int(_define("h3_host.h", "H3_FPS"))
+ assert constants["canvas_multiple"] == int(
+ _define("h3_host.h", "H3_CANVAS_MULTIPLE")
+ )
+ assert constants["max_steps"] == int(_define("h3_host.h", "H3_MAX_STEPS"))
+ assert constants["max_pixels"] == eval(_define("h3_host.h", "H3_MAX_PIXELS"))
+ assert constants["dit_layers"]["min"] == int(
+ _define("h3.h", "H3_MIN_DIT_LAYERS")
+ )
+ assert constants["dit_layers"]["max"] == int(
+ _define("h3.h", "H3_DEFAULT_DIT_LAYERS")
+ )
+
+
+def test_defaults_match_h3_header():
+ by_key = {o["key"]: o for o in SCHEMA["options"]}
+ assert by_key["width"]["default"] == int(_define("h3.h", "H3_DEFAULT_WIDTH"))
+ assert by_key["height"]["default"] == int(_define("h3.h", "H3_DEFAULT_HEIGHT"))
+ assert by_key["frames"]["default"] == int(_define("h3.h", "H3_DEFAULT_FRAMES"))
+ assert by_key["steps"]["default"] == int(_define("h3.h", "H3_DEFAULT_STEPS"))
+ assert by_key["dit_layers"]["default"] == int(
+ _define("h3.h", "H3_DEFAULT_DIT_LAYERS")
+ )
+
+
+def test_frame_alignment_matches_h3_host():
+ source = (ROOT / "h3_host.c").read_text()
+ body = re.search(
+ r"int h3_align_frame_count\(int requested\) \{(.*?)\n\}", source, re.DOTALL
+ )
+ assert body, "h3_align_frame_count not found"
+ frames = SCHEMA["constants"]["frames"]
+ assert f"< {frames['align_base']} ?" in body.group(1)
+ assert f"% {frames['align_stride']}" in body.group(1)
+
+
+def test_validation_messages_are_quoted_from_h3_c():
+ source = _c_text("h3.c")
+ for entry in SCHEMA["constraints"]:
+ assert entry["message"] in source, entry["id"]
+ for entry in SCHEMA["mutual_exclusions"]:
+ if entry.get("source") == "h3.c":
+ assert entry["message"] in source, entry["message"]
+
+
+def test_reference_limits_match_h3_c():
+ source = _c_text("h3.c")
+ refs = SCHEMA["references"]
+ assert "Ref2VA supports at most 12 references" in source
+ assert refs["max_total"] == 12
+ assert (
+ f"Ref2VA limits are {refs['max_images']} images, "
+ f"{refs['max_videos']} videos, and {refs['max_audio_inputs']} audio inputs"
+ in source
+ )
diff --git a/webui/backend/tests/test_users_db.py b/webui/backend/tests/test_users_db.py
new file mode 100644
index 00000000..9868e9ce
--- /dev/null
+++ b/webui/backend/tests/test_users_db.py
@@ -0,0 +1,151 @@
+"""T120 (R30): versioned schema, user tables, and password hashing.
+
+The point of this task is that an existing database — the one with the
+user's takes in it — must survive the migration byte for byte, and that a
+fresh install starts at the latest version.
+"""
+
+import sqlite3
+
+import pytest
+
+from app.auth import hash_password, verify_password
+from app.db import LATEST_VERSION, Database
+
+OLD_SCHEMA = """
+CREATE TABLE jobs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ state TEXT NOT NULL DEFAULT 'queued',
+ prompt TEXT NOT NULL DEFAULT '',
+ params TEXT NOT NULL,
+ argv TEXT,
+ phase TEXT,
+ completed INTEGER NOT NULL DEFAULT 0,
+ total INTEGER NOT NULL DEFAULT 0,
+ progress REAL NOT NULL DEFAULT 0.0,
+ error TEXT,
+ output_path TEXT,
+ log_path TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ started_at TEXT,
+ finished_at TEXT
+);
+CREATE TABLE assets (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ sha256 TEXT NOT NULL UNIQUE,
+ kind TEXT NOT NULL,
+ filename TEXT NOT NULL,
+ path TEXT NOT NULL,
+ bytes INTEGER NOT NULL,
+ metadata TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+"""
+
+
+def _columns(path, table):
+ connection = sqlite3.connect(path)
+ names = [row[1] for row in connection.execute(f"PRAGMA table_info({table})")]
+ connection.close()
+ return names
+
+
+def _tables(path):
+ connection = sqlite3.connect(path)
+ names = {
+ row[0]
+ for row in connection.execute(
+ "SELECT name FROM sqlite_master WHERE type = 'table'"
+ )
+ }
+ connection.close()
+ return names
+
+
+def _seed_old_database(path):
+ """A database as it existed before R30: version 1, real rows inside."""
+ connection = sqlite3.connect(path)
+ connection.executescript(OLD_SCHEMA)
+ connection.execute(
+ "INSERT INTO jobs (state, prompt, params) VALUES ('completed', 'p', '{}')"
+ )
+ connection.execute(
+ "INSERT INTO assets (sha256, kind, filename, path, bytes)"
+ " VALUES ('a' * 64, 'image', 'f.png', '/data/a/f.png', 10)"
+ )
+ connection.commit()
+ connection.close()
+
+
+def test_a_fresh_database_starts_at_the_latest_version(tmp_path):
+ database = Database(tmp_path / "db.sqlite")
+ assert database.schema_version() == LATEST_VERSION
+ tables = _tables(tmp_path / "db.sqlite")
+ assert {"jobs", "assets", "users", "sessions", "invites"} <= tables
+ assert "owner" in _columns(tmp_path / "db.sqlite", "jobs")
+ assert "owner" in _columns(tmp_path / "db.sqlite", "assets")
+ database.close()
+
+
+def test_an_existing_database_migrates_without_losing_rows(tmp_path):
+ path = tmp_path / "db.sqlite"
+ _seed_old_database(path)
+
+ database = Database(path)
+ assert database.schema_version() == LATEST_VERSION
+
+ jobs = database.query_all("SELECT * FROM jobs")
+ assets = database.query_all("SELECT * FROM assets")
+ assert len(jobs) == 1 and jobs[0]["prompt"] == "p"
+ assert len(assets) == 1 and assets[0]["filename"] == "f.png"
+ # Old rows are ownerless until an admin exists (backfill is a later task).
+ assert jobs[0]["owner"] is None and assets[0]["owner"] is None
+ database.close()
+
+
+def test_migration_is_idempotent(tmp_path):
+ path = tmp_path / "db.sqlite"
+ _seed_old_database(path)
+ Database(path).close()
+ reopened = Database(path)
+ assert reopened.schema_version() == LATEST_VERSION
+ assert len(reopened.query_all("SELECT * FROM jobs")) == 1
+ reopened.close()
+
+
+def test_session_rows_cascade_with_their_user(tmp_path):
+ database = Database(tmp_path / "db.sqlite")
+ user_id = database.run(
+ "INSERT INTO users (username, password_hash) VALUES (?, ?)",
+ ("admin", hash_password("secret")),
+ )
+ database.run(
+ "INSERT INTO sessions (token, user_id, expires_at)"
+ " VALUES ('t', ?, datetime('now', '+7 days'))",
+ (user_id,),
+ )
+ database.run("DELETE FROM users WHERE id = ?", (user_id,))
+ assert database.query_one("SELECT * FROM sessions WHERE token = 't'") is None
+ database.close()
+
+
+def test_only_admin_and_user_roles_exist(tmp_path):
+ database = Database(tmp_path / "db.sqlite")
+ with pytest.raises(sqlite3.IntegrityError):
+ database.run(
+ "INSERT INTO users (username, password_hash, role)"
+ " VALUES ('x', 'h', 'superuser')"
+ )
+ database.close()
+
+
+def test_password_hash_roundtrip():
+ stored = hash_password("a long enough password")
+ assert stored.startswith("$argon2id$")
+ assert verify_password(stored, "a long enough password") is True
+ assert verify_password(stored, "the wrong password") is False
+
+
+def test_a_malformed_hash_is_never_a_match():
+ assert verify_password("not a hash", "whatever") is False
+ assert verify_password("", "whatever") is False
diff --git a/webui/backend/tools/calibrate_progress.py b/webui/backend/tools/calibrate_progress.py
new file mode 100644
index 00000000..03ca5270
--- /dev/null
+++ b/webui/backend/tools/calibrate_progress.py
@@ -0,0 +1,125 @@
+"""Measure how long each h3 phase takes, to weight the progress bar.
+
+Usage:
+ webui/backend/.venv/bin/python webui/backend/tools/calibrate_progress.py \
+ --model-dir ./MiniMax-H3 [h3 options...]
+
+Prints a JSON summary and, with --write, updates
+webui/shared/progress_weights.json. Re-run it when the hardware changes: a
+progress bar calibrated on another machine is a guess, not a measurement.
+"""
+
+import argparse
+import json
+import re
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[3]
+PROGRESS = re.compile(r"^(?P\S.*?)\s{2,}(?P\d+)/(?P\d+)\s*$")
+
+
+def measure(argv: list[str]) -> dict[str, float]:
+ started = time.monotonic()
+ marks: dict[str, float] = {}
+ order: list[str] = []
+ current: str | None = None
+ process = subprocess.Popen(argv, stderr=subprocess.PIPE, text=True)
+ buffer = ""
+ assert process.stderr is not None
+ while True:
+ chunk = process.stderr.read(1)
+ if not chunk:
+ break
+ if chunk in "\r\n":
+ line, buffer = buffer, ""
+ match = PROGRESS.match(line.strip())
+ if match and match["phase"].strip() != current:
+ current = match["phase"].strip()
+ if current not in marks:
+ marks[current] = time.monotonic()
+ order.append(current)
+ print(f" {current}", file=sys.stderr)
+ else:
+ buffer += chunk
+ process.wait()
+ ended = time.monotonic()
+ durations: dict[str, float] = {}
+ for index, phase in enumerate(order):
+ following = marks[order[index + 1]] if index + 1 < len(order) else ended
+ durations[phase] = round(following - marks[phase], 3)
+ durations["_total"] = round(ended - started, 3)
+ durations["_returncode"] = float(process.returncode)
+ return durations
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--binary", default=str(ROOT / "h3"))
+ parser.add_argument("--model-dir", default=str(ROOT / "MiniMax-H3"))
+ parser.add_argument("--prompt", default="A bright red cube on a white background.")
+ parser.add_argument("--width", type=int, default=256)
+ parser.add_argument("--height", type=int, default=256)
+ parser.add_argument("--frames", type=int, default=22)
+ parser.add_argument("--steps", type=int, default=8)
+ parser.add_argument("--layers", type=int, default=50)
+ parser.add_argument("--output", default="/dev/null")
+ parser.add_argument("--write", action="store_true")
+ parser.add_argument(
+ "--append",
+ action="store_true",
+ help="add this run as another sample instead of replacing the file, so "
+ "fixed and variable cost can be told apart",
+ )
+ args = parser.parse_args()
+
+ argv = [
+ args.binary, "-d", args.model_dir, "-p", args.prompt,
+ "--width", str(args.width), "--height", str(args.height),
+ "--frames", str(args.frames), "--steps", str(args.steps),
+ "--layers", str(args.layers), "--seed", "42", "-o", args.output,
+ ]
+ durations = measure(argv)
+ report = {
+ "reference": {
+ "width": args.width,
+ "height": args.height,
+ "frames": args.frames,
+ "steps": args.steps,
+ "layers": args.layers,
+ },
+ "phase_seconds": {k: v for k, v in durations.items() if not k.startswith("_")},
+ "total_seconds": durations["_total"],
+ }
+ print(json.dumps(report, indent=2))
+ if args.write:
+ target = ROOT / "webui/shared/progress_weights.json"
+ existing = json.loads(target.read_text()) if target.exists() else {}
+ sample = {
+ "reference": report["reference"],
+ "phase_seconds": report["phase_seconds"],
+ "total_seconds": report["total_seconds"],
+ }
+ if args.append:
+ samples = existing.get("samples", [])
+ if not samples and "phase_seconds" in existing:
+ samples = [
+ {
+ "reference": existing["reference"],
+ "phase_seconds": existing["phase_seconds"],
+ "total_seconds": existing["total_seconds"],
+ }
+ ]
+ samples.append(sample)
+ existing["samples"] = samples
+ else:
+ existing = {**existing, **sample, "samples": [sample]}
+ target.write_text(json.dumps(existing, indent=2) + "\n")
+ print(f"wrote {target}", file=sys.stderr)
+ return int(durations["_returncode"])
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/webui/frontend/index.html b/webui/frontend/index.html
new file mode 100644
index 00000000..729a3fa4
--- /dev/null
+++ b/webui/frontend/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ h3c studio
+
+
+
+
+
+
diff --git a/webui/frontend/package-lock.json b/webui/frontend/package-lock.json
new file mode 100644
index 00000000..d68fe98e
--- /dev/null
+++ b/webui/frontend/package-lock.json
@@ -0,0 +1,1889 @@
+{
+ "name": "h3c-studio",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "h3c-studio",
+ "version": "0.1.0",
+ "dependencies": {
+ "@fontsource/instrument-sans": "^5.3.0",
+ "@fontsource/instrument-serif": "^5.3.0",
+ "@fontsource/martian-mono": "^5.3.0",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0"
+ },
+ "devDependencies": {
+ "@types/node": "^26.3.0",
+ "@types/react": "^19.2.0",
+ "@types/react-dom": "^19.2.0",
+ "@vitejs/plugin-react": "^5.0.0",
+ "typescript": "^5.9.0",
+ "vite": "^7.1.0"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
+ "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@babel/types": "^7.29.8",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
+ "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.8"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
+ "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.8",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.8",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.8",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
+ "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
+ "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
+ "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
+ "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
+ "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
+ "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
+ "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
+ "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
+ "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
+ "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
+ "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
+ "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
+ "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
+ "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
+ "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
+ "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
+ "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
+ "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
+ "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
+ "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
+ "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@fontsource/instrument-sans": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@fontsource/instrument-sans/-/instrument-sans-5.3.0.tgz",
+ "integrity": "sha512-QwXc4hb/px3XvSPS2CPAOgey8nyrQFxgxrmXQQ+pN+P51hKdAcchxpg8rSbjANFfPu6VKXhDqkVDucXMZ9CM5g==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
+ "node_modules/@fontsource/instrument-serif": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@fontsource/instrument-serif/-/instrument-serif-5.3.0.tgz",
+ "integrity": "sha512-mDiaIg0u67sYV59fie92Wz4sM8UiVlbL7fLxnFPCKkX15DMASEnQTREbaP5S5/3DCcsoAOcQ0sV9E4AeY4QqYQ==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
+ "node_modules/@fontsource/martian-mono": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@fontsource/martian-mono/-/martian-mono-5.3.0.tgz",
+ "integrity": "sha512-0iTnU6SZi6j5z4ymwlZGep6akvp/xWm+r+WyZO9hMfDJWX+dsFLipQp9zO02S0lkUQ+zAahcZNy3XLiNP+gVqQ==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
+ "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.63.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.0.tgz",
+ "integrity": "sha512-70TeIFezKKy65LgAVyQh+w94/gjWhvPWaLaGGeMEgVrPkQhuj/M5bAYYZzIFUj9Y69oHyTm5Um/R6gcLh4A8JA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.63.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.0.tgz",
+ "integrity": "sha512-YC86tYIHK6M1IV+wbzO+Bxk8RCBr6ZyWYgWxUCzaZD8mc8rrFoIJDNzDrkHBYRc/wKdrsIXmm6/F7NzrAO+OrA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.63.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.0.tgz",
+ "integrity": "sha512-oI+ECtUcli0y0fi4xpW82GdPIXdTkI8G8DSjG2LRuw09fPAGykaWYH/hXxiKuTxiAjiPSTIIuYUqof5Z2hShWw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.63.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.0.tgz",
+ "integrity": "sha512-NwV+1s7TiKrMe4owHyKB/dTLD7ZJD0YEBEhIz+hvav1Cu1GReJjF+rsdNwjzENQeIAbE/CoNiaAc5Vz2h5DPAA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.63.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.0.tgz",
+ "integrity": "sha512-tWtHBTu5gOPK4u4Urtk4qAHW3zZ9rQAmbssO8gp7ELvGTGI3aCiq6NqyTQ0PCIg7KbHJF2UkGDDs77YZGxfjCA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.63.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.0.tgz",
+ "integrity": "sha512-2qPoJiwTvtHQ27NnYvTnsgk8laXWYuVmNESG8WFZBcEPKLfZ3I27qBJarjVRQtwGeYyRfq5ZowHXih9lm2BItw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.63.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.0.tgz",
+ "integrity": "sha512-FQwsTRvLNuHoTdICABJQfbPUSEueISGmnpT06tXTMpfprf5NiKLSXKA0A+w45wJnCmZAnzgqBwbt6ARFuyOi5w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.63.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.0.tgz",
+ "integrity": "sha512-BBVTXziw8mY1a4ZbWME9tZyfzqXCDPqaC7Z3heQ29p5dkvXzwL0NwelO8zLa8c3RBKvl3YTuSnBgsBhYBtwjIw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.63.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.0.tgz",
+ "integrity": "sha512-w2Iyy9+RqKwx3d9qWMKsJg0FfRBsY0/pXNv0mCQ3ueRvJI6+QAScfD4nrMlzFLs2HNVW6Ew+mtZfDl9b7Ew5/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.63.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.0.tgz",
+ "integrity": "sha512-YK++KtrFRHYE0P6/RtYEAy9t8F37znP+K03RrIuLPYOL6SVlObRumf/0OE4V/h63xL9DwkWbNssZfmA9hawuDA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.63.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.0.tgz",
+ "integrity": "sha512-aBfOG6fP7YkkPmTqPwufRJeFyz7WPpECv9XNbnsk9+vg7rxdih0lbtEel7jcRng4LZrrmU3FfitCFyEj4BWDWg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.63.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.0.tgz",
+ "integrity": "sha512-LGaHEOeHNAag9VuS1Crs5DFg4RrU9MPi2nVnNJk9DTePx/B6RRYKVmrIXt2h7YOJlwjaFJ6lwtFDliZxScTLrQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.63.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.0.tgz",
+ "integrity": "sha512-jClvk+J0FC3b7Udvegiw5/4hErbHtmsNsQgENnKXDWtNCJXsJYZH5WURvu7imDOO38xYml24eeh5x3A04ppwCw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.63.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.0.tgz",
+ "integrity": "sha512-0OJlaGK+8+B777Ql5okIpD7ua5Ro9+VB9Ve0OKa28OQJZ1RbuUBVNHK/e3pr4BROqsyPl1JrPO1ZxJseCNffcA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.63.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.0.tgz",
+ "integrity": "sha512-Ygsx+HoNH7afwi1bTIXbnTvVnsO+zurPLSYxybV1hHFVU72OWOCl6v05ql/z0hkpAPx+DK7Kn9Bi7MayCcjLTA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.63.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.0.tgz",
+ "integrity": "sha512-pDQxtMGb+OvG3fLwR2OkZlSd47hW+kWg4BYMG/++sR6RqorQccwPTDsxda5hPwiIeIErAnCF9ma3SAU06bdQtQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.63.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.0.tgz",
+ "integrity": "sha512-0BnUG9mS8I4SSHr3XsxVhuCMEiu+rX61xxZF5vujso4LaiAGFZFxvDjg6Xn6tLPNTUAfuCvQYas4LMQMVsKRSQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.63.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.0.tgz",
+ "integrity": "sha512-Adu/VttB1dpPNW+FEacrZ+xVm9tFty84+RrFzsqlFaPxoJB+9XXyDGtp5dCOoBwGBIEVH0To7lExFXEx0BIF4A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.63.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.0.tgz",
+ "integrity": "sha512-NQ3bDvjUbFKmP23671xUlXtKmqVsUBd6M4PQCvbmNtOy06hnQIdKHy8oG/6S3R/S6He1JgPk6A5VT+prAJMYEw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.63.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.0.tgz",
+ "integrity": "sha512-u2eDAl4+0aFvA13GxlGBtTI3SS3sdgwgtV0HyjZ0QaQVCgNE+jqNGey+GtxWiq+wxr/UycAx/OnfJzApCFamvA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.63.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.0.tgz",
+ "integrity": "sha512-XvRb5vfW3wAZQ+ZUG21AnHHDKtNcw99eigzEhjr//NZ3u7SoBaPP0seSc7FgP7p1epAEdAoZckMW9WY/+4w70w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.63.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.0.tgz",
+ "integrity": "sha512-iZPmniy4kNBf5yo2RezbkYNNK5HPbXE9+g+twnbqSng7dtLEJy1SKoxiE/ni4FDacjyuZpEeb9U054N4EoKHYw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.63.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.0.tgz",
+ "integrity": "sha512-mFBBd+LF37fnE8JnYUOH+imj0aPFPK30vpar4ehJkgnLj9sZn8ZxiRENmLtgIwxK7TC8klF6N57fxdNBwQoqOA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.63.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.0.tgz",
+ "integrity": "sha512-ujeqEY3B+zbGn3Z4Q03cUBG/LGWnBJncVT36WER31LcOsQk9+1dmINKKtvmmfChUvRbK1G0R8OhMWFgHgaZtAw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.63.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.0.tgz",
+ "integrity": "sha512-hncn90N4sOky0L2LKE5oESKLbxCPeVo4eLA2LSMoDzM+879ml4WSr+Rr4DWknNIVVvS1Hirkc9hx02W6YxS8rQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "26.3.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.3.0.tgz",
+ "integrity": "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.3.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.18",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
+ "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.5",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz",
+ "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
+ "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.29.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-rc.3",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.18.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.11.19",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.19.tgz",
+ "integrity": "sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.8",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
+ "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.11.12",
+ "caniuse-lite": "^1.0.30001809",
+ "electron-to-chromium": "^1.5.402",
+ "node-releases": "^2.0.53",
+ "update-browserslist-db": "^1.3.0"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001810",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
+ "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.414",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.414.tgz",
+ "integrity": "sha512-aYlviXiaXBbzvKgyALpcMmqa3Np3sDr0XnZbEG62n2UpZFbEcjQ4EEMOLGzVPhwVnwTz0lvKY+GcARbunuHekw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
+ "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.2",
+ "@esbuild/android-arm": "0.28.2",
+ "@esbuild/android-arm64": "0.28.2",
+ "@esbuild/android-x64": "0.28.2",
+ "@esbuild/darwin-arm64": "0.28.2",
+ "@esbuild/darwin-x64": "0.28.2",
+ "@esbuild/freebsd-arm64": "0.28.2",
+ "@esbuild/freebsd-x64": "0.28.2",
+ "@esbuild/linux-arm": "0.28.2",
+ "@esbuild/linux-arm64": "0.28.2",
+ "@esbuild/linux-ia32": "0.28.2",
+ "@esbuild/linux-loong64": "0.28.2",
+ "@esbuild/linux-mips64el": "0.28.2",
+ "@esbuild/linux-ppc64": "0.28.2",
+ "@esbuild/linux-riscv64": "0.28.2",
+ "@esbuild/linux-s390x": "0.28.2",
+ "@esbuild/linux-x64": "0.28.2",
+ "@esbuild/netbsd-arm64": "0.28.2",
+ "@esbuild/netbsd-x64": "0.28.2",
+ "@esbuild/openbsd-arm64": "0.28.2",
+ "@esbuild/openbsd-x64": "0.28.2",
+ "@esbuild/openharmony-arm64": "0.28.2",
+ "@esbuild/sunos-x64": "0.28.2",
+ "@esbuild/win32-arm64": "0.28.2",
+ "@esbuild/win32-ia32": "0.28.2",
+ "@esbuild/win32-x64": "0.28.2"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.18",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
+ "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.53",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz",
+ "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.26",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.17",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
+ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
+ "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.8"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.18.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
+ "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.63.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.0.tgz",
+ "integrity": "sha512-T5vnZ2y4QqC3/4P+w2+JO+Q/OVdnPsv4XcSYJYMEn0R9/jjl5AgLwO9LAZMzP2lN71O6pypn91rB7lDstUkfrQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.63.0",
+ "@rollup/rollup-android-arm64": "4.63.0",
+ "@rollup/rollup-darwin-arm64": "4.63.0",
+ "@rollup/rollup-darwin-x64": "4.63.0",
+ "@rollup/rollup-freebsd-arm64": "4.63.0",
+ "@rollup/rollup-freebsd-x64": "4.63.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.63.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.63.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.63.0",
+ "@rollup/rollup-linux-arm64-musl": "4.63.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.63.0",
+ "@rollup/rollup-linux-loong64-musl": "4.63.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.63.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.63.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.63.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.63.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.63.0",
+ "@rollup/rollup-linux-x64-gnu": "4.63.0",
+ "@rollup/rollup-linux-x64-musl": "4.63.0",
+ "@rollup/rollup-openbsd-x64": "4.63.0",
+ "@rollup/rollup-openharmony-arm64": "4.63.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.63.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.63.0",
+ "@rollup/rollup-win32-x64-gnu": "4.63.0",
+ "@rollup/rollup-win32-x64-msvc": "4.63.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz",
+ "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.3.6",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
+ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ }
+ }
+}
diff --git a/webui/frontend/package.json b/webui/frontend/package.json
new file mode 100644
index 00000000..3039f1b6
--- /dev/null
+++ b/webui/frontend/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "h3c-studio",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc --noEmit && vite build",
+ "preview": "vite preview",
+ "lint": "eslint src"
+ },
+ "dependencies": {
+ "@fontsource/instrument-sans": "^5.3.0",
+ "@fontsource/instrument-serif": "^5.3.0",
+ "@fontsource/martian-mono": "^5.3.0",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0"
+ },
+ "devDependencies": {
+ "@types/node": "^26.3.0",
+ "@types/react": "^19.2.0",
+ "@types/react-dom": "^19.2.0",
+ "@vitejs/plugin-react": "^5.0.0",
+ "typescript": "^5.9.0",
+ "vite": "^7.1.0"
+ }
+}
diff --git a/webui/frontend/public/favicon.svg b/webui/frontend/public/favicon.svg
new file mode 100644
index 00000000..121f938a
--- /dev/null
+++ b/webui/frontend/public/favicon.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
diff --git a/webui/frontend/scripts/generate-options.mjs b/webui/frontend/scripts/generate-options.mjs
new file mode 100644
index 00000000..d31b55df
--- /dev/null
+++ b/webui/frontend/scripts/generate-options.mjs
@@ -0,0 +1,63 @@
+// Generates src/generated/options.ts from webui/shared/options.schema.json.
+// The schema is the single source of truth: never edit the generated file.
+import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const schemaPath = resolve(here, "../../shared/options.schema.json");
+const target = resolve(here, "../src/generated/options.ts");
+const schema = JSON.parse(readFileSync(schemaPath, "utf8"));
+
+const header = `// GENERATED by scripts/generate-options.mjs from
+// webui/shared/options.schema.json. Do not edit by hand.
+`;
+
+const body = `
+export type OptionUi = "simple" | "advanced" | "hidden";
+
+export interface OptionSpec {
+ flag: string;
+ key: string;
+ type: string;
+ group: string;
+ ui: OptionUi;
+ label?: string;
+ help?: string;
+ role?: string;
+ default?: unknown;
+ min?: number;
+ max?: number;
+ multiple_of?: number;
+ values?: string[];
+ backends?: string[];
+ short?: string;
+ asset_kind?: string;
+ reference_kind?: string;
+ repeatable?: boolean;
+ required?: boolean;
+ arity?: number;
+}
+
+export const OPTIONS: OptionSpec[] = ${JSON.stringify(schema.options, null, 2)};
+
+export const CONSTANTS = ${JSON.stringify(schema.constants, null, 2)} as const;
+
+export const CANVAS_PRESETS = ${JSON.stringify(schema.canvas_presets, null, 2)};
+
+export const QUALITY_PRESETS = ${JSON.stringify(schema.quality_presets, null, 2)};
+
+export const REFERENCE_RULES = ${JSON.stringify(schema.references, null, 2)};
+
+export const SLOWER_FLAGS: string[] = OPTIONS.filter(
+ (option) => option.group === "parity",
+).map((option) => option.flag.slice(2));
+
+export function optionByKey(key: string): OptionSpec | undefined {
+ return OPTIONS.find((option) => option.key === key);
+}
+`;
+
+mkdirSync(dirname(target), { recursive: true });
+writeFileSync(target, header + body);
+console.log(`generated ${target} from ${schema.options.length} options`);
diff --git a/webui/frontend/src/App.tsx b/webui/frontend/src/App.tsx
new file mode 100644
index 00000000..4dcbcc16
--- /dev/null
+++ b/webui/frontend/src/App.tsx
@@ -0,0 +1,463 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import type { DragEvent } from "react";
+
+import { ApiError, api, watchJob } from "./api";
+import { AuthScreen } from "./components/AuthScreen";
+import { Create, SHAPES } from "./components/Create";
+import { DeleteControl } from "./components/DeleteControl";
+import { Expert } from "./components/Expert";
+import { FineTune } from "./components/FineTune";
+import { LiveStrip } from "./components/LiveStrip";
+import { LogoMark } from "./components/Logo";
+import { People } from "./components/People";
+import { References } from "./components/References";
+import { RenderStage } from "./components/RenderStage";
+import { Takes, Waiting } from "./components/Takes";
+import { explain, humanMinutes } from "./copy";
+import { QUALITY_PRESETS } from "./generated/options";
+import { DEFAULT_SPEC, applyQualityPreset } from "./spec";
+import type {
+ Asset,
+ Job,
+ JobSpec,
+ Plugin,
+ SystemInfo,
+ User,
+ ValidationReport,
+} from "./types";
+import { useEstimates } from "./useEstimates";
+
+export function App() {
+ const [spec, setSpec] = useState(DEFAULT_SPEC);
+ const [system, setSystem] = useState(null);
+ const [plugins, setPlugins] = useState([]);
+ const [assets, setAssets] = useState([]);
+ const [jobs, setJobs] = useState([]);
+ const [report, setReport] = useState(null);
+ const [refused, setRefused] = useState(null);
+ const [undeleted, setUndeleted] = useState(null);
+ const [dropNote, setDropNote] = useState(null);
+ // Who is at the screen. Undefined while the session is being checked,
+ // null when there is none: the page becomes the door (R30).
+ const [me, setMe] = useState(undefined);
+ // Which job is on stage. Null means composing: a job keeps running either way.
+ const [staged, setStaged] = useState(null);
+ const [sheet, setSheet] = useState<{ title: string; body: string; job?: Job } | null>(null);
+ // One panel, three tabs. Null means it is closed, which is how it opens.
+ const [panel, setPanel] = useState<"picture" | "references" | "expert" | null>(
+ null,
+ );
+ // People is a place of its own (T130), not a tab inside "Everything else".
+ const [view, setView] = useState<"studio" | "people">("studio");
+ const streams = useRef(new Map void>());
+
+ const refresh = useCallback(async () => {
+ try {
+ const [list, library] = await Promise.all([api.jobs(), api.assets()]);
+ setJobs(list);
+ setAssets(library);
+ } catch (failure) {
+ if (failure instanceof ApiError && failure.status === 401) setMe(null);
+ }
+ }, []);
+
+ useEffect(() => {
+ api.me()
+ .then(setMe)
+ .catch(() => setMe(null));
+ }, []);
+
+ useEffect(() => {
+ if (me === null || me === undefined) return;
+ /* Fetch on mount. The state updates land in promise callbacks, not in the
+ * effect body, so there is no cascading render to avoid here. */
+ /* eslint-disable react-hooks/set-state-in-effect */
+ api.system().then(setSystem).catch(() => setSystem(null));
+ api.capabilities().then((c) => setPlugins(c.plugins ?? [])).catch(() => setPlugins([]));
+ void refresh();
+ /* eslint-enable react-hooks/set-state-in-effect */
+ }, [me, refresh]);
+
+ // Follow every unfinished job; each stream closes itself when the job ends.
+ useEffect(() => {
+ for (const job of jobs) {
+ const live = job.state === "queued" || job.state === "running";
+ if (live && !streams.current.has(job.id)) {
+ const stop = watchJob(job.id, (update) => {
+ setJobs((current) =>
+ current.map((entry) => (entry.id === update.id ? update : entry)),
+ );
+ if (update.state !== "queued" && update.state !== "running") {
+ streams.current.get(update.id)?.();
+ streams.current.delete(update.id);
+ }
+ });
+ streams.current.set(job.id, stop);
+ }
+ }
+ }, [jobs]);
+
+ // Server-side validation, so the browser refuses exactly what h3 refuses.
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ api.validate(spec).then(setReport).catch(() => setReport(null));
+ }, 200);
+ return () => clearTimeout(timer);
+ }, [spec]);
+
+ const variants = useMemo(
+ () => [
+ ...SHAPES.map((shape) => ({ width: shape.width, height: shape.height })),
+ ...QUALITY_PRESETS.map((preset) => {
+ const applied = applyQualityPreset(spec, preset.id);
+ return {
+ steps: applied.steps,
+ dit_layers: applied.dit_layers,
+ denoise_reuse: applied.denoise_reuse,
+ core_reuse: applied.core_reuse,
+ token_reduction: applied.token_reduction,
+ render_width: applied.render_width,
+ render_height: applied.render_height,
+ };
+ }),
+ { steps: spec.steps + 10 },
+ { dit_layers: Math.max(35, spec.dit_layers - 5) },
+ { denoise_reuse: Math.min(3, spec.denoise_reuse + 1), core_reuse: 1 },
+ { render_width: 384, render_height: Math.round((384 * spec.height) / spec.width / 32) * 32 },
+ ],
+ [spec],
+ );
+ const estimates = useEstimates(spec, variants);
+
+ const running = jobs.find((job) => job.state === "running") ?? null;
+ const onStage = staged !== null ? (jobs.find((job) => job.id === staged) ?? null) : null;
+ const blocking = (report?.errors ?? []).filter(
+ (message) => message !== "a prompt is required",
+ );
+
+ async function make() {
+ setRefused(null);
+ try {
+ const job = await api.submit(spec);
+ setJobs((current) => [job, ...current]);
+ setStaged(job.id);
+ } catch (failure) {
+ if (failure instanceof ApiError && failure.status === 401) {
+ setMe(null);
+ return;
+ }
+ setRefused(failure instanceof ApiError ? failure.errors : ["The request failed."]);
+ }
+ }
+
+ async function signOut() {
+ await api.logout();
+ setMe(null);
+ }
+
+ async function remove(id: number) {
+ setUndeleted(null);
+ try {
+ await api.remove(id);
+ setJobs((current) => current.filter((entry) => entry.id !== id));
+ if (staged === id) setStaged(null);
+ } catch (failure) {
+ setUndeleted(
+ failure instanceof ApiError
+ ? String(failure.message)
+ : "That video could not be deleted.",
+ );
+ }
+ }
+
+ async function cancel(id: number) {
+ const job = await api.cancel(id);
+ setJobs((current) => current.map((entry) => (entry.id === id ? job : entry)));
+ }
+
+ async function showLog(job: Job) {
+ const text = await fetch(api.logUrl(job.id)).then((response) =>
+ response.ok ? response.text() : "No log was written for this job.",
+ );
+ setSheet({ title: `Job ${job.id}`, body: text, job });
+ }
+
+ /* R29 P10: files dropped anywhere on the page land in the library, so
+ * material is one drag away from any job. */
+ async function acceptDrop(event: DragEvent) {
+ event.preventDefault();
+ const files = Array.from(event.dataTransfer.files);
+ if (files.length === 0) return;
+ const added: Asset[] = [];
+ const refusedNames: string[] = [];
+ for (const file of files) {
+ try {
+ added.push(await api.upload(file));
+ } catch {
+ refusedNames.push(file.name);
+ }
+ }
+ if (added.length > 0) {
+ setAssets((current) => [...added, ...current]);
+ }
+ setDropNote(
+ refusedNames.length === 0
+ ? `Added to your library: ${added.map((asset) => asset.filename).join(", ")}.`
+ : `Could not add: ${refusedNames.join(", ")}.`,
+ );
+ }
+
+ const device = system?.device ?? {};
+ const problems = [...blocking, ...(refused ?? [])];
+
+ if (me === undefined) {
+ return One moment…
;
+ }
+ if (me === null) {
+ return ;
+ }
+
+ return (
+ event.preventDefault()}
+ onDrop={(event) => void acceptDrop(event)}
+ >
+
+
+
+ h3c studio
+
+
+
+ {system?.available
+ ? `${device.name ?? "GPU"} · ${
+ running ? "making a video" : "ready"
+ }`
+ : (system?.reason ?? "looking for the engine…")}
+
+
+ {/* R32: the administration has its own door, visible to the admin
+ alone, instead of hiding inside "Everything else". */}
+ {me.role === "admin" ? (
+ setView(view === "people" ? "studio" : "people")}
+ >
+ People
+
+ ) : null}
+ {me.username}
+ void signOut()}>sign out
+
+
+
+
+ {view === "people" && me.role === "admin" ? (
+
+
+
People
+ setView("studio")}>
+ ← back to the studio
+
+
+ {/* A video keeps being made while the admin is here. */}
+ {running ? (
+
{
+ setView("studio");
+ setStaged(running.id);
+ }}
+ onStop={() => void cancel(running.id)}
+ />
+ ) : null}
+
+
+ ) : (
+
+ {onStage ? (
+ <>
+
setStaged(null)}
+ onStop={() => {
+ if (onStage.state === "running") void cancel(onStage.id);
+ setStaged(null);
+ }}
+ />
+ {onStage.state !== "running" ? (
+
+
setStaged(null)}>
+ Make another
+
+
+
+ Download this one
+
+ {onStage.state === "completed" ? (
+ remove(onStage.id)}
+ />
+ ) : null}
+
+
+ ) : null}
+ >
+ ) : (
+ <>
+ {running ? (
+ setStaged(running.id)}
+ onStop={() => void cancel(running.id)}
+ />
+ ) : null}
+ setAssets((current) => [asset, ...current])}
+ onOpenReferences={() => setPanel("references")}
+ />
+
+ {problems.length > 0 ? (
+
+ {explain(problems[0]).title} {explain(problems[0]).fix}
+ setSheet({ title: "What h3 reported", body: problems.join("\n") })}>
+ what h3 reported
+
+
+ ) : null}
+ {report && report.warnings.length > 0 ? (
+ {report.warnings.join(" ")}
+ ) : null}
+ {dropNote ? (
+
+ {dropNote}
+
+ ) : null}
+
+
+ 0 || !system?.available}
+ onClick={() => void make()}
+ >
+ {running ? "Add to the queue" : "Make the video"}
+ {/* R29 P9: the wait rides on the button itself. */}
+ {estimates.seconds !== null ? (
+ ≈ {humanMinutes(estimates.seconds)}
+ ) : null}
+
+ setPanel((open) => (open === null ? "picture" : null))}
+ >
+ Everything else {panel === null ? "↓" : "↑"}
+
+
+
+ void cancel(id)} onLog={showLog} />
+
+ {panel !== null ? (
+
+ Everything else
+
+ {([
+ ["picture", "Picture"],
+ ["references", `Reference material${spec.references.length ? ` (${spec.references.length})` : ""}`],
+ ["expert", "Expert"],
+ ] as const).map(([id, label]) => (
+ setPanel(id)}
+ >
+ {label}
+
+ ))}
+
+ {panel === "picture" ? (
+
+ ) : null}
+ {panel === "references" ? (
+ setAssets((current) => [asset, ...current])}
+ />
+ ) : null}
+ {panel === "expert" ? (
+
+ ) : null}
+
+ ) : null}
+
+ >
+ )}
+
+ )}
+
+
+ {view === "people" ? null : (
+
setStaged(job.id)}
+ onDelete={(job) => void remove(job.id)}
+ />
+ )}
+ {undeleted ? (
+
+ {undeleted}
+
+ ) : null}
+
+ {sheet ? (
+ setSheet(null)}>
+
event.stopPropagation()}>
+
+ {sheet.title}
+ setSheet(null)}>
+ close
+
+
+
+ {sheet.job?.error ? (
+
+
{explain(sheet.job.error).title}
+
{explain(sheet.job.error).fix}
+
+ ) : null}
+
{sheet.body}
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/webui/frontend/src/api.ts b/webui/frontend/src/api.ts
new file mode 100644
index 00000000..49310ea4
--- /dev/null
+++ b/webui/frontend/src/api.ts
@@ -0,0 +1,121 @@
+import type {
+ Asset,
+ Capabilities,
+ Invite,
+ Job,
+ JobSpec,
+ SystemInfo,
+ User,
+ ValidationReport,
+} from "./types";
+
+async function request(path: string, init?: RequestInit): Promise {
+ const response = await fetch(path, {
+ headers: init?.body ? { "Content-Type": "application/json" } : undefined,
+ ...init,
+ });
+ if (!response.ok) {
+ const detail = await response.json().catch(() => null);
+ throw new ApiError(response.status, detail?.detail ?? response.statusText);
+ }
+ return (await response.json()) as T;
+}
+
+export class ApiError extends Error {
+ constructor(
+ readonly status: number,
+ readonly detail: unknown,
+ ) {
+ super(typeof detail === "string" ? detail : "request failed");
+ }
+
+ /** Validation failures arrive as { errors: [...] }. */
+ get errors(): string[] {
+ const detail = this.detail as { errors?: string[] } | string;
+ if (typeof detail === "object" && Array.isArray(detail?.errors)) {
+ return detail.errors;
+ }
+ return [String(this.message)];
+ }
+}
+
+export const api = {
+ system: () => request("/api/system"),
+ capabilities: () => request("/api/capabilities"),
+ jobs: () => request("/api/jobs"),
+ job: (id: number) => request(`/api/jobs/${id}`),
+ validate: (spec: JobSpec) =>
+ request("/api/jobs/validate", {
+ method: "POST",
+ body: JSON.stringify(spec),
+ }),
+ submit: (spec: JobSpec) =>
+ request("/api/jobs", { method: "POST", body: JSON.stringify(spec) }),
+ cancel: (id: number) =>
+ request(`/api/jobs/${id}/cancel`, { method: "POST" }),
+ /** Deletes the job and everything it wrote. Answers 204, with no body. */
+ remove: async (id: number): Promise => {
+ const response = await fetch(`/api/jobs/${id}`, { method: "DELETE" });
+ if (!response.ok) {
+ const detail = await response.json().catch(() => null);
+ throw new ApiError(response.status, detail?.detail ?? response.statusText);
+ }
+ },
+ assets: () => request("/api/assets"),
+ upload: async (file: File): Promise => {
+ const body = new FormData();
+ body.append("file", file);
+ const response = await fetch("/api/assets", { method: "POST", body });
+ if (!response.ok) {
+ const detail = await response.json().catch(() => null);
+ throw new ApiError(response.status, detail?.detail ?? response.statusText);
+ }
+ return (await response.json()) as Asset;
+ },
+ videoUrl: (id: number) => `/api/jobs/${id}/video`,
+ posterUrl: (id: number) => `/api/jobs/${id}/poster`,
+ logUrl: (id: number) => `/api/jobs/${id}/log`,
+ previewUrl: (id: number, step: number | null) =>
+ `/api/jobs/${id}/preview?step=${step ?? 0}`,
+ assetUrl: (id: number) => `/api/assets/${id}/file`,
+
+ me: () => request("/api/auth/me"),
+ login: (username: string, password: string) =>
+ request("/api/auth/login", {
+ method: "POST",
+ body: JSON.stringify({ username, password }),
+ }),
+ register: (username: string, password: string, invite: string) =>
+ request("/api/auth/register", {
+ method: "POST",
+ body: JSON.stringify({ username, password, invite: invite || null }),
+ }),
+ logout: async (): Promise => {
+ await fetch("/api/auth/logout", { method: "POST" });
+ },
+ users: () => request("/api/users"),
+ invites: () => request("/api/invites"),
+ createInvite: () => request<{ code: string }>("/api/invites", { method: "POST" }),
+ deleteUser: async (id: number): Promise => {
+ const response = await fetch(`/api/users/${id}`, { method: "DELETE" });
+ if (!response.ok) {
+ const detail = await response.json().catch(() => null);
+ throw new ApiError(response.status, detail?.detail ?? response.statusText);
+ }
+ },
+ resetPassword: (id: number, password: string) =>
+ request(`/api/users/${id}/password`, {
+ method: "POST",
+ body: JSON.stringify({ password }),
+ }),
+};
+
+/** Subscribe to one job's progress; returns an unsubscribe function. */
+export function watchJob(id: number, onJob: (job: Job) => void): () => void {
+ const source = new EventSource(`/api/jobs/${id}/events`);
+ source.addEventListener("job", (event) => {
+ onJob(JSON.parse((event as MessageEvent).data) as Job);
+ });
+ source.addEventListener("error", () => source.close());
+ return () => source.close();
+}
diff --git a/webui/frontend/src/components/AuthScreen.tsx b/webui/frontend/src/components/AuthScreen.tsx
new file mode 100644
index 00000000..538af57b
--- /dev/null
+++ b/webui/frontend/src/components/AuthScreen.tsx
@@ -0,0 +1,116 @@
+import { useState } from "react";
+
+import { ApiError, api } from "../api";
+import { LogoMark } from "./Logo";
+import type { User } from "../types";
+
+/** The door: one column, two states, the same look as the rest of the page.
+ *
+ * The first person here makes their own account and becomes the
+ * administrator; everyone after that needs an invite, and the server is the
+ * one that says so — the field is simply left empty for the first account.
+ */
+export function AuthScreen({ onSignedIn }: { onSignedIn: (user: User) => void }) {
+ const [mode, setMode] = useState<"login" | "register">("login");
+ const [username, setUsername] = useState("");
+ const [password, setPassword] = useState("");
+ const [invite, setInvite] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [problem, setProblem] = useState(null);
+
+ async function submit() {
+ setBusy(true);
+ setProblem(null);
+ try {
+ if (mode === "register") {
+ await api.register(username.trim(), password, invite.trim());
+ // Registering makes the account but not the session: walk in.
+ const user = await api.login(username.trim(), password);
+ onSignedIn(user);
+ } else {
+ const user = await api.login(username.trim(), password);
+ onSignedIn(user);
+ }
+ } catch (failure) {
+ setProblem(
+ failure instanceof ApiError ? failure.errors.join(" ") : "The request failed.",
+ );
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
+
+
+
+
{mode === "login" ? "Welcome back" : "Make your account"}
+
+
+ Username
+ setUsername(event.target.value)}
+ />
+
+
+ Password
+ setPassword(event.target.value)}
+ />
+
+ {mode === "register" ? (
+
+
+ Invite from the administrator
+
+ setInvite(event.target.value)}
+ />
+
+ ) : null}
+
+ {problem ? (
+
+ {problem}
+
+ ) : null}
+
+
+ void submit()}
+ >
+ {busy ? "One moment…" : mode === "login" ? "Sign in" : "Register"}
+
+
+
+
+ {mode === "login" ? (
+ <>
+ First time here?{" "}
+ setMode("register")}>Make an account
+ >
+ ) : (
+ <>
+ Already have an account?{" "}
+ setMode("login")}>Sign in
+ >
+ )}
+
+
+ The administrator account is defined on the server; every other
+ account is made with a single-use invite.
+
+
+
+ );
+}
diff --git a/webui/frontend/src/components/Create.tsx b/webui/frontend/src/components/Create.tsx
new file mode 100644
index 00000000..2891a801
--- /dev/null
+++ b/webui/frontend/src/components/Create.tsx
@@ -0,0 +1,342 @@
+import { useState } from "react";
+
+import { api } from "../api";
+import { CONSTANTS, QUALITY_PRESETS } from "../generated/options";
+import { humanMinutes } from "../copy";
+import {
+ DEFAULT_SPEC,
+ applyQualityPreset,
+ matchingPreset,
+ resolvedFrames,
+ resolvedSeconds,
+} from "../spec";
+import type { Asset, JobSpec, ReferenceKind } from "../types";
+import { PhotoSlot } from "./PhotoSlot";
+
+interface Props {
+ spec: JobSpec;
+ assets: Asset[];
+ shapeSeconds: (number | null)[];
+ qualitySeconds: (number | null)[];
+ totalSeconds: number | null;
+ onChange: (spec: JobSpec) => void;
+ onUploaded: (asset: Asset) => void;
+ onOpenReferences: () => void;
+}
+
+export const SHAPES = [
+ { name: "Widescreen", width: 864, height: 480 },
+ { name: "Square", width: 512, height: 512 },
+ { name: "Vertical", width: 480, height: 864 },
+];
+
+const EXAMPLES = [
+ "A red fox walks through fresh snow in a pine forest, tracking shot.",
+ "A surfer riding inside a sharp blue ocean wave, realistic spray.",
+ "A bright red cube rotates smoothly on a white background.",
+];
+
+const PRESET_NAMES: Record = {
+ draft: "a quick look",
+ balanced: "balanced",
+ reference: "the best it can",
+};
+
+/* R29 P1: what a reference does, in the person's words. */
+const REF_NAMES: Record = {
+ image: "a photo to keep",
+ video: "a clip to keep",
+ silent_video: "a silent clip to keep",
+ video_audio: "a clip with its sound",
+ audio: "a sound to keep",
+};
+
+type Open = "length" | "shape" | "quality" | "variation" | "photos" | null;
+
+/** One line of direction, and four words that can be changed.
+ *
+ * Everything a person needs to make a video is on this screen, and none of it
+ * is a form: the choices read as a sentence about the shot, and each one
+ * opens where it stands.
+ */
+export function Create(props: Props) {
+ const { spec, assets, shapeSeconds, qualitySeconds, totalSeconds } = props;
+ const { onChange, onUploaded, onOpenReferences } = props;
+ const [open, setOpen] = useState(null);
+ const requested = spec.seconds ?? resolvedSeconds(spec);
+ const preset = matchingPreset(spec);
+ const shape = SHAPES.find((s) => s.width === spec.width && s.height === spec.height);
+ // Nothing chosen yet is not the same as a choice of your own: on a first
+ // visit the quality is simply the one the engine comes with.
+ const untouched =
+ spec.steps === DEFAULT_SPEC.steps &&
+ spec.dit_layers === DEFAULT_SPEC.dit_layers &&
+ spec.denoise_reuse === DEFAULT_SPEC.denoise_reuse &&
+ spec.core_reuse === DEFAULT_SPEC.core_reuse &&
+ spec.token_reduction === DEFAULT_SPEC.token_reduction &&
+ spec.render_width === 0;
+ const anchored = spec.first_frame !== null || spec.last_frame !== null;
+ const anchorsBlocked = spec.references.length > 0;
+ const assetOf = (path: string | null) =>
+ path === null ? undefined : assets.find((asset) => asset.path === path);
+
+ const toggle = (which: Open) => setOpen((current) => (current === which ? null : which));
+ const word = (which: Open, text: string) => (
+ toggle(which)}>
+ {text}
+
+ );
+
+ return (
+ <>
+
+
+ {/* R29 P1: attachments hang off the prompt, not in a room of their
+ own. A chip opens its picker where it stands; the ✕ lets go. */}
+
+ {spec.first_frame !== null ? (
+
toggle("photos")}>
+ {assetOf(spec.first_frame) ? (
+
+ ) : null}
+ starts from this photo
+ {
+ event.stopPropagation();
+ onChange({ ...spec, first_frame: null });
+ }}
+ >
+ ✕
+
+
+ ) : null}
+ {spec.last_frame !== null ? (
+
toggle("photos")}>
+ {assetOf(spec.last_frame) ? (
+
+ ) : null}
+ ends on this photo
+ {
+ event.stopPropagation();
+ onChange({ ...spec, last_frame: null });
+ }}
+ >
+ ✕
+
+
+ ) : null}
+ {spec.references.map((reference, index) => (
+
+ {assetOf(reference.path) ? (
+
+ ) : null}
+ {REF_NAMES[reference.kind]}
+ {
+ event.stopPropagation();
+ onChange({
+ ...spec,
+ references: spec.references.filter((_, i) => i !== index),
+ });
+ }}
+ >
+ ✕
+
+
+ ))}
+
+ + a photo, clip or sound
+
+
+
+
+ {word("length", `${resolvedSeconds(spec).toFixed(1)} s`)}
+ ·
+ {word("shape", shape ? shape.name.toLowerCase() : `${spec.width}×${spec.height}`)}
+ ·
+ {word(
+ "quality",
+ preset !== null
+ ? PRESET_NAMES[preset]
+ : untouched
+ ? "the settings it comes with"
+ : "your own settings",
+ )}
+ ·
+ {word("variation", `variation ${spec.seed}`)}
+
+ {totalSeconds === null ? (
+ "working out the wait…"
+ ) : (
+ <>
+ about {humanMinutes(totalSeconds)}
+ >
+ )}
+
+
+
+ {open === "length" ? (
+
+
+ onChange({ ...spec, seconds: Number(event.target.value), frames: null })
+ }
+ />
+
+ Videos come in fixed lengths. The nearest to {requested.toFixed(1)} s is{" "}
+ {resolvedSeconds(spec).toFixed(1)} s — {resolvedFrames(spec)} frames at{" "}
+ {CONSTANTS.fps} fps.
+
+
+ ) : null}
+
+ {open === "shape" ? (
+
+ {SHAPES.map((entry, index) => (
+
+ onChange({
+ ...spec,
+ width: entry.width,
+ height: entry.height,
+ render_width: 0,
+ render_height: 0,
+ })
+ }
+ >
+ {entry.name}
+
+ {entry.width}×{entry.height} ·{" "}
+ {shapeSeconds[index] == null ? "…" : humanMinutes(shapeSeconds[index])}
+
+
+ ))}
+
+ ) : null}
+
+ {open === "quality" ? (
+
+ {QUALITY_PRESETS.map((entry, index) => (
+ onChange(applyQualityPreset(spec, entry.id))}
+ >
+ {PRESET_NAMES[entry.id] ?? entry.label}
+
+ {qualitySeconds[index] == null ? "…" : humanMinutes(qualitySeconds[index])}
+
+
+ ))}
+
+ ) : null}
+
+ {open === "variation" ? (
+
+
+ {spec.seed}
+ onChange({ ...spec, seed: Math.floor(Math.random() * 100000) })}
+ >
+ Try another
+
+
+
Same words and same variation give the same video, every time.
+
+ ) : null}
+
+ {open === "photos" ? (
+
+
onChange({ ...spec, first_frame: asset?.path ?? null })}
+ onUploaded={onUploaded}
+ />
+ onChange({ ...spec, last_frame: asset?.path ?? null })}
+ onUploaded={onUploaded}
+ />
+ {anchorsBlocked ? (
+
+ Not available while you are using reference material: they are two
+ different ways to work.
+
+ ) : null}
+
+ ) : null}
+
+
+ or start from
+ toggle("photos")}>
+ {anchored ? "the photos you chose" : "a photo"}
+
+
+ {spec.references.length > 0
+ ? `${spec.references.length} reference${spec.references.length === 1 ? "" : "s"}`
+ : "reference material"}
+
+
+ {/* R29 P10: one line says the whole page accepts files. */}
+
+ Drop a photo, clip or sound anywhere on this page to add it to your library.
+
+
+ {spec.prompt.trim() === "" ? (
+
+ Try:
+ {EXAMPLES.map((example) => (
+ onChange({ ...spec, prompt: example })}
+ >
+ {example.split(" ").slice(0, 4).join(" ").replace(/[,.]$/, "")}
+
+ ))}
+
+ ) : null}
+ >
+ );
+}
diff --git a/webui/frontend/src/components/DeleteControl.tsx b/webui/frontend/src/components/DeleteControl.tsx
new file mode 100644
index 00000000..930b6e14
--- /dev/null
+++ b/webui/frontend/src/components/DeleteControl.tsx
@@ -0,0 +1,32 @@
+import { useState } from "react";
+
+/** Deleting a video is final — no bin, no undo — so it asks once first.
+ *
+ * The question replaces the button in place rather than opening a dialog:
+ * the answer stays where the eye already is, on the take being deleted.
+ */
+export function DeleteControl({ label, onDelete }: {
+ label: string;
+ onDelete: () => void | Promise;
+}) {
+ const [asking, setAsking] = useState(false);
+
+ if (!asking) {
+ return (
+ setAsking(true)}>
+ {label}
+
+ );
+ }
+ return (
+
+ Delete for good?
+ void onDelete()}>
+ Yes
+
+ setAsking(false)}>
+ Keep
+
+
+ );
+}
diff --git a/webui/frontend/src/components/Expert.tsx b/webui/frontend/src/components/Expert.tsx
new file mode 100644
index 00000000..e97cdcbb
--- /dev/null
+++ b/webui/frontend/src/components/Expert.tsx
@@ -0,0 +1,251 @@
+import { CONSTANTS, OPTIONS } from "../generated/options";
+import { ALL_SLOWER_FLAGS } from "../spec";
+import type { JobSpec, Plugin, SystemInfo } from "../types";
+
+interface Props {
+ spec: JobSpec;
+ system: SystemInfo | null;
+ plugins: Plugin[];
+ onChange: (spec: JobSpec) => void;
+}
+
+function Flag(props: {
+ checked: boolean;
+ onChange: (value: boolean) => void;
+ title: string;
+ flag: string;
+ why?: string;
+ disabled?: boolean;
+}) {
+ return (
+
+ props.onChange(event.target.checked)}
+ />
+
+ {props.title} {props.flag}
+ {props.why ? {props.why} : null}
+
+
+ );
+}
+
+/** Everything h3 accepts, named as it is on the command line. */
+export function Expert({ spec, system, plugins, onChange }: Props) {
+ const cuda = (system?.device?.architecture ?? "CUDA").startsWith("CUDA");
+ const hidden = OPTIONS.filter((option) => option.ui === "hidden");
+
+ return (
+
+
+
+
+ Text and variation --prompt --seed
+
+
+ The prompt is sent as written. The same prompt, settings and seed produce the
+ same video on the same build.
+
+
+
+
+ Duration and canvas{" "}
+
+ --frames --seconds --width --height --render-width --render-height
+
+
+
+ Frames round up to 5 + 17n, {CONSTANTS.frames.min_generation}…
+ {CONSTANTS.frames.max_aligned}. Sides are multiples of{" "}
+ {CONSTANTS.canvas_multiple} and the area stays under{" "}
+ {CONSTANTS.max_pixels_label}.
+
+
+
+
+
+
+ Sampler{" "}
+ --steps --layers --reuse --core-reuse --token-reduction
+
+
+ Steps 2…{CONSTANTS.max_steps}, layers {CONSTANTS.dit_layers.min}…
+ {CONSTANTS.dit_layers.max}, reuse 1…3, core reuse 1…6. Reuse and core reuse
+ cannot both exceed 1.
+
+
+
+ --core-reuse
+
+ onChange({
+ ...spec,
+ core_reuse: Number(event.target.value) || 1,
+ denoise_reuse: Number(event.target.value) > 1 ? 1 : spec.denoise_reuse,
+ })
+ }
+ />
+
+
+ --ref-image-size
+
+ onChange({
+ ...spec,
+ reference_image_size: event.target.value as "match" | "max",
+ })
+ }
+ >
+ match
+ max
+
+
+
+
+
+
+ onChange({ ...spec, ssd_streaming: on })}
+ />
+ onChange({ ...spec, use_int8_row_fc2: on })}
+ />
+ onChange({ ...spec, use_reference_rope: on })}
+ />
+ onChange({ ...spec, write_frames: on })}
+ />
+ onChange({ ...spec, profile: on })}
+ />
+
+
+
+
+ Parity flags {ALL_SLOWER_FLAGS.length}
+
+
Force close-reference implementations, slower by design.
+
+ {ALL_SLOWER_FLAGS.map((flag) => (
+
+
+ onChange({
+ ...spec,
+ slower: event.target.checked
+ ? [...spec.slower, flag]
+ : spec.slower.filter((entry) => entry !== flag),
+ })
+ }
+ />
+
+ --{flag}
+
+
+ ))}
+
+
+
+
+
Post-processing
+
+ {plugins.map((plugin) => (
+
+ onChange({
+ ...spec,
+ postprocess: on
+ ? [...spec.postprocess, plugin.name]
+ : spec.postprocess.filter((name) => name !== plugin.name),
+ })
+ }
+ />
+ ))}
+ {plugins.length === 0 ? (
+ No post-processing plugin is registered.
+ ) : null}
+
+ {plugins.find((plugin) => plugin.notice) ? (
+
{plugins.find((plugin) => plugin.notice)?.notice}
+ ) : null}
+
+
+
+
+ Watching it being made --preview-dir
+
+
+ Switched on under Picture . The server picks the directory the
+ passes are written to.
+
+
+
+
+
+ The photos a video starts and ends on{" "}
+ --first-frame --last-frame
+
+
+ Chosen where they are seen, under or start from · a photo , next to
+ the description. They are not repeated here.
+
+
+
+
+
+ Set by the server --model-dir --output --info
+
+
+ Not exposed:{" "}
+ {hidden
+ .filter((option) => option.role === "excluded")
+ .map((option) => option.flag)
+ .join(" ")}{" "}
+ — terminal graphics and CLI help, which a browser has no use for.
+
+
+
+ );
+}
diff --git a/webui/frontend/src/components/FineTune.tsx b/webui/frontend/src/components/FineTune.tsx
new file mode 100644
index 00000000..5731e885
--- /dev/null
+++ b/webui/frontend/src/components/FineTune.tsx
@@ -0,0 +1,182 @@
+import { CONSTANTS } from "../generated/options";
+import { humanMinutes } from "../copy";
+import { resolvedFrames } from "../spec";
+import type { JobSpec } from "../types";
+
+interface Props {
+ spec: JobSpec;
+ deltas: Record;
+ onChange: (spec: JobSpec) => void;
+}
+
+function Delta({ seconds, base }: { seconds: number | null | undefined; base: number | null }) {
+ if (seconds === null || seconds === undefined || base === null) return null;
+ const difference = seconds - base;
+ if (Math.abs(difference) < 20) return about the same ;
+ return (
+
+ {difference < 0 ? "saves" : "adds"} ≈ {humanMinutes(Math.abs(difference))}
+
+ );
+}
+
+/** The same trade-offs as Create, one control at a time and still in plain words. */
+export function FineTune({ spec, deltas, onChange }: Props) {
+ const base = deltas.base ?? null;
+ const internal = spec.render_width > 0 && spec.render_height > 0;
+
+ return (
+
+
+
+
+ Detail passes
+
+
How many times the picture is refined. More passes bring out more detail.
+
onChange({ ...spec, steps: Number(event.target.value) || 2 })}
+ />
+
+
+
+
+ Model depth
+
+
How much of the model runs. Less is faster and a little looser.
+
+ onChange({ ...spec, dit_layers: Number(event.target.value) || 50 })
+ }
+ />
+
+
+
+
+
+
+
+ How often it redraws
+
+
+ Redrawing at every pass is closest to the reference. Less often is faster, and
+ the framing can shift.
+
+
1 ? 0 : spec.denoise_reuse}
+ onChange={(event) =>
+ onChange({
+ ...spec,
+ denoise_reuse: Number(event.target.value) || 1,
+ core_reuse: 1,
+ })
+ }
+ >
+ Every pass — closest to the reference
+ Every other pass — the validated fast setting
+ Rarely — preview quality
+
+
+
+
+
+ Work smaller, then enlarge
+
+
Draw at a smaller size and scale the result up. Faster, with less fine detail.
+
{
+ const side = Number(event.target.value);
+ const ratio = spec.height / spec.width;
+ onChange({
+ ...spec,
+ render_width: side,
+ render_height: side ? Math.round((side * ratio) / 32) * 32 : 0,
+ });
+ }}
+ >
+ Off — draw at full size
+ 384 wide, then enlarge
+ 320 wide, then enlarge
+
+
+
+
+
+
+
+
+ Exact size
+
+
+ Multiples of 32, and at most {CONSTANTS.max_pixels_label} pixels in total.
+
+
+ onChange({ ...spec, width: Number(event.target.value) || 0 })}
+ />
+ onChange({ ...spec, height: Number(event.target.value) || 0 })}
+ />
+
+
+
+
+ Exact length
+
+
+ In frames rather than seconds. This job runs {resolvedFrames(spec)} frames.
+
+
+ onChange({ ...spec, frames: Number(event.target.value) || null, seconds: null })
+ }
+ />
+
+
+
+
+ onChange({ ...spec, preview: event.target.checked })}
+ />
+
+ Watch it being made
+
+ Shows the picture after every pass, so you can stop early if it is going the
+ wrong way.
+
+
+
+
+ onChange({ ...spec, token_reduction: event.target.checked })}
+ />
+
+ Pair up detail while drawing
+
+ Faster, and the composition can drift. Leave it off at small sizes.
+
+
+
+
+ );
+}
diff --git a/webui/frontend/src/components/LiveStrip.tsx b/webui/frontend/src/components/LiveStrip.tsx
new file mode 100644
index 00000000..c6af7978
--- /dev/null
+++ b/webui/frontend/src/components/LiveStrip.tsx
@@ -0,0 +1,48 @@
+import { api } from "../api";
+import { clock, humanMinutes, phaseName } from "../copy";
+import type { Job } from "../types";
+
+/** A video keeps being made while you set up the next one.
+ *
+ * This is what stays on screen once you leave the stage: enough to know how
+ * it is going, and one click to go back and watch.
+ */
+export function LiveStrip({ job, onWatch, onStop }: {
+ job: Job;
+ onWatch: () => void;
+ onStop: () => void;
+}) {
+ const preview =
+ job.params.preview && job.preview_step !== null
+ ? api.previewUrl(job.id, job.preview_step)
+ : null;
+
+ return (
+
+
+ {preview ? : … }
+
+
+
{job.prompt || "(no description)"}
+
+
+
+
+ {phaseName(job.phase)}
+
+ {clock(job.elapsed)}
+ {job.remaining !== null ? ` · about ${humanMinutes(job.remaining)} left` : ""}
+
+
+
+
+
+ Watch
+
+
+ Stop
+
+
+
+ );
+}
diff --git a/webui/frontend/src/components/Logo.tsx b/webui/frontend/src/components/Logo.tsx
new file mode 100644
index 00000000..780e8e44
--- /dev/null
+++ b/webui/frontend/src/components/Logo.tsx
@@ -0,0 +1,39 @@
+/** The h3c studio mark (R31, concept A — "the frame").
+ *
+ * The developing frame drawn as a sign: an open frame with the
+ * perforation on its left edge, and the corner at the bottom right still
+ * developing in the accent. One SVG, used from the favicon to the header;
+ * `currentColor` makes it follow the theme with no new colour.
+ */
+export function LogoMark({ size = 22 }: { size?: number }) {
+ const stroke = size <= 20 ? 3 : 2.6;
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/webui/frontend/src/components/People.tsx b/webui/frontend/src/components/People.tsx
new file mode 100644
index 00000000..939cd137
--- /dev/null
+++ b/webui/frontend/src/components/People.tsx
@@ -0,0 +1,124 @@
+import { useCallback, useEffect, useState } from "react";
+
+import { ApiError, api } from "../api";
+import type { Invite, User } from "../types";
+import { DeleteControl } from "./DeleteControl";
+
+/** Accounts and invites, for the administrator only (R30). */
+export function People({ me }: { me: User }) {
+ const [users, setUsers] = useState([]);
+ const [invites, setInvites] = useState([]);
+ const [newCode, setNewCode] = useState(null);
+ const [passwords, setPasswords] = useState>({});
+ const [problem, setProblem] = useState(null);
+
+ const refresh = useCallback(async () => {
+ const [list, codes] = await Promise.all([api.users(), api.invites()]);
+ setUsers(list);
+ setInvites(codes);
+ }, []);
+
+ useEffect(() => {
+ /* The state updates land in promise callbacks, not in the effect body,
+ * so there is no cascading render to avoid here. */
+ /* eslint-disable react-hooks/set-state-in-effect */
+ void refresh();
+ /* eslint-enable react-hooks/set-state-in-effect */
+ }, [refresh]);
+
+ function fail(failure: unknown, fallback: string) {
+ setProblem(failure instanceof ApiError ? failure.errors.join(" ") : fallback);
+ }
+
+ async function makeInvite() {
+ setProblem(null);
+ try {
+ const { code } = await api.createInvite();
+ setNewCode(code);
+ await refresh();
+ } catch (failure) {
+ fail(failure, "The invite could not be made.");
+ }
+ }
+
+ async function remove(user: User) {
+ setProblem(null);
+ try {
+ await api.deleteUser(user.id ?? 0);
+ await refresh();
+ } catch (failure) {
+ fail(failure, "That account could not be deleted.");
+ }
+ }
+
+ async function reset(user: User) {
+ setProblem(null);
+ const secret = passwords[user.id ?? 0] ?? "";
+ try {
+ await api.resetPassword(user.id ?? 0, secret);
+ setPasswords((current) => ({ ...current, [user.id ?? 0]: "" }));
+ setProblem(`${user.username} was signed out and must use the new password.`);
+ } catch (failure) {
+ fail(failure, "The password could not be changed.");
+ }
+ }
+
+ return (
+
+
+ void makeInvite()}>
+ New invite
+
+ {newCode ? (
+
+ {newCode}
+
+ ) : null}
+
+ {invites.filter((invite) => !invite.used).length} unused ·{" "}
+ {invites.filter((invite) => invite.used).length} used
+
+
+
+ {users.map((user) => (
+
+
+ {user.username}
+ {user.role === "admin" ? · administrator : null}
+
+
+ setPasswords((current) => ({
+ ...current,
+ [user.id ?? 0]: event.target.value,
+ }))
+ }
+ />
+ void reset(user)}
+ >
+ reset password
+
+ {user.id !== me.id && user.id !== undefined ? (
+ remove(user)} />
+ ) : null}
+
+ ))}
+
+ {problem ? (
+
+ {problem}
+
+ ) : null}
+
+ Resetting a password signs that person out. An account can only be
+ deleted once it owns no videos and no uploads.
+
+
+ );
+}
diff --git a/webui/frontend/src/components/PhotoSlot.tsx b/webui/frontend/src/components/PhotoSlot.tsx
new file mode 100644
index 00000000..a64d9054
--- /dev/null
+++ b/webui/frontend/src/components/PhotoSlot.tsx
@@ -0,0 +1,115 @@
+import { useRef, useState } from "react";
+
+import { api } from "../api";
+import type { Asset } from "../types";
+
+interface Props {
+ title: string;
+ subtitle: string;
+ assets: Asset[];
+ value: string | null;
+ disabled?: boolean;
+ onPick: (asset: Asset | null) => void;
+ onUploaded: (asset: Asset) => void;
+}
+
+/** A drop target that doubles as a picker over photos already uploaded. */
+export function PhotoSlot(props: Props) {
+ const { title, subtitle, assets, value, disabled, onPick, onUploaded } = props;
+ const [over, setOver] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [problem, setProblem] = useState(null);
+ const [browsing, setBrowsing] = useState(false);
+ const input = useRef(null);
+ const chosen = assets.find((asset) => asset.path === value) ?? null;
+ const photos = assets.filter((asset) => asset.kind === "image");
+
+ async function accept(file: File | undefined) {
+ if (!file) return;
+ setBusy(true);
+ setProblem(null);
+ try {
+ const asset = await api.upload(file);
+ onUploaded(asset);
+ onPick(asset);
+ } catch (failure) {
+ setProblem(failure instanceof Error ? failure.message : "That file did not load.");
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
(chosen ? setBrowsing((open) => !open) : input.current?.click())}
+ onDragOver={(event) => {
+ event.preventDefault();
+ setOver(true);
+ }}
+ onDragLeave={() => setOver(false)}
+ onDrop={(event) => {
+ event.preventDefault();
+ setOver(false);
+ if (!disabled) void accept(event.dataTransfer.files[0]);
+ }}
+ >
+ {chosen ? "▣" : "+"}
+
+ {busy ? "Loading…" : chosen ? chosen.filename : title}
+ {chosen ? "click to change" : subtitle}
+
+ {chosen ? (
+ {
+ event.stopPropagation();
+ onPick(null);
+ }}
+ >
+ remove
+
+ ) : null}
+
+
void accept(event.target.files?.[0])}
+ />
+ {!disabled && photos.length > 0 && !chosen ? (
+
setBrowsing((o) => !o)}>
+ {browsing ? "hide photos" : "use a photo you already added"}
+
+ ) : null}
+ {browsing ? (
+
+ {photos.map((asset) => (
+
{
+ onPick(asset);
+ setBrowsing(false);
+ }}
+ >
+
+ {asset.filename}
+
+ ))}
+
input.current?.click()}>
+
+ +
+
+ add another
+
+
+ ) : null}
+ {problem ?
{problem}
: null}
+
+ );
+}
diff --git a/webui/frontend/src/components/References.tsx b/webui/frontend/src/components/References.tsx
new file mode 100644
index 00000000..b824915b
--- /dev/null
+++ b/webui/frontend/src/components/References.tsx
@@ -0,0 +1,164 @@
+import { useRef, useState } from "react";
+
+import { api } from "../api";
+import { REFERENCE_RULES } from "../generated/options";
+import { resolvedFrames } from "../spec";
+import type { Asset, JobSpec, ReferenceKind } from "../types";
+
+interface Props {
+ spec: JobSpec;
+ onChange: (spec: JobSpec) => void;
+ onUploaded: (asset: Asset) => void;
+}
+
+const KINDS: { kind: ReferenceKind; label: string; flag: string; short: string }[] = [
+ { kind: "image", label: "photo", flag: "--ref-image", short: "photo" },
+ { kind: "video", label: "clip", flag: "--ref-video", short: "clip" },
+ { kind: "silent_video", label: "clip, no sound", flag: "--ref-silent-video", short: "clip" },
+ { kind: "video_audio", label: "clip + sound", flag: "--ref-video-audio", short: "clip+snd" },
+ { kind: "audio", label: "sound", flag: "--ref-audio", short: "sound" },
+];
+
+/** Reference material, in the order h3 will read it. */
+export function References({ spec, onChange, onUploaded }: Props) {
+ const [pending, setPending] = useState(null);
+ const input = useRef(null);
+ const references = spec.references;
+ const videoKinds: ReferenceKind[] = ["video", "silent_video", "video_audio"];
+ const counts = {
+ images: references.filter((r) => r.kind === "image").length,
+ videos: references.filter((r) => videoKinds.includes(r.kind)).length,
+ audio: references.filter((r) => ["audio", "video", "video_audio"].includes(r.kind)).length,
+ seconds: references
+ .filter((r) => r.kind === "audio")
+ .reduce((total, r) => total + (r.seconds ?? 0), 0),
+ };
+ const full = references.length >= REFERENCE_RULES.max_total;
+
+ function move(index: number, delta: number) {
+ const next = [...references];
+ const target = index + delta;
+ if (target < 0 || target >= next.length) return;
+ [next[index], next[target]] = [next[target], next[index]];
+ onChange({ ...spec, references: next });
+ }
+
+ async function accept(file: File | undefined) {
+ if (!file || !pending) return;
+ try {
+ const asset = await api.upload(file);
+ onUploaded(asset);
+ onChange({
+ ...spec,
+ references: [
+ ...references,
+ {
+ kind: pending,
+ path: asset.path,
+ label: asset.filename,
+ seconds: asset.metadata.seconds ?? null,
+ },
+ ],
+ });
+ } finally {
+ setPending(null);
+ }
+ }
+
+ return (
+
+
+ Material to draw from: a photo for the subject, a clip to continue, a sound for the
+ mood. The order matters, and reference material cannot be combined with a start or
+ end photo.
+
+
+ {references.length === 0 ? (
+
Nothing added yet.
+ ) : (
+
+ {references.map((reference, index) => (
+
+
+ {KINDS.find((entry) => entry.kind === reference.kind)?.short}
+
+ {reference.label ?? reference.path.split("/").pop()}
+
+ {reference.seconds ? `${reference.seconds.toFixed(1)} s · ` : ""}
+ {KINDS.find((entry) => entry.kind === reference.kind)?.flag}
+
+
+ move(index, -1)} aria-label="move up">↑
+ move(index, 1)} aria-label="move down">↓
+
+ onChange({
+ ...spec,
+ references: references.filter((_, at) => at !== index),
+ })
+ }
+ >
+ ✕
+
+
+
+ ))}
+
+ )}
+
+
+
+ in all {references.length} /{REFERENCE_RULES.max_total}
+
+
+ photos {counts.images} /{REFERENCE_RULES.max_images}
+
+
+ clips {counts.videos} /{REFERENCE_RULES.max_videos}
+
+
+ sounds {counts.audio} /{REFERENCE_RULES.max_audio_inputs}
+
+
+ sound length {counts.seconds.toFixed(1)} /15 s
+
+
+
+
+ Add:
+ {KINDS.map((entry) => (
+ {
+ setPending(entry.kind);
+ input.current?.click();
+ }}
+ >
+ {entry.label}
+
+ ))}
+
+
void accept(event.target.files?.[0])}
+ />
+
+
+
+ At most {REFERENCE_RULES.max_total} items: {REFERENCE_RULES.max_images} photos,{" "}
+ {REFERENCE_RULES.max_videos} clips, {REFERENCE_RULES.max_audio_inputs} sounds.
+
+ A sound needs a photo or a clip beside it, and must last 2 to 15 seconds.
+
+ A clip's own sound is trimmed to the video's length and needs 2 seconds, so ask
+ for at least 56 frames — this video is {resolvedFrames(spec)}.
+
+
+
+ );
+}
diff --git a/webui/frontend/src/components/RenderStage.tsx b/webui/frontend/src/components/RenderStage.tsx
new file mode 100644
index 00000000..30bd20ee
--- /dev/null
+++ b/webui/frontend/src/components/RenderStage.tsx
@@ -0,0 +1,136 @@
+import { api } from "../api";
+import { clock, explain, humanMinutes, phaseName, railIndex, railPhases } from "../copy";
+import { resolvedSeconds } from "../spec";
+import type { Job } from "../types";
+
+/** The signature moment: the picture emerging from noise, pass by pass.
+ *
+ * The same stage shows a finished take, with the video where the developing
+ * frame was: what you watched being made is what you watch afterwards.
+ */
+export function RenderStage(props: {
+ job: Job;
+ onStop: () => void;
+ onLeave?: () => void;
+}) {
+ const { job, onStop, onLeave } = props;
+ const running = job.state === "running" || job.state === "queued";
+ const phases = railPhases(job);
+ const reached = running ? railIndex(phases, job.phase) : phases.length;
+ const preview =
+ job.params.preview && job.preview_step !== null
+ ? api.previewUrl(job.id, job.preview_step)
+ : null;
+ const broken = job.state === "failed" || job.state === "cancelled";
+
+ return (
+ <>
+
+ “{job.prompt}”
+
+ {job.params.width}×{job.params.height} · variation {job.params.seed}
+
+ {running && onLeave ? (
+
+ Keep making
+
+ ) : null}
+
+ {running ? "Stop" : "Close"}
+
+
+
+
+
+ {job.state === "completed" ? (
+
+ ) : broken ? (
+
+ {job.error ? explain(job.error).title : "This one did not finish."}
+
+ ) : preview ? (
+
+ ) : (
+
+ {job.params.preview
+ ? "the first pass has not been drawn yet"
+ : "watching is turned off for this video"}
+
+ )}
+ {running && preview ? (
+
+ pass {(job.preview_step ?? 0) + 1} of {job.params.steps}
+
+ ) : null}
+
+
+ {/* The film edge doubles as the progress bar: one line, not three. */}
+
+ {phases.map((phase, index) => (
+
+ ))}
+
+
+
+
+ {running ? (
+ <>
+
+ {phaseName(job.phase)}
+ {job.phase?.startsWith("denoise") && job.total
+ ? ` — pass ${Math.min(job.completed + 1, job.total)} of ${job.total}`
+ : ""}
+
+
+
+ elapsed {clock(job.elapsed)}
+
+ {job.remaining !== null ? (
+
+ about {humanMinutes(job.remaining)} left
+
+ ) : null}
+ {Math.round(job.progress * 100)} % done
+
+ >
+ ) : broken ? (
+ <>
+
+ {job.error ? explain(job.error).title : "This one did not finish."}
+
+
+ {job.error ? explain(job.error).fix : ""}
+
+ >
+ ) : (
+ <>
+
Ready
+
+
+ {resolvedSeconds(job.params).toFixed(1)} s · {job.params.width}×
+ {job.params.height}
+
+
+ made in {clock(job.elapsed)}
+
+ variation {job.params.seed}
+
+ >
+ )}
+
+ {job.phase ?? "starting"} {job.total ? `${job.completed}/${job.total}` : ""} ·{" "}
+ {job.params.width}×{job.params.height} · seed {job.params.seed}
+
+
+ >
+ );
+}
diff --git a/webui/frontend/src/components/Takes.tsx b/webui/frontend/src/components/Takes.tsx
new file mode 100644
index 00000000..c3c5ec41
--- /dev/null
+++ b/webui/frontend/src/components/Takes.tsx
@@ -0,0 +1,127 @@
+import { useRef, useState } from "react";
+
+import { api } from "../api";
+import { clock, stateName } from "../copy";
+import type { Job } from "../types";
+import { DeleteControl } from "./DeleteControl";
+
+interface Props {
+ jobs: Job[];
+ onOpen: (job: Job) => void;
+ onDelete: (job: Job) => void;
+}
+
+/** Everything this machine has made, most recent first.
+ *
+ * R29 P3: a grid, and hovering a take starts it playing in place; the
+ * actions surface over the moving picture instead of waiting below it.
+ */
+export function Takes({ jobs, onOpen, onDelete }: Props) {
+ const done = jobs.filter((job) => job.state === "completed");
+ return (
+
+ Takes
+ {done.length === 0 ? (
+
+ Nothing made yet. Describe a scene above and the first take lands here.
+
+ ) : (
+
+ {done.map((job) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+function TakeCard({ job, onOpen, onDelete }: {
+ job: Job;
+ onOpen: (job: Job) => void;
+ onDelete: (job: Job) => void;
+}) {
+ const video = useRef(null);
+ // A take whose video cannot be read has no poster either (T107): the
+ // cell says so, instead of showing a broken picture.
+ const [broken, setBroken] = useState(false);
+
+ return (
+ {
+ const player = video.current;
+ if (player) {
+ player.currentTime = 0;
+ // A poster stays up if the video cannot play yet.
+ void player.play().catch(() => {});
+ }
+ }}
+ onMouseLeave={() => video.current?.pause()}
+ >
+
onOpen(job)}>
+ {broken ? (
+
no picture
+ ) : (
+
setBroken(true)}
+ />
+ )}
+
+
event.stopPropagation()}>
+
onOpen(job)}>Open
+
+ Save
+
+
onDelete(job)} />
+
+
+
{job.prompt.split(" ").slice(0, 5).join(" ")}
+
+ {job.params.width}×{job.params.height} · {clock(job.elapsed)}
+
+
+ );
+}
+
+/** Jobs that are not the one on stage: waiting, stopped or broken. */
+export function Waiting({ jobs, onCancel, onLog }: {
+ jobs: Job[];
+ onCancel: (id: number) => void;
+ onLog: (job: Job) => void;
+}) {
+ const others = jobs.filter(
+ (job) => job.state === "queued" || job.state === "failed" || job.state === "cancelled",
+ );
+ if (others.length === 0) return null;
+ return (
+
+ {others.slice(0, 6).map((job) => (
+
+ {stateName(job.state)}
+ {job.prompt || "(no description)"}
+ {job.state === "queued" ? (
+ onCancel(job.id)}>
+ remove
+
+ ) : (
+ onLog(job)}>
+ what happened
+
+ )}
+
+ ))}
+
+ );
+}
diff --git a/webui/frontend/src/copy.ts b/webui/frontend/src/copy.ts
new file mode 100644
index 00000000..83936573
--- /dev/null
+++ b/webui/frontend/src/copy.ts
@@ -0,0 +1,130 @@
+import copy from "../../shared/copy.json";
+
+interface OptionCopy {
+ name: string;
+ help?: string;
+ time?: string;
+}
+
+interface ErrorCopy {
+ match: string;
+ title: string;
+ fix: string;
+}
+
+const OPTIONS = copy.options as Record;
+const PHASES = copy.phases as Record;
+const STATES = copy.states as Record;
+const ERRORS = copy.errors as ErrorCopy[];
+
+/** What a person calls this setting. */
+export function optionName(key: string): string {
+ return OPTIONS[key]?.name ?? key;
+}
+
+export function optionHelp(key: string): string | undefined {
+ return OPTIONS[key]?.help;
+}
+
+/** What h3 is doing, said in words anyone can read. */
+export function phaseName(phase: string | null): string {
+ if (!phase) return "Getting started";
+ return PHASES[phase] ?? phase;
+}
+
+export function stateName(state: string): string {
+ return STATES[state] ?? state;
+}
+
+/** Turn an engine message into something that says what to change. */
+export function explain(message: string): ErrorCopy {
+ const found = ERRORS.find((entry) => message.includes(entry.match));
+ return (
+ found ?? {
+ match: message,
+ title: "That job cannot be made as it is.",
+ fix: "Change one of the settings above and try again.",
+ }
+ );
+}
+
+/** "4 min", "1 h 12 min", "40 s" — the way a person reads a wait. */
+export function humanMinutes(seconds: number | null | undefined): string {
+ if (seconds === null || seconds === undefined || !isFinite(seconds)) return "—";
+ if (seconds < 90) return `${Math.max(1, Math.round(seconds))} s`;
+ const minutes = Math.round(seconds / 60);
+ if (minutes < 60) return `${minutes} min`;
+ return `${Math.floor(minutes / 60)} h ${minutes % 60} min`;
+}
+
+/** 00:04.5 — the frame counter's voice, for a length. */
+export function timecode(seconds: number): string {
+ const whole = Math.floor(seconds);
+ const tenths = Math.round((seconds - whole) * 10);
+ return `00:${String(whole).padStart(2, "0")}.${tenths}`;
+}
+
+/** 00:03:41 — for elapsed and remaining. */
+export function clock(seconds: number | null | undefined): string {
+ if (seconds === null || seconds === undefined) return "--:--";
+ const total = Math.max(0, Math.round(seconds));
+ const hours = Math.floor(total / 3600);
+ const minutes = Math.floor((total % 3600) / 60);
+ const rest = total % 60;
+ const pad = (n: number) => String(n).padStart(2, "0");
+ return hours ? `${pad(hours)}:${pad(minutes)}:${pad(rest)}` : `${pad(minutes)}:${pad(rest)}`;
+}
+
+/** The phases h3 walks through, in order, for the perforation rail. */
+export const PHASE_ORDER = [
+ "tokenizer",
+ "text encoder",
+ "Qwen vision",
+ "refine text",
+ "precompute AdaLN",
+ "load transformer core",
+ "preview VAE load",
+ "denoise",
+ "audio VAE encoder",
+ "video VAE encoder",
+ "audio VAE",
+ "video VAE load",
+ "FFmpeg",
+];
+
+/** Which mark of the rail a reported phase belongs to.
+ *
+ * h3 reports sub-phases the rail has no mark for — `denoise enqueue` while
+ * streaming weights from SSD, for one. Falling back to the longest listed
+ * phase the report starts with keeps the rail on `denoise` instead of
+ * blanking it for the whole of the longest stretch of the job.
+ */
+export function railIndex(phases: string[], phase: string | null | undefined): number {
+ if (!phase) return -1;
+ const exact = phases.indexOf(phase);
+ if (exact >= 0) return exact;
+ let best = -1;
+ phases.forEach((listed, index) => {
+ if (phase.startsWith(listed) && (best < 0 || listed.length > phases[best].length)) {
+ best = index;
+ }
+ });
+ return best;
+}
+
+/** The phases this particular job will pass through.
+ *
+ * A rail with marks that can never fill is a rail that lies: reference
+ * material adds encoding phases, and the preview adds a load of its own.
+ */
+export function railPhases(job: {
+ params: { preview?: boolean; references?: unknown[] };
+}): string[] {
+ const withReferences = (job.params.references?.length ?? 0) > 0;
+ return PHASE_ORDER.filter((phase) => {
+ if (phase === "preview VAE load") return Boolean(job.params.preview);
+ if (phase === "Qwen vision") return withReferences;
+ if (phase.endsWith("VAE encoder")) return withReferences;
+ return true;
+ });
+}
diff --git a/webui/frontend/src/generated/options.ts b/webui/frontend/src/generated/options.ts
new file mode 100644
index 00000000..55f98d59
--- /dev/null
+++ b/webui/frontend/src/generated/options.ts
@@ -0,0 +1,615 @@
+// GENERATED by scripts/generate-options.mjs from
+// webui/shared/options.schema.json. Do not edit by hand.
+
+export type OptionUi = "simple" | "advanced" | "hidden";
+
+export interface OptionSpec {
+ flag: string;
+ key: string;
+ type: string;
+ group: string;
+ ui: OptionUi;
+ label?: string;
+ help?: string;
+ role?: string;
+ default?: unknown;
+ min?: number;
+ max?: number;
+ multiple_of?: number;
+ values?: string[];
+ backends?: string[];
+ short?: string;
+ asset_kind?: string;
+ reference_kind?: string;
+ repeatable?: boolean;
+ required?: boolean;
+ arity?: number;
+}
+
+export const OPTIONS: OptionSpec[] = [
+ {
+ "flag": "--model-dir",
+ "short": "-d",
+ "key": "model_dir",
+ "type": "path",
+ "group": "server",
+ "ui": "hidden",
+ "role": "server",
+ "default": null,
+ "label": "Model directory",
+ "help": "Set from H3_MODEL_DIR; never chosen by the browser."
+ },
+ {
+ "flag": "--prompt",
+ "short": "-p",
+ "key": "prompt",
+ "type": "text",
+ "group": "content",
+ "ui": "simple",
+ "required": true,
+ "default": "",
+ "label": "Prompt"
+ },
+ {
+ "flag": "--output",
+ "short": "-o",
+ "key": "output",
+ "type": "path",
+ "group": "output",
+ "ui": "advanced",
+ "role": "server",
+ "default": "outputs/h3.mp4",
+ "label": "Output file",
+ "help": "Server-assigned per job. An empty value disables MP4 encoding."
+ },
+ {
+ "flag": "--width",
+ "key": "width",
+ "type": "int",
+ "group": "output",
+ "ui": "simple",
+ "default": 864,
+ "min": 32,
+ "multiple_of": 32,
+ "label": "Width"
+ },
+ {
+ "flag": "--height",
+ "key": "height",
+ "type": "int",
+ "group": "output",
+ "ui": "simple",
+ "default": 480,
+ "min": 32,
+ "multiple_of": 32,
+ "label": "Height"
+ },
+ {
+ "flag": "--render-width",
+ "key": "render_width",
+ "type": "int",
+ "group": "output",
+ "ui": "advanced",
+ "default": 0,
+ "min": 32,
+ "multiple_of": 32,
+ "label": "Internal render width",
+ "help": "Optional lower internal canvas; upscaled to the output size."
+ },
+ {
+ "flag": "--render-height",
+ "key": "render_height",
+ "type": "int",
+ "group": "output",
+ "ui": "advanced",
+ "default": 0,
+ "min": 32,
+ "multiple_of": 32,
+ "label": "Internal render height"
+ },
+ {
+ "flag": "--frames",
+ "key": "frames",
+ "type": "int",
+ "group": "duration",
+ "ui": "simple",
+ "default": 56,
+ "min": 5,
+ "max": 362,
+ "label": "Frames",
+ "help": "Rounded up to 5 + 17*n."
+ },
+ {
+ "flag": "--seconds",
+ "key": "seconds",
+ "type": "float",
+ "group": "duration",
+ "ui": "simple",
+ "default": null,
+ "min": 0.917,
+ "max": 15.083,
+ "label": "Duration (s)",
+ "help": "Converted at 24 fps, then rounded up to the next legal temporal shape."
+ },
+ {
+ "flag": "--steps",
+ "key": "steps",
+ "type": "int",
+ "group": "sampler",
+ "ui": "simple",
+ "default": 20,
+ "min": 2,
+ "max": 1000,
+ "label": "Denoising steps"
+ },
+ {
+ "flag": "--reuse",
+ "key": "denoise_reuse",
+ "type": "int",
+ "group": "sampler",
+ "ui": "advanced",
+ "default": 1,
+ "min": 1,
+ "max": 3,
+ "label": "Denoiser reuse",
+ "help": "1 close, 2 fast, 3 aggressive."
+ },
+ {
+ "flag": "--layers",
+ "key": "dit_layers",
+ "type": "int",
+ "group": "sampler",
+ "ui": "advanced",
+ "default": 50,
+ "min": 35,
+ "max": 50,
+ "label": "Active DiT blocks",
+ "help": "50 exact, 45 fast, 40 aggressive."
+ },
+ {
+ "flag": "--core-reuse",
+ "key": "core_reuse",
+ "type": "int",
+ "group": "sampler",
+ "ui": "advanced",
+ "default": 1,
+ "min": 1,
+ "max": 6,
+ "label": "Core residual reuse",
+ "help": "1 exact, 4 fast, 6 aggressive."
+ },
+ {
+ "flag": "--token-reduction",
+ "key": "token_reduction",
+ "type": "bool",
+ "group": "sampler",
+ "ui": "advanced",
+ "default": false,
+ "label": "Token reduction",
+ "help": "Pairs horizontal video tokens in middle DiT blocks. Keep off at 256 square."
+ },
+ {
+ "flag": "--ssd-streaming",
+ "key": "ssd_streaming",
+ "type": "bool",
+ "group": "memory",
+ "ui": "advanced",
+ "default": false,
+ "label": "SSD streaming",
+ "help": "Exact low-memory mode: GB10 DiT peak 27.06 GB to 1.63 GB, but slower."
+ },
+ {
+ "flag": "--use-int8-row-fc2",
+ "key": "use_int8_row_fc2",
+ "type": "bool",
+ "group": "memory",
+ "ui": "advanced",
+ "default": false,
+ "backends": [
+ "metal"
+ ],
+ "label": "int8 row FC2",
+ "help": "Metal/M5 specialization; a measured no-op on CUDA."
+ },
+ {
+ "flag": "--use-reference-rope",
+ "key": "use_reference_rope",
+ "type": "bool",
+ "group": "memory",
+ "ui": "advanced",
+ "default": false,
+ "label": "Reference RoPE",
+ "help": "Restores the released 256x256 spatial RoPE grid for parity checks."
+ },
+ {
+ "flag": "--use-slower-bf16-mlp",
+ "key": "use_slower_bf16_mlp",
+ "type": "bool",
+ "group": "parity",
+ "ui": "advanced",
+ "default": false,
+ "label": "BF16 MLP"
+ },
+ {
+ "flag": "--use-slower-bf16-qkv",
+ "key": "use_slower_bf16_qkv",
+ "type": "bool",
+ "group": "parity",
+ "ui": "advanced",
+ "default": false,
+ "label": "BF16 QKV"
+ },
+ {
+ "flag": "--use-slower-bf16-attention-output",
+ "key": "use_slower_bf16_attention_output",
+ "type": "bool",
+ "group": "parity",
+ "ui": "advanced",
+ "default": false,
+ "label": "BF16 attention output"
+ },
+ {
+ "flag": "--use-slower-row-major-attention-output",
+ "key": "use_slower_row_major_attention_output",
+ "type": "bool",
+ "group": "parity",
+ "ui": "advanced",
+ "default": false,
+ "label": "Row-major attention output"
+ },
+ {
+ "flag": "--use-slower-unfused-int8-inputs",
+ "key": "use_slower_unfused_int8_inputs",
+ "type": "bool",
+ "group": "parity",
+ "ui": "advanced",
+ "default": false,
+ "label": "Unfused int8 inputs"
+ },
+ {
+ "flag": "--use-slower-unfused-qkv-rope",
+ "key": "use_slower_unfused_qkv_rope",
+ "type": "bool",
+ "group": "parity",
+ "ui": "advanced",
+ "default": false,
+ "label": "Unfused QKV RoPE"
+ },
+ {
+ "flag": "--use-slower-scalar-qkv-rms",
+ "key": "use_slower_scalar_qkv_rms",
+ "type": "bool",
+ "group": "parity",
+ "ui": "advanced",
+ "default": false,
+ "label": "Scalar QKV RMS"
+ },
+ {
+ "flag": "--use-slower-uncached-int8-scales",
+ "key": "use_slower_uncached_int8_scales",
+ "type": "bool",
+ "group": "parity",
+ "ui": "advanced",
+ "default": false,
+ "label": "Uncached int8 scales"
+ },
+ {
+ "flag": "--use-slower-dynamic-fc1-k",
+ "key": "use_slower_dynamic_fc1_k",
+ "type": "bool",
+ "group": "parity",
+ "ui": "advanced",
+ "default": false,
+ "label": "Dynamic FC1 K loop"
+ },
+ {
+ "flag": "--use-slower-grouped-quantizer",
+ "key": "use_slower_grouped_quantizer",
+ "type": "bool",
+ "group": "parity",
+ "ui": "advanced",
+ "default": false,
+ "label": "Grouped quantizer"
+ },
+ {
+ "flag": "--seed",
+ "key": "seed",
+ "type": "uint64",
+ "group": "content",
+ "ui": "simple",
+ "default": 42,
+ "label": "Seed"
+ },
+ {
+ "flag": "--first-frame",
+ "key": "first_frame",
+ "type": "asset",
+ "asset_kind": "image",
+ "group": "content",
+ "ui": "simple",
+ "default": null,
+ "label": "First frame"
+ },
+ {
+ "flag": "--last-frame",
+ "key": "last_frame",
+ "type": "asset",
+ "asset_kind": "image",
+ "group": "content",
+ "ui": "simple",
+ "default": null,
+ "label": "Last frame"
+ },
+ {
+ "flag": "--ref-image",
+ "key": "ref_image",
+ "type": "reference",
+ "reference_kind": "image",
+ "group": "references",
+ "ui": "advanced",
+ "repeatable": true,
+ "label": "Reference image"
+ },
+ {
+ "flag": "--ref-image-size",
+ "key": "reference_image_size",
+ "type": "enum",
+ "values": [
+ "match",
+ "max"
+ ],
+ "group": "references",
+ "ui": "advanced",
+ "default": "match",
+ "label": "Reference image sizing"
+ },
+ {
+ "flag": "--ref-video",
+ "key": "ref_video",
+ "type": "reference",
+ "reference_kind": "video",
+ "group": "references",
+ "ui": "advanced",
+ "repeatable": true,
+ "label": "Reference video (keep audio)"
+ },
+ {
+ "flag": "--ref-silent-video",
+ "key": "ref_silent_video",
+ "type": "reference",
+ "reference_kind": "silent_video",
+ "group": "references",
+ "ui": "advanced",
+ "repeatable": true,
+ "label": "Reference video (drop audio)"
+ },
+ {
+ "flag": "--ref-video-audio",
+ "key": "ref_video_audio",
+ "type": "reference",
+ "reference_kind": "video_audio",
+ "group": "references",
+ "ui": "advanced",
+ "repeatable": true,
+ "arity": 2,
+ "label": "Reference video + soundtrack"
+ },
+ {
+ "flag": "--ref-audio",
+ "key": "ref_audio",
+ "type": "reference",
+ "reference_kind": "audio",
+ "group": "references",
+ "ui": "advanced",
+ "repeatable": true,
+ "label": "Reference audio"
+ },
+ {
+ "flag": "--frames-dir",
+ "key": "frames_dir",
+ "type": "path",
+ "group": "diagnostics",
+ "ui": "advanced",
+ "default": null,
+ "label": "Write frames as PPM",
+ "help": "Server-assigned when enabled."
+ },
+ {
+ "flag": "--preview-dir",
+ "key": "preview",
+ "type": "path",
+ "group": "diagnostics",
+ "ui": "simple",
+ "role": "server",
+ "default": null,
+ "label": "Live preview",
+ "help": "Writes a PPM after every denoising step. The UI exposes it as a toggle and assigns the directory. Adds a preview VAE load phase and one decode per step."
+ },
+ {
+ "flag": "--profile",
+ "key": "profile",
+ "type": "bool",
+ "group": "diagnostics",
+ "ui": "advanced",
+ "default": false,
+ "label": "Profile phases"
+ },
+ {
+ "flag": "--show",
+ "key": "show",
+ "type": "bool",
+ "group": "server",
+ "ui": "hidden",
+ "role": "excluded",
+ "default": false,
+ "label": "Terminal preview",
+ "help": "Kitty/Ghostty graphics protocol; meaningless in a browser. Live preview uses --preview-dir (task 82)."
+ },
+ {
+ "flag": "--zoom",
+ "key": "zoom",
+ "type": "int",
+ "group": "server",
+ "ui": "hidden",
+ "role": "excluded",
+ "default": 2,
+ "label": "Terminal zoom",
+ "help": "Terminal-only display factor."
+ },
+ {
+ "flag": "--info",
+ "key": "info",
+ "type": "bool",
+ "group": "server",
+ "ui": "hidden",
+ "role": "server",
+ "default": false,
+ "label": "Model and device inventory",
+ "help": "Used by GET /api/system."
+ },
+ {
+ "flag": "--help",
+ "short": "-h",
+ "key": "help",
+ "type": "bool",
+ "group": "server",
+ "ui": "hidden",
+ "role": "excluded",
+ "default": false,
+ "label": "CLI help"
+ }
+];
+
+export const CONSTANTS = {
+ "fps": 24,
+ "canvas_multiple": 32,
+ "min_canvas": 32,
+ "max_pixels": 1032192,
+ "max_pixels_label": "768 * 1344",
+ "max_steps": 1000,
+ "frames": {
+ "align_base": 5,
+ "align_stride": 17,
+ "min_request": 5,
+ "max_aligned": 362,
+ "min_generation": 22,
+ "note": "Requests are rounded up to 5 + 17*n. Generation needs at least one 22-frame decoder chunk."
+ },
+ "audio": {
+ "sample_rate": 32000,
+ "min_seconds": 2,
+ "max_total_seconds": 15
+ },
+ "dit_layers": {
+ "min": 35,
+ "max": 50
+ }
+} as const;
+
+export const CANVAS_PRESETS = [
+ {
+ "label": "256 square (fast preview)",
+ "width": 256,
+ "height": 256
+ },
+ {
+ "label": "512 square (development)",
+ "width": 512,
+ "height": 512
+ },
+ {
+ "label": "768 square",
+ "width": 768,
+ "height": 768
+ },
+ {
+ "label": "1344x768 landscape",
+ "width": 1344,
+ "height": 768
+ },
+ {
+ "label": "768x1344 portrait",
+ "width": 768,
+ "height": 1344
+ },
+ {
+ "label": "1024x768",
+ "width": 1024,
+ "height": 768
+ },
+ {
+ "label": "768x1024",
+ "width": 768,
+ "height": 1024
+ }
+];
+
+export const QUALITY_PRESETS = [
+ {
+ "id": "draft",
+ "label": "Draft",
+ "steps": 20,
+ "dit_layers": 40,
+ "denoise_reuse": 3,
+ "token_reduction": false,
+ "note": "The validated aggressive preview: fewer blocks, redrawn rarely, and drawn at 62.5 % of the output before being enlarged. Do not add token reduction on top of layers 40 and reuse 3.",
+ "render_scale": 0.625
+ },
+ {
+ "id": "balanced",
+ "label": "Balanced",
+ "steps": 20,
+ "dit_layers": 45,
+ "denoise_reuse": 2,
+ "token_reduction": true,
+ "render_scale": 1
+ },
+ {
+ "id": "reference",
+ "label": "Reference",
+ "steps": 50,
+ "dit_layers": 50,
+ "denoise_reuse": 1,
+ "token_reduction": false,
+ "render_scale": 1
+ }
+];
+
+export const REFERENCE_RULES = {
+ "max_total": 12,
+ "max_images": 9,
+ "max_videos": 3,
+ "max_audio_inputs": 3,
+ "ordered": true,
+ "requires_checkpoint": "ref2va",
+ "rules": [
+ {
+ "id": "audio_needs_visual",
+ "message": "reference audio requires an image or video reference"
+ },
+ {
+ "id": "audio_minimum",
+ "message": "reference audio requires at least 2 seconds at 32 kHz"
+ },
+ {
+ "id": "audio_total",
+ "message": "ordered reference audio exceeds 15 seconds in total"
+ },
+ {
+ "id": "soundtrack_duration",
+ "message": "a video soundtrack is truncated to the output duration and needs at least 2 seconds: request at least 56 output frames"
+ },
+ {
+ "id": "video_audio_path",
+ "message": "a video+audio reference needs a soundtrack path"
+ }
+ ]
+};
+
+export const SLOWER_FLAGS: string[] = OPTIONS.filter(
+ (option) => option.group === "parity",
+).map((option) => option.flag.slice(2));
+
+export function optionByKey(key: string): OptionSpec | undefined {
+ return OPTIONS.find((option) => option.key === key);
+}
diff --git a/webui/frontend/src/main.tsx b/webui/frontend/src/main.tsx
new file mode 100644
index 00000000..850abbf3
--- /dev/null
+++ b/webui/frontend/src/main.tsx
@@ -0,0 +1,22 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+
+import { App } from "./App";
+// Fonts are bundled, not fetched: a machine with a GPU may well be offline.
+import "@fontsource/instrument-sans/400.css";
+import "@fontsource/instrument-serif/400.css";
+import "@fontsource/instrument-serif/400-italic.css";
+import "@fontsource/instrument-sans/500.css";
+import "@fontsource/instrument-sans/600.css";
+import "@fontsource/instrument-sans/700.css";
+import "@fontsource/martian-mono/300.css";
+import "@fontsource/martian-mono/500.css";
+import "./styles.css";
+
+const container = document.getElementById("root");
+if (!container) throw new Error("missing #root");
+createRoot(container).render(
+
+
+ ,
+);
diff --git a/webui/frontend/src/spec.ts b/webui/frontend/src/spec.ts
new file mode 100644
index 00000000..a93dd5fd
--- /dev/null
+++ b/webui/frontend/src/spec.ts
@@ -0,0 +1,99 @@
+import { CONSTANTS, QUALITY_PRESETS, SLOWER_FLAGS } from "./generated/options";
+import type { JobSpec } from "./types";
+
+export const DEFAULT_SPEC: JobSpec = {
+ prompt: "",
+ width: 512,
+ height: 512,
+ render_width: 0,
+ render_height: 0,
+ frames: null,
+ seconds: 2.5,
+ steps: 20,
+ denoise_reuse: 1,
+ dit_layers: 50,
+ core_reuse: 1,
+ token_reduction: false,
+ ssd_streaming: false,
+ use_int8_row_fc2: false,
+ use_reference_rope: false,
+ seed: 42,
+ first_frame: null,
+ last_frame: null,
+ references: [],
+ reference_image_size: "match",
+ write_frames: false,
+ profile: false,
+ preview: true,
+ slower: [],
+ postprocess: [],
+};
+
+export const ALL_SLOWER_FLAGS = SLOWER_FLAGS;
+
+/** Mirror of h3_align_frame_count: legal shapes are 5 + 17n. */
+export function alignFrames(requested: number): number {
+ const value = Math.max(requested, CONSTANTS.frames.align_base);
+ const remainder = (value - CONSTANTS.frames.align_base) % CONSTANTS.frames.align_stride;
+ return remainder === 0 ? value : value + CONSTANTS.frames.align_stride - remainder;
+}
+
+export function resolvedFrames(spec: JobSpec): number {
+ const requested =
+ spec.frames ??
+ (spec.seconds !== null ? Math.round(spec.seconds * CONSTANTS.fps) : 56);
+ return alignFrames(Math.max(requested, 1));
+}
+
+export function resolvedSeconds(spec: JobSpec): number {
+ return resolvedFrames(spec) / CONSTANTS.fps;
+}
+
+export function megapixels(width: number, height: number): string {
+ return (width * height / 1e6).toFixed(2);
+}
+
+export function applyQualityPreset(spec: JobSpec, id: string): JobSpec {
+ const preset = QUALITY_PRESETS.find((entry) => entry.id === id);
+ if (!preset) return spec;
+ // A preset may also draw at a smaller size and enlarge: that is where most
+ // of the time goes, so a "quick look" that only thins the model is not quick.
+ const scale = preset.render_scale ?? 1;
+ const grid = CONSTANTS.canvas_multiple;
+ const snap = (value: number) => Math.max(grid, Math.round(value / grid) * grid);
+ const render =
+ scale < 1
+ ? { render_width: snap(spec.width * scale), render_height: snap(spec.height * scale) }
+ : { render_width: 0, render_height: 0 };
+ return {
+ ...spec,
+ steps: preset.steps,
+ dit_layers: preset.dit_layers,
+ denoise_reuse: preset.denoise_reuse,
+ core_reuse: 1,
+ token_reduction: preset.token_reduction,
+ ...render,
+ };
+}
+
+export function matchingPreset(spec: JobSpec): string | null {
+ const found = QUALITY_PRESETS.find((preset) => {
+ const scaled = (preset.render_scale ?? 1) < 1;
+ return (
+ preset.steps === spec.steps &&
+ preset.dit_layers === spec.dit_layers &&
+ preset.denoise_reuse === spec.denoise_reuse &&
+ preset.token_reduction === spec.token_reduction &&
+ spec.core_reuse === 1 &&
+ scaled === (spec.render_width > 0)
+ );
+ });
+ return found?.id ?? null;
+}
+
+export function formatDuration(seconds: number | null | undefined): string {
+ if (seconds === null || seconds === undefined) return "—";
+ const total = Math.round(seconds);
+ const minutes = Math.floor(total / 60);
+ return `${String(minutes).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`;
+}
diff --git a/webui/frontend/src/styles.css b/webui/frontend/src/styles.css
new file mode 100644
index 00000000..986ac89a
--- /dev/null
+++ b/webui/frontend/src/styles.css
@@ -0,0 +1,468 @@
+/* ── tokens ────────────────────────────────────────────────────────────────
+ R28. The colour stays where R23 put it — neutrals and a single magenta —
+ because the boldness is spent on type and layout instead: the prompt is set
+ in a display serif and the page is one column with almost nothing drawn on
+ it. Two things are allowed to be surfaces: the developing frame, and the
+ panel that pops open under a choice. */
+:root{
+ --ground:#F1F2F3; --surface:#FFFFFF; --sunken:#E5E7E9;
+ --line:#DCDEE1; --line-strong:#9AA0A7;
+ --ink:#101317; --muted:#5A626B;
+ --accent:#B5157C; --accent-ink:#FFFFFF; --accent-wash:#FBEAF4;
+ --ready:#0C737D; --fail:#B93129; --fail-wash:#FBE9E8;
+ --serif:"Instrument Serif",ui-serif,Georgia,serif;
+ --sans:"Instrument Sans",ui-sans-serif,system-ui,-apple-system,Segoe UI,sans-serif;
+ --mono:"Martian Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
+ --column:660px;
+ --radius:12px;
+}
+@media (prefers-color-scheme: dark){
+ :root:not([data-theme="light"]){
+ --ground:#0F1114; --surface:#171A1E; --sunken:#101317;
+ --line:#272B31; --line-strong:#767D85;
+ --ink:#E9EBEE; --muted:#98A0A9;
+ --accent:#F062AF; --accent-ink:#16040E; --accent-wash:#2A1220;
+ --ready:#43C2CA; --fail:#EE7078; --fail-wash:#2A1618;
+ }
+}
+:root[data-theme="dark"]{
+ --ground:#0F1114; --surface:#171A1E; --sunken:#101317;
+ --line:#272B31; --line-strong:#767D85;
+ --ink:#E9EBEE; --muted:#98A0A9;
+ --accent:#F062AF; --accent-ink:#16040E; --accent-wash:#2A1220;
+ --ready:#43C2CA; --fail:#EE7078; --fail-wash:#2A1618;
+}
+
+*{box-sizing:border-box}
+html,body{height:100%}
+body{
+ margin:0;background:var(--ground);color:var(--ink);
+ font-family:var(--sans);font-size:15px;line-height:1.55;
+ -webkit-font-smoothing:antialiased;
+}
+h1,h2,h3{margin:0;font-weight:600}
+button{font:inherit;color:inherit;background:none;border:none;cursor:pointer}
+button:disabled{opacity:.45;cursor:not-allowed}
+:focus-visible{outline:2px solid var(--accent);outline-offset:3px;border-radius:3px}
+
+/* ── shell ──────────────────────────────────────────────────────────────── */
+.bar{
+ display:flex;align-items:baseline;gap:14px;
+ max-width:var(--column);margin:0 auto;padding:26px 22px 0;
+}
+.wordmark{font-family:var(--serif);font-size:19px;letter-spacing:-.01em;
+ display:inline-flex;align-items:center;gap:9px}
+.wordmark .mark{color:var(--ink);flex:0 0 auto}
+.wordmark b{font-weight:400}
+.wordmark span{font-style:italic;color:var(--muted)}
+.status{
+ margin-left:auto;display:inline-flex;align-items:center;gap:8px;
+ font-family:var(--mono);font-size:10px;font-weight:300;letter-spacing:.04em;
+ color:var(--muted);
+}
+.dot{width:6px;height:6px;border-radius:50%;background:var(--ready)}
+[data-state="rendering"] .dot{background:var(--accent)}
+
+main{max-width:var(--column);margin:0 auto;padding:0 22px 64px}
+.stage{max-width:100%}
+
+/* ── the prompt is the page ─────────────────────────────────────────────── */
+.write{margin-top:44px}
+.prompt{
+ display:block;width:100%;border:0;padding:0;resize:none;background:none;
+ font-family:var(--serif);font-size:clamp(30px,4.2vw,40px);line-height:1.14;
+ letter-spacing:-.015em;color:var(--ink);caret-color:var(--accent);
+}
+.prompt::placeholder{color:var(--line-strong)}
+.prompt:focus{outline:none}
+.under{height:1px;background:var(--line);margin-top:22px;transition:background .18s ease}
+.write:focus-within .under{background:var(--accent)}
+
+/* R29 P1 — what the shot starts from, ends on and keeps hangs off the
+ prompt as chips; each one opens its picker where it stands. */
+.attach{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}
+.chip{
+ display:inline-flex;align-items:center;gap:8px;
+ padding:4px 12px 4px 4px;border:1px solid var(--line);
+ border-radius:999px;background:var(--surface);font-size:12.5px;
+}
+.chip img{
+ width:28px;height:28px;border-radius:999px;object-fit:cover;display:block;
+ background:var(--sunken);
+}
+.chip:hover{border-color:var(--line-strong)}
+.chip.add{padding-left:12px;color:var(--muted)}
+.chip.add:hover{color:var(--ink);border-color:var(--line-strong)}
+.chip .x{color:var(--line-strong);font-size:11px;margin-left:2px}
+.chip .x:hover{color:var(--fail)}
+
+/* The shot line: every choice is a word, and every word is a control. */
+.shot{
+ margin-top:16px;display:flex;flex-wrap:wrap;align-items:baseline;gap:0 9px;
+ font-size:14.5px;color:var(--muted);
+}
+.val{color:var(--ink);border-bottom:1px dotted var(--line-strong);padding-bottom:1px}
+.val:hover,.val[aria-expanded="true"]{color:var(--accent);border-bottom-color:var(--accent)}
+.sep{color:var(--line-strong)}
+.total{margin-left:auto;font-variant-numeric:tabular-nums}
+.total b{color:var(--ink);font-weight:600}
+
+/* The one thing on the page that pops. */
+.pick{
+ margin-top:14px;display:inline-flex;flex-wrap:wrap;gap:6px;padding:5px;
+ background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);
+}
+.pick.wide{display:block;padding:14px 16px;width:100%;max-width:430px}
+.pick button{padding:7px 13px;border-radius:8px;font-size:13.5px;text-align:left}
+.pick button:hover{background:var(--sunken)}
+.pick button[aria-pressed="true"]{background:var(--accent);color:var(--accent-ink)}
+.pick .n{display:block;font-weight:500}
+.pick .cost{display:block;font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums}
+.pick button[aria-pressed="true"] .cost{color:var(--accent-ink);opacity:.85}
+.pick input[type=range]{width:100%;accent-color:var(--accent);margin:4px 0 0}
+.pick .digits{
+ font-family:var(--mono);font-size:16px;letter-spacing:.16em;
+ font-variant-numeric:tabular-nums;
+}
+.pick .variation{display:flex;align-items:center;gap:14px}
+.pick .shuffle{border:1px solid var(--line);border-radius:999px;padding:5px 13px;font-size:12.5px}
+.pick .shuffle:hover{border-color:var(--accent);color:var(--accent);background:none}
+
+.go{display:flex;align-items:center;gap:20px;margin-top:34px;flex-wrap:wrap}
+.make{
+ background:var(--accent);color:var(--accent-ink);border-radius:999px;
+ padding:13px 30px;font-size:15px;font-weight:600;letter-spacing:-.005em;
+}
+.make:hover:not(:disabled){filter:brightness(1.08)}
+/* R29 P9 — the wait sits on the button, where others put credits. */
+.make .wait{
+ font-family:var(--mono);font-size:10.5px;font-weight:300;letter-spacing:.03em;
+ opacity:.85;font-variant-numeric:tabular-nums;
+}
+.more{color:var(--muted);font-size:14px}
+.more:hover{color:var(--ink)}
+
+.starts{margin-top:26px;display:flex;flex-wrap:wrap;gap:9px;align-items:baseline;font-size:13.5px}
+.starts em{font-style:normal;color:var(--muted)}
+.starts button{color:var(--ink);border-bottom:1px dotted var(--line-strong)}
+.starts button:hover{color:var(--accent);border-bottom-color:var(--accent)}
+/* R29 P10 — one line says the whole page accepts files. */
+.drop{margin-top:10px;font-size:12.5px;color:var(--muted)}
+.pair{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:16px}
+@media (max-width:620px){.pair{grid-template-columns:1fr}}
+
+/* An error is a sentence, and the engine's own words are one click away. */
+.wrong{margin-top:16px;font-size:14px;color:var(--fail);max-width:56ch}
+.wrong b{font-weight:600}
+.wrong button{
+ margin-left:8px;white-space:nowrap;color:var(--muted);font-size:12.5px;
+ border-bottom:1px dotted var(--line-strong);
+}
+.wrong button:hover{color:var(--ink)}
+.note{color:var(--muted);font-size:13.5px;margin:9px 0 0}
+.note b{color:var(--ink);font-weight:600}
+.cost{font-variant-numeric:tabular-nums}
+
+/* ── one panel with three tabs, instead of three panels ─────────────────── */
+.everything{margin-top:40px;padding-top:18px;border-top:1px solid var(--line)}
+.everything h2{
+ margin:0 0 14px;font-size:12px;font-weight:600;letter-spacing:.02em;color:var(--muted);
+}
+.tabs{display:flex;gap:18px;border-bottom:1px solid var(--line)}
+.tabs button{
+ padding:0 0 10px;font-size:13.5px;color:var(--muted);
+ border-bottom:2px solid transparent;margin-bottom:-1px;
+}
+.tabs button[aria-selected="true"]{color:var(--ink);border-bottom-color:var(--accent)}
+.inner{padding-top:20px}
+.row{display:grid;grid-template-columns:1fr 1fr;gap:18px 26px}
+@media (max-width:620px){.row{grid-template-columns:1fr}}
+.field{display:block;font-size:13.5px}
+.field>span{display:block;margin-bottom:5px}
+.field p{margin:0 0 7px;font-size:12.5px;color:var(--muted)}
+/* Not the checkboxes: they are not text fields, and stretching one to the
+ width of the column pushes its label away from it. */
+.field input:not([type="checkbox"]),.field select{
+ width:100%;padding:8px 10px;font:inherit;font-size:13.5px;color:var(--ink);
+ background:var(--ground);border:1px solid var(--line);border-radius:8px;
+}
+.field input:not([type="checkbox"]):focus,.field select:focus{outline:none;border-color:var(--accent)}
+.flag{font-family:var(--mono);font-size:10px;font-weight:300;color:var(--muted);margin-left:7px}
+.check{display:flex;align-items:flex-start;gap:9px;margin-top:16px;font-size:13.5px}
+/* The label takes the room it needs and no more: without this it stretches and
+ the checkboxes stop lining up. */
+.check>span{flex:1;text-align:left}
+.check input{flex:0 0 auto;margin-top:3px;accent-color:var(--accent)}
+.check code{
+ font-family:var(--mono);font-size:9.5px;color:var(--muted);margin-left:6px;
+ /* The longest parity flag is wider than half the panel: let that one wrap
+ rather than run off the edge. */
+ overflow-wrap:break-word;
+}
+/* The description goes under the name, never welded to the flag. */
+.check .why{display:block;margin:2px 0 0;font-size:12px}
+.checks{
+ display:grid;grid-template-columns:repeat(auto-fit,minmax(288px,1fr));
+ gap:2px 22px;margin-top:6px;
+}
+@media (max-width:620px){.checks{grid-template-columns:1fr}}
+.why{margin-top:18px;font-size:12.5px;color:var(--muted)}
+.delta{font-size:11.5px;color:var(--muted);font-variant-numeric:tabular-nums}
+
+/* ── references ─────────────────────────────────────────────────────────── */
+.reflist{margin-top:16px;display:flex;flex-direction:column;gap:8px}
+.ref{
+ display:flex;align-items:center;gap:11px;padding:9px 11px;
+ border:1px solid var(--line);border-radius:10px;font-size:13.5px;
+}
+.ref .k{font-family:var(--mono);font-size:10px;color:var(--muted);flex:0 0 auto}
+.ref .t{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.ref .m{margin-left:auto;display:flex;gap:8px;flex:0 0 auto}
+.ref .m button{font-size:12px;color:var(--muted)}
+.ref .m button:hover{color:var(--ink)}
+.counters{display:flex;gap:14px;margin-top:12px;font-size:12px;color:var(--muted)}
+.counters .n{font-variant-numeric:tabular-nums;color:var(--ink)}
+.btns{display:flex;flex-wrap:wrap;gap:9px;margin-top:14px}
+.rules{margin-top:14px;font-size:12.5px;color:var(--muted)}
+.tries{display:flex;gap:9px;flex-wrap:wrap;margin-top:12px;align-items:baseline}
+.tries em{font-style:normal;color:var(--muted);font-size:13px}
+.try{
+ font-size:13px;color:var(--ink);border:1px solid var(--line);
+ border-radius:999px;padding:4px 12px;
+}
+.try:hover{border-color:var(--accent);color:var(--accent)}
+.count{margin-left:8px;font-family:var(--mono);font-size:10px;color:var(--muted)}
+
+/* ── a photo, from the disk or from the library ─────────────────────────── */
+.slot{
+ display:flex;align-items:center;gap:11px;padding:12px 13px;width:100%;
+ text-align:left;border:1px dashed var(--line-strong);border-radius:10px;
+ background:none;
+}
+.slot.filled{border-style:solid}
+.slot:hover{border-color:var(--accent)}
+.slot .plus{
+ width:26px;height:26px;flex:0 0 26px;border-radius:6px;border:1px solid var(--line);
+ display:grid;place-items:center;color:var(--muted);font-size:15px;line-height:1;
+}
+.slot:hover .plus{color:var(--accent);border-color:var(--accent)}
+.slot .t{font-size:13.5px}
+.slot .s{display:block;color:var(--muted);font-size:12px}
+.slot .clear{margin-left:auto;color:var(--muted);font-size:12px}
+.slot .clear:hover{color:var(--fail)}
+.drop-over{border-color:var(--accent)!important}
+.lib{
+ display:inline-block;font-size:12.5px;color:var(--muted);
+ border-bottom:1px dotted var(--line-strong);
+}
+.lib:hover{color:var(--accent);border-bottom-color:var(--accent)}
+.libgrid{
+ display:grid;grid-template-columns:repeat(auto-fill,minmax(104px,1fr));
+ gap:10px;margin-top:12px;
+}
+.libpick{text-align:left}
+.libpick .thumb,.libpick img{
+ width:100%;aspect-ratio:16/10;object-fit:cover;display:block;
+ border-radius:8px;background:var(--sunken);
+}
+.libpick.on .thumb{outline:2px solid var(--accent);outline-offset:1px}
+.libpick .cap{
+ display:block;margin-top:5px;font-size:11px;color:var(--muted);
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
+}
+
+/* ── the developing frame: the one loud thing ───────────────────────────── */
+.summary{display:flex;align-items:baseline;gap:14px;margin-top:44px;flex-wrap:wrap}
+.quote{font-family:var(--serif);font-size:24px;line-height:1.2}
+.meta{font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}
+.summary .stop{font-size:13.5px;color:var(--muted)}
+.summary .stop:first-of-type{margin-left:auto}
+.summary .stop:hover{color:var(--ink)}
+/* Left, like everything else on the page: a centred frame reads as a
+ different column from the words under it. */
+.develop{width:fit-content;max-width:100%;margin-top:20px}
+.frame{
+ max-width:100%;background:var(--sunken);border-radius:14px;
+ overflow:hidden;position:relative;
+}
+.frame img,.frame video{
+ display:block;width:100%;height:100%;max-height:72vh;object-fit:contain;
+ background:var(--sunken);
+}
+.stepmark{
+ position:absolute;left:10px;bottom:10px;padding:3px 8px;border-radius:6px;
+ background:rgba(0,0,0,.55);color:#fff;font-family:var(--mono);font-size:10px;
+}
+/* Perforation, progress and phase in one line of ticks instead of three
+ separate readouts. */
+.rail{display:flex;gap:3px;margin-top:12px}
+.perf{flex:1;height:6px;border-radius:2px;background:var(--line)}
+.perf.done{background:var(--line-strong)}
+.perf.now{background:var(--accent)}
+.phase{display:flex;align-items:baseline;gap:12px;margin-top:14px;font-size:14.5px;flex-wrap:wrap}
+.said{font-weight:500}
+.clock{
+ margin-left:auto;display:flex;gap:12px;color:var(--muted);font-size:13.5px;
+ font-variant-numeric:tabular-nums;
+}
+.tech{
+ flex:0 0 100%;font-family:var(--mono);font-size:10px;font-weight:300;
+ color:var(--muted);
+}
+.spinner{color:var(--muted);font-size:13.5px}
+
+/* ── takes: a grid that plays on hover (R29 P3) ────────────────────────── */
+.takes{max-width:var(--column);margin:56px auto 0;padding:0 22px}
+.takes h2{font-size:12px;font-weight:600;color:var(--muted);margin:0 0 14px;letter-spacing:.02em}
+.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
+.take{min-width:0}
+.take .screen{
+ position:relative;aspect-ratio:16/10;border-radius:8px;overflow:hidden;
+ background:var(--sunken);cursor:pointer;
+}
+.take .poster,.take .playing{
+ position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;
+}
+/* An unreadable video has no poster: a quiet placeholder, not a broken
+ picture (T107). */
+.take .poster.gone{
+ display:grid;place-items:center;color:var(--muted);
+ font-family:var(--sans);font-size:11px;
+}
+.take .playing{display:none}
+.take:hover .poster{display:none}
+.take:hover .playing{display:block}
+/* actions surface only on hover, over a dark veil */
+.take .veil{
+ position:absolute;inset:0;display:flex;align-items:flex-end;gap:10px;
+ padding:8px;opacity:0;transition:opacity .15s ease;
+ background:linear-gradient(transparent 55%,rgba(0,0,0,.55));
+}
+.take:hover .veil,.take .veil:has(.asking){opacity:1}
+.take .veil button,.take .veil a{color:#fff;font-size:12px;text-shadow:0 1px 2px rgba(0,0,0,.6);text-decoration:none}
+.take .veil button:hover,.take .veil a:hover{text-decoration:underline}
+.take .veil .del{color:#fff;margin-left:auto}
+.take .veil .del:hover{color:var(--fail)}
+.take .veil .del.yes{color:#EE7078}
+.take .veil .asking{color:#fff}
+.take .cap{
+ display:block;margin-top:8px;font-family:var(--serif);font-size:14.5px;line-height:1.25;
+ overflow-wrap:anywhere;
+}
+.take .meta{
+ display:flex;gap:8px;font-size:11px;color:var(--muted);
+ font-variant-numeric:tabular-nums;
+}
+.empty{
+ border:1px dashed var(--line-strong);border-radius:var(--radius);
+ padding:22px;text-align:center;color:var(--muted);font-size:13.5px;
+}
+
+/* Deleting a take asks once, in place. */
+.del{font-size:11.5px;color:var(--muted);margin-left:auto}
+.del:hover{color:var(--fail)}
+.del.yes{color:var(--fail);font-weight:600}
+.asking{display:inline-flex;flex-wrap:wrap;align-items:center;gap:8px;font-size:11.5px}
+.take .asking{justify-content:flex-end;width:100%}
+.take .asking>span:first-child{flex:0 0 100%;text-align:right}
+.go .asking,.go .del{margin-left:12px;font-size:13px}
+
+/* ── what is being made, while something else is being written ──────────── */
+.live{
+ display:flex;align-items:center;gap:14px;margin-top:30px;
+ padding-bottom:18px;border-bottom:1px solid var(--line);
+}
+.live .peek{
+ width:64px;height:40px;flex:0 0 64px;border-radius:6px;overflow:hidden;
+ background:var(--sunken);padding:0;
+}
+.live .peek img{width:100%;height:100%;object-fit:cover;display:block}
+.live .what{flex:1;min-width:0}
+.live .t{font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.live .meter{height:3px;background:var(--line);border-radius:99px;margin:6px 0 5px;overflow:hidden}
+.live .meter>span{display:block;height:100%;background:var(--accent)}
+.live .clock{
+ display:flex;gap:12px;justify-content:space-between;margin:0;
+ font-size:11.5px;color:var(--muted);
+}
+.live .acts{display:flex;gap:14px;flex-shrink:0;font-size:13px}
+
+/* ── the queue, and jobs that stopped ───────────────────────────────────── */
+.queued{margin-top:26px;display:flex;flex-direction:column;gap:7px}
+.queued .one{display:flex;align-items:baseline;gap:11px;font-size:13px}
+.queued .st{
+ font-family:var(--mono);font-size:10px;font-weight:300;color:var(--muted);
+ flex:0 0 auto;
+}
+.queued .t{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.queued .lib{margin-left:auto;flex:0 0 auto}
+.undeleted{max-width:var(--column);margin:0 auto;padding:0 22px;color:var(--fail);font-size:13.5px}
+
+/* ── the log, when it is asked for ──────────────────────────────────────── */
+.modal{
+ position:fixed;inset:0;background:rgba(8,10,12,.5);display:grid;
+ place-items:center;padding:26px;z-index:10;
+}
+.sheet{
+ background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);
+ width:min(860px,100%);max-height:82vh;display:flex;flex-direction:column;
+}
+.sheet header{
+ display:flex;align-items:center;gap:12px;padding:13px 16px;
+ border-bottom:1px solid var(--line);font-size:13.5px;
+}
+.pad{padding:16px;overflow:auto}
+.log{
+ margin:0;font-family:var(--mono);font-size:11px;font-weight:300;line-height:1.7;
+ white-space:pre-wrap;color:var(--muted);
+}
+
+/* ── the door (R30): one column, same voice as the rest of the page ────── */
+.checking{
+ max-width:var(--column);margin:20vh auto 0;text-align:center;
+ color:var(--muted);font-size:14px;
+}
+.auth{max-width:380px;margin:14vh auto 0;padding:0 22px}
+.auth-mark{color:var(--ink);margin-bottom:18px}
+.auth h1{font-family:var(--serif);font-size:30px;font-weight:400;margin:0 0 22px}
+.auth .field{margin-bottom:14px}
+.auth .field .hint{color:var(--muted);font-size:12px}
+.auth .go{margin-top:20px}
+.auth .switch{margin-top:18px;font-size:13.5px;color:var(--muted)}
+.auth .switch button{color:var(--ink);border-bottom:1px dotted var(--line-strong)}
+.auth .switch button:hover{color:var(--accent);border-bottom-color:var(--accent)}
+
+/* Signed-in name in the header, with the way out next to it. */
+.whoami{
+ margin-left:14px;font-size:12.5px;color:var(--muted);white-space:nowrap;
+}
+.whoami button{margin-left:8px;font-size:12.5px;color:var(--muted)}
+.whoami button:hover{color:var(--ink)}
+.whoami .people-door{
+ margin-left:0;margin-right:10px;color:var(--ink);font-weight:500;
+}
+.whoami .people-door:hover{color:var(--accent)}
+.whoami .people-door.on{color:var(--accent)}
+
+/* ── people as a place of its own (T130) ─────────────────────────────── */
+.people-page{max-width:var(--column);margin:0 auto}
+.people-head{display:flex;align-items:baseline;margin:38px 0 4px}
+.people-head h2{font-family:var(--serif);font-size:27px;font-weight:400}
+.people-head .back{margin-left:auto;color:var(--muted);font-size:13.5px}
+.people-head .back:hover{color:var(--ink)}
+.people-page .live{margin-top:18px;border-bottom:1px solid var(--line);padding-bottom:16px}
+
+/* ── people: accounts and invites, for the administrator ───────────────── */
+.people{padding-top:16px;display:flex;flex-direction:column;gap:10px}
+.people .row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
+.people .who{font-size:14px}
+.people .who em{font-style:normal;color:var(--muted);font-size:12.5px}
+.people input[type="password"]{
+ padding:6px 10px;font:inherit;font-size:13px;color:var(--ink);
+ background:var(--ground);border:1px solid var(--line);border-radius:8px;
+}
+.people .invites .code{
+ font-family:var(--mono);font-size:12px;background:var(--accent-wash);
+ color:var(--ink);padding:4px 10px;border-radius:8px;user-select:all;
+}
+.people .invlist{margin-left:auto;font-size:12.5px;color:var(--muted)}
diff --git a/webui/frontend/src/types.ts b/webui/frontend/src/types.ts
new file mode 100644
index 00000000..fbe14015
--- /dev/null
+++ b/webui/frontend/src/types.ts
@@ -0,0 +1,132 @@
+export type ReferenceKind =
+ | "image"
+ | "video"
+ | "silent_video"
+ | "video_audio"
+ | "audio";
+
+export interface User {
+ id?: number;
+ username: string;
+ role: "admin" | "user";
+ created_at?: string;
+}
+
+export interface Invite {
+ code: string;
+ created_at: string;
+ used: boolean;
+}
+
+export interface Reference {
+ kind: ReferenceKind;
+ path: string;
+ audio_path?: string | null;
+ seconds?: number | null;
+ /** Local only: what to show in the list. */
+ label?: string;
+}
+
+export interface JobSpec {
+ prompt: string;
+ width: number;
+ height: number;
+ render_width: number;
+ render_height: number;
+ frames: number | null;
+ seconds: number | null;
+ steps: number;
+ denoise_reuse: number;
+ dit_layers: number;
+ core_reuse: number;
+ token_reduction: boolean;
+ ssd_streaming: boolean;
+ use_int8_row_fc2: boolean;
+ use_reference_rope: boolean;
+ seed: number;
+ first_frame: string | null;
+ last_frame: string | null;
+ references: Reference[];
+ reference_image_size: "match" | "max";
+ write_frames: boolean;
+ profile: boolean;
+ preview: boolean;
+ slower: string[];
+ postprocess: string[];
+}
+
+export type JobState =
+ | "queued"
+ | "running"
+ | "completed"
+ | "failed"
+ | "cancelled";
+
+export interface Job {
+ id: number;
+ state: JobState;
+ prompt: string;
+ params: JobSpec;
+ argv: string[] | null;
+ phase: string | null;
+ completed: number;
+ total: number;
+ progress: number;
+ error: string | null;
+ output_path: string | null;
+ created_at: string;
+ started_at: string | null;
+ finished_at: string | null;
+ elapsed: number | null;
+ remaining: number | null;
+ preview_step: number | null;
+ warnings?: string[];
+}
+
+export interface Asset {
+ id: number;
+ sha256: string;
+ kind: "image" | "video" | "audio";
+ filename: string;
+ path: string;
+ bytes: number;
+ metadata: {
+ seconds?: number | null;
+ width?: number | null;
+ height?: number | null;
+ has_audio?: boolean;
+ notes?: string[];
+ };
+ duplicate?: boolean;
+}
+
+export interface SystemInfo {
+ available: boolean;
+ reason?: string;
+ engine?: string;
+ version?: string;
+ device: Record;
+ components: Record;
+ has_ref2va?: boolean;
+}
+
+export interface Plugin {
+ name: string;
+ label: string;
+ description: string;
+ env_var: string;
+ available: boolean;
+ reason: string | null;
+ notice: string | null;
+}
+
+export interface Capabilities {
+ plugins: Plugin[];
+}
+
+export interface ValidationReport {
+ errors: string[];
+ warnings: string[];
+ frames: number;
+ seconds: number;
+}
diff --git a/webui/frontend/src/useEstimates.ts b/webui/frontend/src/useEstimates.ts
new file mode 100644
index 00000000..590d2ba1
--- /dev/null
+++ b/webui/frontend/src/useEstimates.ts
@@ -0,0 +1,54 @@
+import { useEffect, useState } from "react";
+
+import type { JobSpec } from "./types";
+
+export interface Estimates {
+ seconds: number | null;
+ variants: (number | null)[];
+ learnedFrom: number;
+}
+
+/** One request labels the current settings and every alternative on screen. */
+export function useEstimates(
+ spec: JobSpec,
+ variants: Partial[],
+): Estimates {
+ const [estimates, setEstimates] = useState({
+ seconds: null,
+ variants: [],
+ learnedFrom: 0,
+ });
+ const key = JSON.stringify([spec, variants]);
+
+ useEffect(() => {
+ const controller = new AbortController();
+ const timer = setTimeout(() => {
+ fetch("/api/jobs/estimate", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ spec, variants }),
+ signal: controller.signal,
+ })
+ .then((response) => (response.ok ? response.json() : null))
+ .then((body) => {
+ if (!body) return;
+ setEstimates({
+ seconds: body.seconds ?? null,
+ variants: (body.variants ?? []).map(
+ (variant: { seconds?: number }) => variant.seconds ?? null,
+ ),
+ learnedFrom: body.learned_from ?? 0,
+ });
+ })
+ .catch(() => undefined);
+ }, 250);
+ return () => {
+ controller.abort();
+ clearTimeout(timer);
+ };
+ // `key` is the serialised request: it changes exactly when the answer would.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [key]);
+
+ return estimates;
+}
diff --git a/webui/frontend/tsconfig.json b/webui/frontend/tsconfig.json
new file mode 100644
index 00000000..a6308d1e
--- /dev/null
+++ b/webui/frontend/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "types": ["node"]
+ },
+ "include": ["src", "vite.config.ts", "../shared/*.json"]
+}
diff --git a/webui/frontend/vite.config.ts b/webui/frontend/vite.config.ts
new file mode 100644
index 00000000..40536d55
--- /dev/null
+++ b/webui/frontend/vite.config.ts
@@ -0,0 +1,21 @@
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vite";
+
+// The dev server proxies /api to the backend, so the browser sees one origin
+// and there is no CORS configuration to get wrong. In production nginx does
+// the same (see docker/nginx.conf).
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ host: "127.0.0.1",
+ port: 5173,
+ proxy: {
+ "/api": {
+ // H3_API points the dev server at a backend on another port.
+ target: process.env.H3_API ?? "http://127.0.0.1:8000",
+ changeOrigin: true,
+ },
+ },
+ },
+ build: { outDir: "dist", sourcemap: false },
+});
diff --git a/webui/shared/copy.json b/webui/shared/copy.json
new file mode 100644
index 00000000..d8da0fb6
--- /dev/null
+++ b/webui/shared/copy.json
@@ -0,0 +1,289 @@
+{
+ "version": 1,
+ "description": "Plain-language names for everything the UI shows. The engine's own wording is kept beside each entry, never instead of it: this file is what the interface says, options.schema.json is what h3 accepts.",
+ "options": {
+ "prompt": {
+ "name": "What the video shows",
+ "help": "Describe the scene in a sentence. What is in it, what moves, how it is filmed."
+ },
+ "seconds": {
+ "name": "How long",
+ "help": "The length of the video. Lengths come in fixed steps, so the number is rounded up to the nearest one.",
+ "time": "longer videos take proportionally longer to make"
+ },
+ "frames": {
+ "name": "How long, in frames",
+ "help": "The same setting counted in frames instead of seconds. 24 frames make a second.",
+ "time": "longer videos take proportionally longer to make"
+ },
+ "width": {
+ "name": "Shape",
+ "help": "How wide and tall the video is. Widescreen for a screen, vertical for a phone.",
+ "time": "twice the area is roughly twice the wait"
+ },
+ "height": {
+ "name": "Shape",
+ "help": "How wide and tall the video is. Widescreen for a screen, vertical for a phone.",
+ "time": "twice the area is roughly twice the wait"
+ },
+ "render_width": {
+ "name": "Work smaller, then enlarge",
+ "help": "Draw at a smaller size and scale the result up. Much faster, and fine detail is lost.",
+ "time": "can halve the wait"
+ },
+ "render_height": {
+ "name": "Work smaller, then enlarge",
+ "help": "Draw at a smaller size and scale the result up. Much faster, and fine detail is lost.",
+ "time": "can halve the wait"
+ },
+ "steps": {
+ "name": "Detail passes",
+ "help": "How many times the picture is refined. More passes bring out more detail.",
+ "time": "each pass adds a fixed amount of time"
+ },
+ "dit_layers": {
+ "name": "Model depth",
+ "help": "How much of the model runs. Less is faster and a little looser.",
+ "time": "cutting five layers saves a few percent"
+ },
+ "denoise_reuse": {
+ "name": "How often it redraws",
+ "help": "Redrawing at every pass is closest to the reference. Redrawing less often is faster, and the framing can shift.",
+ "time": "can save around a third of the drawing time"
+ },
+ "core_reuse": {
+ "name": "How often it redraws the main part",
+ "help": "Keeps the quick work fresh at every pass and runs the expensive part less often. An alternative to redrawing less often, not a companion to it.",
+ "time": "similar saving, different trade-off"
+ },
+ "token_reduction": {
+ "name": "Pair up detail while drawing",
+ "help": "Treats neighbouring detail as one while drawing. Faster, and the composition can drift. Leave it off at small sizes.",
+ "time": "saves roughly a quarter of the drawing time"
+ },
+ "ssd_streaming": {
+ "name": "Use less graphics memory",
+ "help": "Reads the model from disk as it goes instead of holding it all in memory. The result is identical; it takes longer.",
+ "time": "about 38 % slower, and needs 1.6 GB instead of 27 GB"
+ },
+ "use_int8_row_fc2": {
+ "name": "Faster maths on Apple M5",
+ "help": "A shortcut that only exists on Apple's M5 chips. On this machine it changes nothing."
+ },
+ "use_reference_rope": {
+ "name": "Original layout grid",
+ "help": "Restores the layout grid as first released, for comparing against reference results."
+ },
+ "seed": {
+ "name": "Variation",
+ "help": "Two videos made with the same settings and the same variation come out the same. Change it to get a different take on the same description."
+ },
+ "first_frame": {
+ "name": "Start from a photo",
+ "help": "The video begins at this picture."
+ },
+ "last_frame": {
+ "name": "End on a photo",
+ "help": "The video ends at this picture."
+ },
+ "ref_image": {
+ "name": "Reference photo",
+ "help": "A photo to take the subject, the look or the setting from. The order of references matters."
+ },
+ "ref_video": {
+ "name": "Reference clip",
+ "help": "A clip to continue or take the look from, keeping its sound."
+ },
+ "ref_silent_video": {
+ "name": "Reference clip, without its sound",
+ "help": "The same, but the clip's own audio is ignored."
+ },
+ "ref_video_audio": {
+ "name": "Reference clip with a different soundtrack",
+ "help": "A clip for the picture and a separate file for the sound."
+ },
+ "ref_audio": {
+ "name": "Reference sound",
+ "help": "A sound to take the mood or the music from. It needs a photo or a clip beside it, and must last between 2 and 15 seconds."
+ },
+ "reference_image_size": {
+ "name": "How reference photos are sized",
+ "help": "Match keeps each photo at the video's shape. Max keeps as much of the photo as the model allows."
+ },
+ "frames_dir": {
+ "name": "Save every frame",
+ "help": "Writes each finished frame as a separate picture file, next to the video."
+ },
+ "preview": {
+ "name": "Watch it being made",
+ "help": "Shows the picture after every pass, so you can stop early if it is going the wrong way.",
+ "time": "adds a short setup and a moment per pass"
+ },
+ "profile": {
+ "name": "Record timings",
+ "help": "Writes how long each stage took into the job's log."
+ },
+ "slower": {
+ "name": "Reference-exact mode",
+ "help": "Forces the slowest, closest-to-reference way of computing each part. For comparing against reference numbers, not for making videos."
+ },
+ "output": {
+ "name": "Where the video goes",
+ "help": "The server picks a folder for each job."
+ },
+ "model_dir": {
+ "name": "Model folder",
+ "help": "Set once when the service starts."
+ }
+ },
+ "phases": {
+ "tokenizer": "Reading your words",
+ "text encoder": "Understanding the description",
+ "refine text": "Understanding the description",
+ "Qwen vision": "Looking at your pictures",
+ "precompute AdaLN": "Getting the model ready",
+ "load transformer core": "Loading the model",
+ "denoise": "Painting the picture",
+ "denoise enqueue": "Painting the picture",
+ "preview VAE load": "Getting the preview ready",
+ "audio VAE": "Preparing the sound",
+ "audio VAE encoder": "Listening to your audio",
+ "video VAE encoder": "Watching your clips",
+ "video VAE load": "Developing the frames",
+ "FFmpeg": "Assembling the video"
+ },
+ "states": {
+ "queued": "Waiting its turn",
+ "running": "Being made",
+ "completed": "Ready",
+ "failed": "Did not finish",
+ "cancelled": "Stopped"
+ },
+ "errors": [
+ {
+ "match": "a prompt is required",
+ "title": "Tell it what to show first.",
+ "fix": "Describe the scene in a sentence — what is in it, what moves, how it is filmed."
+ },
+ {
+ "match": "width and height must be multiples of 32",
+ "title": "That size is not one the model can draw.",
+ "fix": "Width and height both have to be multiples of 32, and at least 32. Pick a shape above, or round your numbers to the nearest 32."
+ },
+ {
+ "match": "canvas exceeds the released 768*1344 pixel limit",
+ "title": "That size is larger than the model can make.",
+ "fix": "The largest is 1344 × 768, however you arrange it. Pick a smaller shape."
+ },
+ {
+ "match": "render width and height must be set together",
+ "title": "The smaller working size needs both numbers.",
+ "fix": "Set the working width and height together, or turn the option off."
+ },
+ {
+ "match": "internal render canvas must be same-aspect",
+ "title": "The smaller working size has to match the shape of the video.",
+ "fix": "Use the same proportions as the output, in multiples of 32, and never larger than it."
+ },
+ {
+ "match": "--seconds and --frames are mutually exclusive",
+ "title": "Choose one way to set the length.",
+ "fix": "Set the length in seconds or in frames, not both."
+ },
+ {
+ "match": "invalid seconds",
+ "title": "That length is not a number the model can use.",
+ "fix": "Use a length between 0.9 and 15.1 seconds."
+ },
+ {
+ "match": "frames must align within the released 5..362 range",
+ "title": "That length is outside what the model was released for.",
+ "fix": "Videos run from about 0.9 to about 15.1 seconds. Pick a length in that range."
+ },
+ {
+ "match": "generation requires at least one trained 22-frame decoder chunk",
+ "title": "That video would be too short to make.",
+ "fix": "The shortest the model can produce is 22 frames, just under a second."
+ },
+ {
+ "match": "denoising steps must be in [2, 1000]",
+ "title": "That number of detail passes is out of range.",
+ "fix": "Use between 2 and 1000 passes. Twenty is the usual choice."
+ },
+ {
+ "match": "denoise reuse must be in [1, 3]",
+ "title": "That amount of reuse is out of range.",
+ "fix": "Reuse between passes goes from 1 (none) to 3 (a lot)."
+ },
+ {
+ "match": "DiT layers must be in [35, 50]",
+ "title": "That model depth is out of range.",
+ "fix": "Depth goes from 35 to 50. Fifty runs the whole model."
+ },
+ {
+ "match": "core reuse must be in [1, 6]",
+ "title": "That amount of reuse is out of range.",
+ "fix": "Reusing the heavy part goes from 1 (never) to 6 (every sixth pass)."
+ },
+ {
+ "match": "core reuse and denoiser reuse cannot be combined",
+ "title": "Those two shortcuts cannot be used together.",
+ "fix": "Pick either reuse between passes or reuse of the heavy part, and leave the other at 1."
+ },
+ {
+ "match": "SSD streaming uses original BF16 weights",
+ "title": "Those two settings do not work together.",
+ "fix": "Using less graphics memory reads the model in its original form, which the Apple M5 shortcut cannot use. Turn one of them off."
+ },
+ {
+ "match": "int8 row FC2 cannot be combined with the BF16 MLP",
+ "title": "Those two settings do not work together.",
+ "fix": "The Apple M5 shortcut and the reference-exact maths are alternatives. Turn one of them off."
+ },
+ {
+ "match": "full references cannot be combined with frame anchors",
+ "title": "Reference material and start or end photos are two different ways to work.",
+ "fix": "Either start and end on your own photos, or give references to draw from. Remove one of the two."
+ },
+ {
+ "match": "Ref2VA supports at most 12 references",
+ "title": "That is more reference material than the model accepts.",
+ "fix": "Keep at most 12 items in total. Remove one before adding another."
+ },
+ {
+ "match": "Ref2VA limits are 9 images, 3 videos, and 3 audio inputs",
+ "title": "Too much of one kind of reference.",
+ "fix": "At most 9 photos, 3 clips and 3 sounds. Remove one of the kind that is full."
+ },
+ {
+ "match": "reference audio requires an image or video reference",
+ "title": "Sound needs something to go with.",
+ "fix": "Add a reference photo or clip beside the sound, or remove the sound."
+ },
+ {
+ "match": "has no soundtrack path",
+ "title": "That clip is missing its soundtrack.",
+ "fix": "Choose the audio file that goes with the clip, or use the clip with its own sound instead."
+ },
+ {
+ "match": "a video soundtrack requires at least 2 seconds",
+ "title": "The video is too short to carry the clip's sound.",
+ "fix": "A clip's sound is trimmed to the length of the video, and needs at least 2 seconds. Make the video at least 2.4 seconds long, or use the clip without its sound."
+ },
+ {
+ "match": "requires at least 2 seconds: the clip is only",
+ "title": "That clip is too short to take sound from.",
+ "fix": "Use a clip of at least 2 seconds, or add it without its sound."
+ },
+ {
+ "match": "reference audio requires at least 2 seconds at 32 kHz",
+ "title": "That sound is too short.",
+ "fix": "Reference sound has to last at least 2 seconds."
+ },
+ {
+ "match": "ordered reference audio exceeds 15 seconds in total",
+ "title": "That is more sound than the model can take in.",
+ "fix": "All the reference sounds together have to fit in 15 seconds. Remove one, or use a shorter file."
+ }
+ ]
+}
diff --git a/webui/shared/options.schema.json b/webui/shared/options.schema.json
new file mode 100644
index 00000000..423835e3
--- /dev/null
+++ b/webui/shared/options.schema.json
@@ -0,0 +1,696 @@
+{
+ "version": 1,
+ "description": "Canonical inventory of the h3.c CLI generation options. Single source of truth shared by the web UI backend and frontend. Hand-maintained; webui/backend/tests/test_schema_matches_cli.py fails if it drifts from main.c.",
+ "sources": [
+ "main.c",
+ "h3.h",
+ "h3.c",
+ "h3_host.h",
+ "README.md"
+ ],
+ "constants": {
+ "fps": 24,
+ "canvas_multiple": 32,
+ "min_canvas": 32,
+ "max_pixels": 1032192,
+ "max_pixels_label": "768 * 1344",
+ "max_steps": 1000,
+ "frames": {
+ "align_base": 5,
+ "align_stride": 17,
+ "min_request": 5,
+ "max_aligned": 362,
+ "min_generation": 22,
+ "note": "Requests are rounded up to 5 + 17*n. Generation needs at least one 22-frame decoder chunk."
+ },
+ "audio": {
+ "sample_rate": 32000,
+ "min_seconds": 2,
+ "max_total_seconds": 15
+ },
+ "dit_layers": {
+ "min": 35,
+ "max": 50
+ }
+ },
+ "groups": [
+ {
+ "id": "content",
+ "label": "Content"
+ },
+ {
+ "id": "output",
+ "label": "Output canvas"
+ },
+ {
+ "id": "duration",
+ "label": "Duration"
+ },
+ {
+ "id": "sampler",
+ "label": "Sampler"
+ },
+ {
+ "id": "memory",
+ "label": "Memory and backend"
+ },
+ {
+ "id": "references",
+ "label": "References"
+ },
+ {
+ "id": "diagnostics",
+ "label": "Diagnostics"
+ },
+ {
+ "id": "parity",
+ "label": "Parity and debug flags"
+ },
+ {
+ "id": "server",
+ "label": "Managed by the server"
+ }
+ ],
+ "options": [
+ {
+ "flag": "--model-dir",
+ "short": "-d",
+ "key": "model_dir",
+ "type": "path",
+ "group": "server",
+ "ui": "hidden",
+ "role": "server",
+ "default": null,
+ "label": "Model directory",
+ "help": "Set from H3_MODEL_DIR; never chosen by the browser."
+ },
+ {
+ "flag": "--prompt",
+ "short": "-p",
+ "key": "prompt",
+ "type": "text",
+ "group": "content",
+ "ui": "simple",
+ "required": true,
+ "default": "",
+ "label": "Prompt"
+ },
+ {
+ "flag": "--output",
+ "short": "-o",
+ "key": "output",
+ "type": "path",
+ "group": "output",
+ "ui": "advanced",
+ "role": "server",
+ "default": "outputs/h3.mp4",
+ "label": "Output file",
+ "help": "Server-assigned per job. An empty value disables MP4 encoding."
+ },
+ {
+ "flag": "--width",
+ "key": "width",
+ "type": "int",
+ "group": "output",
+ "ui": "simple",
+ "default": 864,
+ "min": 32,
+ "multiple_of": 32,
+ "label": "Width"
+ },
+ {
+ "flag": "--height",
+ "key": "height",
+ "type": "int",
+ "group": "output",
+ "ui": "simple",
+ "default": 480,
+ "min": 32,
+ "multiple_of": 32,
+ "label": "Height"
+ },
+ {
+ "flag": "--render-width",
+ "key": "render_width",
+ "type": "int",
+ "group": "output",
+ "ui": "advanced",
+ "default": 0,
+ "min": 32,
+ "multiple_of": 32,
+ "label": "Internal render width",
+ "help": "Optional lower internal canvas; upscaled to the output size."
+ },
+ {
+ "flag": "--render-height",
+ "key": "render_height",
+ "type": "int",
+ "group": "output",
+ "ui": "advanced",
+ "default": 0,
+ "min": 32,
+ "multiple_of": 32,
+ "label": "Internal render height"
+ },
+ {
+ "flag": "--frames",
+ "key": "frames",
+ "type": "int",
+ "group": "duration",
+ "ui": "simple",
+ "default": 56,
+ "min": 5,
+ "max": 362,
+ "label": "Frames",
+ "help": "Rounded up to 5 + 17*n."
+ },
+ {
+ "flag": "--seconds",
+ "key": "seconds",
+ "type": "float",
+ "group": "duration",
+ "ui": "simple",
+ "default": null,
+ "min": 0.917,
+ "max": 15.083,
+ "label": "Duration (s)",
+ "help": "Converted at 24 fps, then rounded up to the next legal temporal shape."
+ },
+ {
+ "flag": "--steps",
+ "key": "steps",
+ "type": "int",
+ "group": "sampler",
+ "ui": "simple",
+ "default": 20,
+ "min": 2,
+ "max": 1000,
+ "label": "Denoising steps"
+ },
+ {
+ "flag": "--reuse",
+ "key": "denoise_reuse",
+ "type": "int",
+ "group": "sampler",
+ "ui": "advanced",
+ "default": 1,
+ "min": 1,
+ "max": 3,
+ "label": "Denoiser reuse",
+ "help": "1 close, 2 fast, 3 aggressive."
+ },
+ {
+ "flag": "--layers",
+ "key": "dit_layers",
+ "type": "int",
+ "group": "sampler",
+ "ui": "advanced",
+ "default": 50,
+ "min": 35,
+ "max": 50,
+ "label": "Active DiT blocks",
+ "help": "50 exact, 45 fast, 40 aggressive."
+ },
+ {
+ "flag": "--core-reuse",
+ "key": "core_reuse",
+ "type": "int",
+ "group": "sampler",
+ "ui": "advanced",
+ "default": 1,
+ "min": 1,
+ "max": 6,
+ "label": "Core residual reuse",
+ "help": "1 exact, 4 fast, 6 aggressive."
+ },
+ {
+ "flag": "--token-reduction",
+ "key": "token_reduction",
+ "type": "bool",
+ "group": "sampler",
+ "ui": "advanced",
+ "default": false,
+ "label": "Token reduction",
+ "help": "Pairs horizontal video tokens in middle DiT blocks. Keep off at 256 square."
+ },
+ {
+ "flag": "--ssd-streaming",
+ "key": "ssd_streaming",
+ "type": "bool",
+ "group": "memory",
+ "ui": "advanced",
+ "default": false,
+ "label": "SSD streaming",
+ "help": "Exact low-memory mode: GB10 DiT peak 27.06 GB to 1.63 GB, but slower."
+ },
+ {
+ "flag": "--use-int8-row-fc2",
+ "key": "use_int8_row_fc2",
+ "type": "bool",
+ "group": "memory",
+ "ui": "advanced",
+ "default": false,
+ "backends": [
+ "metal"
+ ],
+ "label": "int8 row FC2",
+ "help": "Metal/M5 specialization; a measured no-op on CUDA."
+ },
+ {
+ "flag": "--use-reference-rope",
+ "key": "use_reference_rope",
+ "type": "bool",
+ "group": "memory",
+ "ui": "advanced",
+ "default": false,
+ "label": "Reference RoPE",
+ "help": "Restores the released 256x256 spatial RoPE grid for parity checks."
+ },
+ {
+ "flag": "--use-slower-bf16-mlp",
+ "key": "use_slower_bf16_mlp",
+ "type": "bool",
+ "group": "parity",
+ "ui": "advanced",
+ "default": false,
+ "label": "BF16 MLP"
+ },
+ {
+ "flag": "--use-slower-bf16-qkv",
+ "key": "use_slower_bf16_qkv",
+ "type": "bool",
+ "group": "parity",
+ "ui": "advanced",
+ "default": false,
+ "label": "BF16 QKV"
+ },
+ {
+ "flag": "--use-slower-bf16-attention-output",
+ "key": "use_slower_bf16_attention_output",
+ "type": "bool",
+ "group": "parity",
+ "ui": "advanced",
+ "default": false,
+ "label": "BF16 attention output"
+ },
+ {
+ "flag": "--use-slower-row-major-attention-output",
+ "key": "use_slower_row_major_attention_output",
+ "type": "bool",
+ "group": "parity",
+ "ui": "advanced",
+ "default": false,
+ "label": "Row-major attention output"
+ },
+ {
+ "flag": "--use-slower-unfused-int8-inputs",
+ "key": "use_slower_unfused_int8_inputs",
+ "type": "bool",
+ "group": "parity",
+ "ui": "advanced",
+ "default": false,
+ "label": "Unfused int8 inputs"
+ },
+ {
+ "flag": "--use-slower-unfused-qkv-rope",
+ "key": "use_slower_unfused_qkv_rope",
+ "type": "bool",
+ "group": "parity",
+ "ui": "advanced",
+ "default": false,
+ "label": "Unfused QKV RoPE"
+ },
+ {
+ "flag": "--use-slower-scalar-qkv-rms",
+ "key": "use_slower_scalar_qkv_rms",
+ "type": "bool",
+ "group": "parity",
+ "ui": "advanced",
+ "default": false,
+ "label": "Scalar QKV RMS"
+ },
+ {
+ "flag": "--use-slower-uncached-int8-scales",
+ "key": "use_slower_uncached_int8_scales",
+ "type": "bool",
+ "group": "parity",
+ "ui": "advanced",
+ "default": false,
+ "label": "Uncached int8 scales"
+ },
+ {
+ "flag": "--use-slower-dynamic-fc1-k",
+ "key": "use_slower_dynamic_fc1_k",
+ "type": "bool",
+ "group": "parity",
+ "ui": "advanced",
+ "default": false,
+ "label": "Dynamic FC1 K loop"
+ },
+ {
+ "flag": "--use-slower-grouped-quantizer",
+ "key": "use_slower_grouped_quantizer",
+ "type": "bool",
+ "group": "parity",
+ "ui": "advanced",
+ "default": false,
+ "label": "Grouped quantizer"
+ },
+ {
+ "flag": "--seed",
+ "key": "seed",
+ "type": "uint64",
+ "group": "content",
+ "ui": "simple",
+ "default": 42,
+ "label": "Seed"
+ },
+ {
+ "flag": "--first-frame",
+ "key": "first_frame",
+ "type": "asset",
+ "asset_kind": "image",
+ "group": "content",
+ "ui": "simple",
+ "default": null,
+ "label": "First frame"
+ },
+ {
+ "flag": "--last-frame",
+ "key": "last_frame",
+ "type": "asset",
+ "asset_kind": "image",
+ "group": "content",
+ "ui": "simple",
+ "default": null,
+ "label": "Last frame"
+ },
+ {
+ "flag": "--ref-image",
+ "key": "ref_image",
+ "type": "reference",
+ "reference_kind": "image",
+ "group": "references",
+ "ui": "advanced",
+ "repeatable": true,
+ "label": "Reference image"
+ },
+ {
+ "flag": "--ref-image-size",
+ "key": "reference_image_size",
+ "type": "enum",
+ "values": [
+ "match",
+ "max"
+ ],
+ "group": "references",
+ "ui": "advanced",
+ "default": "match",
+ "label": "Reference image sizing"
+ },
+ {
+ "flag": "--ref-video",
+ "key": "ref_video",
+ "type": "reference",
+ "reference_kind": "video",
+ "group": "references",
+ "ui": "advanced",
+ "repeatable": true,
+ "label": "Reference video (keep audio)"
+ },
+ {
+ "flag": "--ref-silent-video",
+ "key": "ref_silent_video",
+ "type": "reference",
+ "reference_kind": "silent_video",
+ "group": "references",
+ "ui": "advanced",
+ "repeatable": true,
+ "label": "Reference video (drop audio)"
+ },
+ {
+ "flag": "--ref-video-audio",
+ "key": "ref_video_audio",
+ "type": "reference",
+ "reference_kind": "video_audio",
+ "group": "references",
+ "ui": "advanced",
+ "repeatable": true,
+ "arity": 2,
+ "label": "Reference video + soundtrack"
+ },
+ {
+ "flag": "--ref-audio",
+ "key": "ref_audio",
+ "type": "reference",
+ "reference_kind": "audio",
+ "group": "references",
+ "ui": "advanced",
+ "repeatable": true,
+ "label": "Reference audio"
+ },
+ {
+ "flag": "--frames-dir",
+ "key": "frames_dir",
+ "type": "path",
+ "group": "diagnostics",
+ "ui": "advanced",
+ "default": null,
+ "label": "Write frames as PPM",
+ "help": "Server-assigned when enabled."
+ },
+ {
+ "flag": "--preview-dir",
+ "key": "preview",
+ "type": "path",
+ "group": "diagnostics",
+ "ui": "simple",
+ "role": "server",
+ "default": null,
+ "label": "Live preview",
+ "help": "Writes a PPM after every denoising step. The UI exposes it as a toggle and assigns the directory. Adds a preview VAE load phase and one decode per step."
+ },
+ {
+ "flag": "--profile",
+ "key": "profile",
+ "type": "bool",
+ "group": "diagnostics",
+ "ui": "advanced",
+ "default": false,
+ "label": "Profile phases"
+ },
+ {
+ "flag": "--show",
+ "key": "show",
+ "type": "bool",
+ "group": "server",
+ "ui": "hidden",
+ "role": "excluded",
+ "default": false,
+ "label": "Terminal preview",
+ "help": "Kitty/Ghostty graphics protocol; meaningless in a browser. Live preview uses --preview-dir (task 82)."
+ },
+ {
+ "flag": "--zoom",
+ "key": "zoom",
+ "type": "int",
+ "group": "server",
+ "ui": "hidden",
+ "role": "excluded",
+ "default": 2,
+ "label": "Terminal zoom",
+ "help": "Terminal-only display factor."
+ },
+ {
+ "flag": "--info",
+ "key": "info",
+ "type": "bool",
+ "group": "server",
+ "ui": "hidden",
+ "role": "server",
+ "default": false,
+ "label": "Model and device inventory",
+ "help": "Used by GET /api/system."
+ },
+ {
+ "flag": "--help",
+ "short": "-h",
+ "key": "help",
+ "type": "bool",
+ "group": "server",
+ "ui": "hidden",
+ "role": "excluded",
+ "default": false,
+ "label": "CLI help"
+ }
+ ],
+ "mutual_exclusions": [
+ {
+ "keys": [
+ "frames",
+ "seconds"
+ ],
+ "message": "--seconds and --frames are mutually exclusive",
+ "source": "main.c"
+ },
+ {
+ "condition": "core_reuse > 1 and denoise_reuse > 1",
+ "message": "core reuse and denoiser reuse cannot be combined",
+ "source": "h3.c"
+ },
+ {
+ "condition": "ssd_streaming and use_int8_row_fc2",
+ "message": "SSD streaming uses original BF16 weights and cannot be combined with int8 row FC2",
+ "source": "h3.c"
+ },
+ {
+ "condition": "use_int8_row_fc2 and use_slower_bf16_mlp",
+ "message": "int8 row FC2 cannot be combined with the BF16 MLP",
+ "source": "h3.c"
+ },
+ {
+ "condition": "references and (first_frame or last_frame)",
+ "message": "full references cannot be combined with frame anchors",
+ "source": "h3.c"
+ }
+ ],
+ "constraints": [
+ {
+ "id": "canvas_multiple",
+ "message": "width and height must be multiples of 32 and at least 32"
+ },
+ {
+ "id": "max_pixels",
+ "message": "canvas exceeds the released 768*1344 pixel limit"
+ },
+ {
+ "id": "render_pair",
+ "message": "render width and height must be set together"
+ },
+ {
+ "id": "render_shape",
+ "message": "internal render canvas must be same-aspect multiples of 32 no larger than the output canvas"
+ },
+ {
+ "id": "frame_range",
+ "message": "frames must align within the released 5..362 range"
+ },
+ {
+ "id": "frame_minimum",
+ "message": "generation requires at least one trained 22-frame decoder chunk"
+ },
+ {
+ "id": "steps_range",
+ "message": "denoising steps must be in [2, 1000]"
+ },
+ {
+ "id": "reuse_range",
+ "message": "denoise reuse must be in [1, 3]"
+ },
+ {
+ "id": "layers_range",
+ "message": "DiT layers must be in [35, 50]"
+ },
+ {
+ "id": "core_reuse_range",
+ "message": "core reuse must be in [1, 6]"
+ }
+ ],
+ "references": {
+ "max_total": 12,
+ "max_images": 9,
+ "max_videos": 3,
+ "max_audio_inputs": 3,
+ "ordered": true,
+ "requires_checkpoint": "ref2va",
+ "rules": [
+ {
+ "id": "audio_needs_visual",
+ "message": "reference audio requires an image or video reference"
+ },
+ {
+ "id": "audio_minimum",
+ "message": "reference audio requires at least 2 seconds at 32 kHz"
+ },
+ {
+ "id": "audio_total",
+ "message": "ordered reference audio exceeds 15 seconds in total"
+ },
+ {
+ "id": "soundtrack_duration",
+ "message": "a video soundtrack is truncated to the output duration and needs at least 2 seconds: request at least 56 output frames"
+ },
+ {
+ "id": "video_audio_path",
+ "message": "a video+audio reference needs a soundtrack path"
+ }
+ ]
+ },
+ "canvas_presets": [
+ {
+ "label": "256 square (fast preview)",
+ "width": 256,
+ "height": 256
+ },
+ {
+ "label": "512 square (development)",
+ "width": 512,
+ "height": 512
+ },
+ {
+ "label": "768 square",
+ "width": 768,
+ "height": 768
+ },
+ {
+ "label": "1344x768 landscape",
+ "width": 1344,
+ "height": 768
+ },
+ {
+ "label": "768x1344 portrait",
+ "width": 768,
+ "height": 1344
+ },
+ {
+ "label": "1024x768",
+ "width": 1024,
+ "height": 768
+ },
+ {
+ "label": "768x1024",
+ "width": 768,
+ "height": 1024
+ }
+ ],
+ "quality_presets": [
+ {
+ "id": "draft",
+ "label": "Draft",
+ "steps": 20,
+ "dit_layers": 40,
+ "denoise_reuse": 3,
+ "token_reduction": false,
+ "note": "The validated aggressive preview: fewer blocks, redrawn rarely, and drawn at 62.5 % of the output before being enlarged. Do not add token reduction on top of layers 40 and reuse 3.",
+ "render_scale": 0.625
+ },
+ {
+ "id": "balanced",
+ "label": "Balanced",
+ "steps": 20,
+ "dit_layers": 45,
+ "denoise_reuse": 2,
+ "token_reduction": true,
+ "render_scale": 1.0
+ },
+ {
+ "id": "reference",
+ "label": "Reference",
+ "steps": 50,
+ "dit_layers": 50,
+ "denoise_reuse": 1,
+ "token_reduction": false,
+ "render_scale": 1.0
+ }
+ ]
+}
diff --git a/webui/shared/progress_weights.json b/webui/shared/progress_weights.json
new file mode 100644
index 00000000..56cf8c28
--- /dev/null
+++ b/webui/shared/progress_weights.json
@@ -0,0 +1,88 @@
+{
+ "reference": {
+ "width": 256,
+ "height": 256,
+ "frames": 22,
+ "steps": 8,
+ "layers": 50
+ },
+ "phase_seconds": {
+ "tokenizer": 2.785,
+ "text encoder": 16.801,
+ "refine text": 1.548,
+ "precompute AdaLN": 29.027,
+ "load transformer core": 40.874,
+ "denoise": 5.118,
+ "audio VAE": 1.154,
+ "video VAE load": 14.288
+ },
+ "total_seconds": 111.829,
+ "factors": {
+ "_source": "README.md speed/quality table and docs/GB10_PROFILE.md, both measured.",
+ "denoise_reuse": {
+ "_note": "At 20 steps h3 performs 20, 11 or 8 fresh DiT evaluations.",
+ "1": 1.0,
+ "2": 0.55,
+ "3": 0.4
+ },
+ "core_reuse": {
+ "_note": "The patch and output heads stay fresh every step; the core runs every Nth. About 30 % of the pass is the heads.",
+ "head_share": 0.3
+ },
+ "token_reduction": {
+ "_note": "512 square, 45 layers, reuse 2: denoise 16.69 s -> 12.60 s.",
+ "factor": 0.755
+ },
+ "dit_layers": {
+ "_note": "Cost is proportional to the residual blocks that run.",
+ "reference": 50
+ },
+ "ssd_streaming": {
+ "_note": "GB10 profile: DiT load 96.563 s -> 51.829 s, denoise 2.715 s -> 84.798 s over two steps.",
+ "load_factor": 0.537,
+ "added_seconds_per_step": 41.0
+ }
+ },
+ "samples": [
+ {
+ "reference": {
+ "width": 256,
+ "height": 256,
+ "frames": 22,
+ "steps": 8,
+ "layers": 50
+ },
+ "phase_seconds": {
+ "tokenizer": 2.785,
+ "text encoder": 16.801,
+ "refine text": 1.548,
+ "precompute AdaLN": 29.027,
+ "load transformer core": 40.874,
+ "denoise": 5.118,
+ "audio VAE": 1.154,
+ "video VAE load": 14.288
+ },
+ "total_seconds": 111.829
+ },
+ {
+ "reference": {
+ "width": 512,
+ "height": 512,
+ "frames": 56,
+ "steps": 20,
+ "layers": 50
+ },
+ "phase_seconds": {
+ "tokenizer": 3.977,
+ "text encoder": 17.396,
+ "refine text": 1.566,
+ "precompute AdaLN": 27.488,
+ "load transformer core": 62.433,
+ "denoise": 538.808,
+ "audio VAE": 1.471,
+ "video VAE load": 53.19
+ },
+ "total_seconds": 706.558
+ }
+ ]
+}