Get Phosphene free

Docs / Engines

Hailuo H3

MiniMax-H3 (FL2VA) takes one prompt and returns picture, dialogue and sound generated together. It is a peer of LTX, not an add-on to it — but it is too big to ship in the base install, so it arrives as its own one-click install: ~75 GB of weights, a 36 GB+ Mac, and a licence with territory restrictions. LTX remains the default engine and is completely untouched by any of this; whichever engines you have installed sit side by side in the switcher.


How much memory it actually needs

Two lanes, two floors, and neither of them is 64. The panel used to say “about 64 GB” in every refusal and tooltip; that number was never one of the product’s own floors and it told 48 GB owners their Mac could not do something it can.

BandLaneState
≥ 60 GB (H3_MIN_RAM_GB)bf16 master DiTRenders. 64 GB Macs report ~63.x after firmware reservations, which is why the floor is 60.
≥ 36 GB (H3_MIN_RAM_GB_Q8), Q8 DiT pack on diskquantised Q8 DiTRenders.
≥ 36 GB, Q8 DiT pack absent—Not a hardware verdict. scripts/pinokio/h3_build_q8.sh builds that pack locally in ~5 min / ~22 GB with no extra download, and “Install Hailuo H3” runs it automatically below 60 GB. The switcher shows H3 as an offer, the inline card names the step, and h3_status() carries needs_q8_dit: true with the sentence in ram_note. The sentence depends on whether the ~75 GB is already on disk: never installed → the sidebar’s Install Hailuo H3 (a real download, the Q8 build is its last step); weights present → Build Hailuo H3 compact engine (minutes, no download). Both entries are in the Pinokio sidebar while the panel runs, too (4.15.3; before that they appeared only with the panel stopped).
< 36 GB—Genuinely out of reach. The refusal states 36, not 64.

h3_ram_verdict() is the single place those bands are decided. The render refusal, the make_job fallback line, the switcher tooltip, the engine-row note and the inline install card all read it, so they cannot state four different numbers again.

The floors are per tier, not per render. They were set from peaks measured at 1024×576 / 124f (27.3 GiB Q8, 42.8 GiB bf16). On the staged loader at 243 frames / 10.125 s the same lanes peak at 32.6 GB (Q8) and 50.8 GB (bf16) — peaks scale with frame count. A 3 s Q8 clip needs far less than a 10 s one, so a length-aware gate could open short H3 renders to smaller Macs. That gate does not exist; the floors are deliberately one number per lane.

Field data — 48 GB really runs it. A user report (X, 2026-09-24, v4.15.3, M5 Pro 48 GB, the compact Q8 engine, Image mode, 3 s clips). Peak is the system memory the user read while rendering, not an MLX-internal number:

TierClipPeak memoryWall
Draft (640×384)3 s37 GB2 m 32 s
Standard (768×448)3 s36 GB3 m 53 s
High (1024×576)3 s39 GB13 m 27 s

Consistent with the Q8 lane’s 25.63 GiB run peak plus macOS and the panel, and with the 36 GB floor leaving headroom on a 48 GB Mac.

Time estimates on 36-59 GB (v4.16.2). Every H3 receipt was measured on a 64 GB M4 Max, and until 4.16.2 the estimate only scaled it by chip. A Pinokio user on an M4 Pro 48 GB was told ~17 min for a 15 s Draft and waited 40+. The fleet (60 days to 2026-09-26, 6,587 H3 renders, wall bucket over the model’s own estimate, one median per install) puts the reduced-RAM lane at 1.32x the >=60 GB lane on chained 10/15 s lengths (29 vs 117 installs) and 1.07x on single windows (43 vs 170). _h3_ram_factor() carries those two numbers (H3_LOWRAM_FACTOR_CHAIN / _SINGLE) into every H3 estimate below 60 GB, and chained cells there show a note: about a third longer than 64 GB, quit other apps, swapping can take far longer. The mechanism is not measured (render_completed has no pressure field); memory pressure inside a budget that leaves the Mac max(6, RAM/8) GB is the likely one. Separately, Fast is a one-click 180 MB adapter: until it is installed, a “Draft” render runs the full 8-forward sampler, about twice the Fast time.


How it plugs in

H3 is a subprocess engine, exactly like the mflux image engines: the panel spawns a CLI, streams its stdout into the log, and reads a metrics JSON when it exits. It is not an action on the LTX warm helper.

POST /queue/add  (engine=h3, h3_tier=…)
      │
      ▼
  make_job()            engine + h3_tier are in the params allowlist;
      │                 the tier stamps width/height/frames/steps
      ▼
  worker_loop → run_job_inner()
      │                 dispatches on params.engine BEFORE any LTX clamp
      ▼
  run_h3_job_inner()
      │  1. gates: capable? installed? mode is Text/Image?
      │  2. HELPER.kill()   ← the one cross-engine interaction
      │  3. caffeinate -i → <H3 venv python> scripts/generate_staged.py …
      │  4. stream stdout → push() + STATE.current.progress
      │  5. metrics JSON → <output>.mp4.json sidecar
      ▼
  mlx_outputs/<name>_h3.mp4  — the normal gallery picks it up

Why the warm helper gets killed first

H3’s staged runner materialises one large component at a time (Q8 text encoder → free → bf16 pruned DiT → free → the two VAEs) and still peaks around 40 GiB. The LTX warm helper holds its own weights resident. Both at once does not fit on a 64 GB Mac. run_h3_job_inner therefore kills the helper before launching; it respawns lazily on the next LTX job (WarmHelper._ensure), so the cost is one cold start and nothing else.

Why a separate venv

Phosphene pins mlx==0.31.1 — 0.31.2 regresses LTX audio by 22 dB. The H3 port needs mlx>=0.32. A separate venv means installing H3 can never break LTX rendering, and uninstalling H3 is rm -rf.

Its package set is locked (4.19.5)

scripts/pinokio/h3_constraints.txt is the H3 venv’s known-good set - every package, exact - captured from a from-zero install and validated with H3 renders. scripts/pinokio/h3_deps.sh is the one install into the venv and always passes it with -c: Install / Repair Hailuo H3 / Update Hailuo H3 runner (install_h3.js), every Update (post_update.sh 8b) and the panel’s POST /h3/repair. uv moves a package already outside the set back into it, so the same script is the repair. Before every H3 render the panel compares the packages the runner is built on (H3_ENV_JUDGED) with the set, plus transformers’ own import-time dependency check, and refuses in a second with Repair Hailuo H3 on the card when they disagree. Through 4.19.4 the venv was resolved from requirements.txt floors alone, so each new install got whatever PyPI held that day.

Not judged: an LTX_H3_PYTHON interpreter (a developer’s own), Linux (scripts/linux/install_h3.sh builds that venv with its own mlx wheel), and any panel started with LTX_H3_ENV_CHECK=0 - for a dev box whose LTX_H3_ROOT is a runner checkout you manage yourself. Moving the set: regenerate the whole file from a from-zero install’s venv, then a from-zero install and H3 renders on it.


Paths

Everything is env-overridable, which is what makes a dev box possible without duplicating 75 GB.

VariableDefaultWhat it points at
LTX_H3_ROOT<install>/minimax-h3-mlxthe engine checkout (scripts/generate_staged.py, .venv/)
LTX_H3_MODELS<install>/mlx_models/hailuo-h3the three weight components
LTX_H3_PYTHON<H3_ROOT>/.venv/bin/python3.11 → pythoninterpreter override (a checkout without its own venv)
LTX_H3_ENV_CHECKunset (on)0 stops the pre-render check of the H3 venv against h3_constraints.txt - for a dev checkout whose venv you manage yourself
LTX_H3_FORCE_CAPABLEunsettest-only: stop the UI hiding the pill on a small Mac. Does not make it render.
LTX_H3_DENSE_10Sdead — not read anywhere in the code (H3-15)the dense 10 s tier (”10s single pass”) ships "offered": True unconditionally; this var does nothing. Kept documented so a future re-gate has somewhere to land, not because it currently works.
LTX_H3_WIDE_DRAFTunsetadds an experimental 512×288 16:9 draft (~2 min). Off because 0.15 MP is below anything this campaign has measured — see Tiers.

Set them in ENVIRONMENT, not in your shell. An export in a terminal reaches only a panel launched from that same terminal, and it is gone the moment Pinokio restarts the app — which is exactly how a working H3 install “loses” its engine after a restart, with the weights still on disk and the panel reporting the pack missing. The ENVIRONMENT file at the install root is read on every launch, so lines there survive restarts and updates:

LTX_H3_ROOT=/path/to/minimax-h3-mlx
LTX_H3_MODELS=/path/to/models

The export form shown further down is for one-off CLI runs, not for the panel.

Model layout — both shapes work

h3_paths() tries two roots in order, so no post-download move is ever needed:

<LTX_H3_MODELS>/deepbeep-pruned-bf16/MiniMax-H3-FL2VA-pruned_bf16.safetensors
<LTX_H3_MODELS>/ddalcu-q8/{text_encoder,video_vae,audio_vae}.safetensors + tokenizer/config
<LTX_H3_MODELS>/upstream-meta/FL2VA/text_encoder/config.json

…or the same three directories one level down under models/, which is what the engine’s own scripts/download_selected.py --root X writes (it appends models/ to whatever root it is given). install_h3.js produces the second shape; the campaign checkout uses the first.


Tiers

H3_TIERS in mlx_ltx_panel.py is the single source of truth — the UI renders its chips from /status.h3.tiers, so a tier change is one Python edit.

The table below uses the CURRENT names — two independent axes, Quality (canvas) × Length (duration) — the same ones /status.h3.tiers and the UI’s chips use. H3-31: this table used to be written entirely in the RETIRED fixed-tier vocabulary (HQ / Wide / Long), which the UI dropped for the two-axis picker; H3_TIER_ALIASES (mlx_ltx_panel.py) still resolves those old composite keys for a sidecar written before the split — HQ → Standard, Wide → High, Long → the chained lengths (10s/15s) at any quality — but they are not choices in the UI, and this table should not have kept reading as if they were.

QualityLengthGeometryAspectWindowsSigma pointsWall time
Draft3s640×384 · 73f5:319 (8 forwards)~3 min
Standard3s768×448 · 73f12:719 (8 forwards)~4-5 min
Standard5s768×448 · 124f12:719 (8 forwards)~8 min
High5s1024×576 · 124f16:9116 (15 forwards) since 2026-09-16~34 min (was ~19 at 8 forwards)
Standard10s768×448 · 243f12:72 × 124f chained9 (8 forwards)~17 min
Standard15s768×448 · 362f12:73 × 124f chained9 (8 forwards)~27 min · batch

Legacy composite key → current two-axis key, for reading an old sidecar:

Legacy keyCurrent key
hq_3sstandard_3s
hq_5sstandard_5s
wide_5shigh_5s
long_10sstandard_10s
long_15sstandard_15s
long_10s_densestandard_10s_dense
wide_draft_3spreview_3s

Aspect — why one tier is 16:9 and the rest are not (2026-08-06)

Every tier used to render at an odd ratio and get pillarboxed by the export pass, and nothing in the UI said so. The canvas must be a multiple of 32 in both axes (the runner errors otherwise), so exact 16:9 means width 512k, height 288k, and there are only three of those:

kCanvasPixelsVerdict
1512×2880.15 MPbelow anything measured here — LTX_H3_WIDE_DRAFT=1 only
21024×5760.59 MPthe delivery canvas — wide_5s
31536×8641.33 MPover the model’s own MAX_PIXELS (1.03 MP) and dearer than native 1344×768. Not shipped.

wide_5s is measured, not extrapolated (the quality loop’s R1 run, on the same M4 Max): 22,923 packed rows, 126.0 s/step, 90.5 s VAE decode, 10.71 GiB decode peak, 18.8 min; the same probe put 768×448 at 9.1 min against this table’s ~8 min, hence the ~17-19 band. Denoise stayed at 37.6 GiB — identical to 768×448, because the DiT weights dominate. The retired ckpt500-EMA Turbo adapter measured 8.5 min on the same canvas (3 forwards at 128.0 / 127.4 / 123.9 s + 131 s of fixed load/decode, codex/opt_out/wide169/w169.log) — which is why turbo_eta is derived per tier from forward counts rather than from a flat ratio (Turbo always runs 3 forwards and the fixed cost never shrinks, so the observed ratio was 0.45 on an 8-forward tier and 0.59 on a 6-forward one). LightX2V v1.0 has not yet received an end-to-end timing run, so the active UI marks its estimates as derived rather than claiming the retired adapter’s wall clock. Against 768×448 at the same seed and forwards, the historical run resolves individual eyebrow hairs, forehead pores, eyelashes and fur strands where the smaller canvas has smears — and 1080p delivery drops from a 2.41× enlargement to 1.875× with no bars.

Each tier’s aspect is derived from its own width/height (_h3_aspect) and appended to the spec string the chip prints, so the advertised ratio can never drift from the geometry that renders.

High runs 16 points (2026-09-16). Every High cell (1024×576, any length) now runs 16 sigma points = 15 forwards per window. A matched gym A/B (same seed, still and prompt) at 15 forwards was clearly better than at 8, where the face was blurry; 1024×576/124f is ~22.9k packed rows, well past the ~13k envelope below. Knobs: H3_HIGH_STEPS and H3_NATIVE_STEPS in mlx_ltx_panel.py (Native stays at 9 until its own 15-forward test is judged). A cell runs max(length steps, canvas steps). H3_MEASURED_ETA entries carry the forwards they were measured at, so the old 18.8 min High receipt no longer prints as “measured” for a 15-forward cell.

Why 9 points everywhere else. --steps is sigma points; the runner does points - 1 forwards. A matched-cost A/B showed 8 forwards is visually free at or below ~13k packed rows (640×384/73f ≈ 5.6k, 768×448/124f ≈ 13.7k). A dense 768×448/243f pass is ~25k rows, where 8 forwards ghosts (two astronauts on screen) — which is why the old 10 s tier needed 15 forwards and 36 minutes. Chaining removes that regime entirely: every pass is a 5 s pass, so every pass is inside the window where 8 forwards was proven.

Geometry rules the runner enforces: width and height must be multiples of 32, and frame counts snap up to the 17n+5 grid.

Turbo adapter resolution (2026-08-14)

Turbo uses the runner’s --lora PATH:SCALE interface at scale 1.0. The panel resolves an exact ordered allowlist under turbo-lora/:

  1. lightx2v_v1.0_768p_ourlayout.safetensors — the default.
  2. lightx2v_v0.1_ourlayout_alpha8.safetensors — compatibility fallback only when v1.0 is absent.

The raw upstream v0.1 file, minimax_h3_fl2v_turbo_4step_v0.1.safetensors, is never accepted. Its alpha/rank factor is external to the checkpoint; loading it at scale 1.0 renders coloured noise. Resolution therefore uses explicit filenames, not a glob.

The Apache-2.0 v1.0 source is lightx2v/Minimax-h3-Turbo’s minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors (SHA-256 1bdabc2e9fce20b1db563b96bcf6e46adcad4c1964f423676436bf266cc7416c). It still needs the runner-layout repack. install_h3.js deliberately contains no raw-file fetch: it records the exact release-asset publication TODO and must gain a digest-checked fetch only after the repacked asset and its output digest exist.

Speed: ⚡ Fast | ✦ Best — TaoMate’s 3-step adapter (2026-09-17)

ONE switch above the Quality cards. Fast renders with TaoLiveAIGC’s TaoMate-H3 3-step adapter on its own sigma ladder; Best is each shape’s own sampler, unchanged (Draft/Standard 8 forwards, High 15, Native as tuned) and the only place the Steps override shows. Fast is the default once its adapter is installed; the choice is remembered per browser (phos_h3_speed). The cards’ estimates follow the switch and the mode (T2V/I2V). Fast runs on Draft, Standard and High (5 s windows, chained too); Native and the dense 10 s single pass are not validated on 3 steps and always render Best — the switch says so under itself when one of them is picked.

Cell (5 s, M4 Max, char + style LoRA)BestFast
Draft 640×3846.9 min (8 fwd, full decode)3.0 min I2V (panel, cold cache: 3.4)
Standard 768×448~9 min (8 fwd)4.8 I2V · 4.9 T2V
High 1024×57635.1 min (15 fwd)9.0 I2V · 8.4 T2V
  • Recipe. TaoMate distils H3 onto points (0, 16, 33, 49) of the shifted 50-point sigma grid (video σ [1, .96117, .85333, 0], audio σ [1, .86087, .59259, 0]) — 3 forwards. Runner spelling: --steps 4 --sigma-subset 50:0,16,33,49 --lora <adapter>:1.0 (--steps 4 alone would re-space a 4-point grid the adapter never saw). Needs a runner with --sigma-subset (minimax-h3-mlx codex/h3-engine-v2 from ccf532b) and stacking --lora; on an older runner the Fast half says “update H3 runner”. Stacks with character/style LoRAs (adapter first; 416 wrapped, 0 unaccounted on the validation render). Full VAE decode, never TAE.
  • Install. Not installed → the Fast half reads Install (180 MB); one click (confirm names the license) runs POST /h3/tristep/install: Kijai’s rank-resized conversion turbo-lora/minimax_h3_taomate_3step_lora_avg_rank_19_bf16.safetensors (alpha == rank, ~87 % of the full delta) from Kijai/MiniMax-H3_comfy @ 098f8c4…, resumable (HTTP Range on the .partial), exact size + sha256 de9663d9… before the rename. Never re-hosted. License: MiniMax H3 Community License (same as H3). A full-rank runner-layout copy taomate_h3_3step_ourlayout.safetensors in the same folder wins. The official release is T2AV only; I2V is unofficial (owner-approved look).
  • Turbo is folded in. The old Standard | Turbo row is gone: Fast replaces it (faster, and the owner’s pick), and two speed controls or two adapters on one render is what the switch removes. The form posts h3_turbo=0; the server still honours h3_turbo from the API (Storyboard, One Shot, curl) wherever Fast does not run, and drops it when Fast does. A Turbo clip’s Load Params / Finish maps to Fast.
  • Job field h3_tristep: 1 Fast, 0 Best, absent = default (Fast where offered and installed, unless a Steps pin asks for a depth). make_job and run_h3_job_inner both refuse it off Draft/Standard/High and never stack it with Turbo.
  • Upscale & Face Fix sits beside Generate (same state as the checkbox under Upscale); the footer line prices the pair from the cell, e.g. Draft · Fast + Face Fix ≈ 5.5 min. Face Fix receipts: 640×384 → 2.5 min, 1024×576 → ~9 min; 768×448 ~4 min is interpolated.
  • Provenance. Sidecar params.h3_tristep, and h3.tristep = file, version, repo, revision, sha256, source repo, license, steps, forwards, sigma subset, decode; plus h3.schedule (the sigmas the runner ran) and h3.lora_accounting. The ⓘ modal prints a Speed row.

Chained windows (v3.4.1)

Window N’s last decoded frame becomes window N+1’s first-frame keyframe through the ordinary FL2VA conditioning path; the duplicate frame is dropped at the join and the pieces butt-join in pixel space, with a one-frame equal-power audio cross-fade (the chain’s real overlap). The panel passes:

--frames <window_frames>  --chain-windows N  --chain-total-frames <delivered>

Measured (M4 Max 64 GB, on the H3 campaign’s chained-window runs):

Dense 10 sChained 10 sChained 15 s
Wall36:1217:0526:34
Packed rows25,13813,662 max13,580 flat
Peak Metal42.6 GiB40.3 GiB40.2 GiB

Duration is now linear in wall clock and constant in memory. Seam grading: both 15 s joins measured quieter than the clip’s own median frame-to-frame motion; identity (face, wardrobe, light) held across both.

Known artefact, surfaced in the UI: every window receives the same prompt, so a prompt that scripts a line asks for that line in each window and gets it. The tier’s note field carries that warning (“scripted dialogue repeats once per 5 s window — put dialogue cues late in the prompt”) and the panel renders it under the tier strip. The fix is per-window prompts (--chain-prompts on the runner); when that reaches the panel UI, delete the note and the warning disappears everywhere at once.

Gating. Chaining needs --chain-windows on the installed runner. h3_supports_chain() probes the script source, /status.h3.chain reports it, h3_visible_tiers() withholds the chained tiers from the UI when it’s false, and make_job falls back to H3_TIER_FINISH_DEFAULT (hq_5s) rather than failing a queued job — named explicitly, because the old “last single-pass row in the table” scan started meaning the ~19 min wide_5s the day a tier was appended, and a fallback nobody asked for has to be the cheap one. The dense 10 s tier (”10s single pass”) ships visible by default, always — LTX_H3_DENSE_10S is not read anywhere and restores nothing; see the constants table above.

Media modes (4.19): Keyframes, Extend, Lip-sync, Add sound, carried context

All of these ride on the packed sequence the reference already defines — video conditioning rows held at the keyframe noise level (0.999), audio conditioning rows held clean, and per-row timesteps — and on runner flags the panel probes on the INSTALLED runner (/status.h3.keyframes, .continuation, .extend, .audio_drive, .video_to_audio). An older pack falls back to LTX with a sentence (Add sound, which has no LTX lane, is refused with the update sentence). The panel-side map is h3_media_plan() in mlx_ltx_panel.py.

CapabilityRunner flagsWhat the model sees
Carried context (chained 10 s / 15 s, Extend, Add sound)--chain-context-frames 17, --seam-colour-frames 24the previous window’s last 17 frames as history rows placed just BEFORE the target on the rotary clock (one VAE clip, 5 latents), plus their sound as clean audio rows from the same instant; the hand-over frame stays the “first” anchor. Each window’s grade is eased onto the carried frame over 24 frames.
Start & end frame / Keyframes--first-frame, --last-frame, --keyframe PATH@FRAMEMiniMax’s FL2VA / L2VA tasks; a timed still is a frame:<i> anchor at origin + i·5/3 (the same clock every target frame lives on). Each still also goes to the vision tower as <Picture n>, and the official alignment sentence is prepended when the prompt has none. On a chain each still is routed to the window that owns its frame. The start frame is optional on H3.
Extend--extend-from SRC --extend-frames Nthe source is window 0; new 5 s windows continue from its last 17 frames and their sound. Output = source (on the H3 canvas) + N frames. Sources up to 60 s (the decoded source is held through the render).
Lip-sync--audio-drive TRACK [--audio-mux ORIGINAL]the track is written into the TARGET audio rows and held clean (t = 1) for every step; only the picture is denoised. The delivered audio is the user’s file, never the VAE round trip. “Voice only” conditions on the demucs vocal stem and delivers the mix.
Add sound--video-to-audio SRCthe clip is written into the TARGET video rows and held clean; only the audio is denoised. The delivery is the source’s own video stream (-c:v copy) with the new track — no export re-encode, no Face Fix.
Fast HD (quality)the Draft canvas + Fast, then mode=upscale (start = source, 1 refine step — the “face kept” recipe)H3 at 640×384 on the 3-step pass, LTX’s x2 Upscale & Face Fix to 1280×768, queued together.

Measured (M4 Max 64 GB, Q8 DiT, 2026-10-07)

Seam = the first frame of a new window. “Jump” = the change across the seam over the median frame-to-frame change around it (1.0 = invisible). Sound step = RMS 0.25 s after minus 0.25 s before.

ChainSound step at seamsColour jump (eased)Motion jump (eased)Time
High 10 s, one still per window+5.7 dB0.851.301008 s
High 10 s, carried context−0.9 dB1.811.141131 s
Standard 15 s ×2 seeds, one stillavg 3.4 dB (worst 7.6)avg 1.02avg 0.93769 s
Standard 15 s ×2 seeds, carriedavg 1.3 dB (worst 2.4)avg 1.20avg 1.08862 s

Without the easing the colour jump is 10–40× on either path — the grade step is a per-window decode artefact and the easing is what removes it. What carried context buys is the sound: the level and timbre continue across the seam (spectral similarity at the second seam 0.85/0.89 → 0.89/0.91, random-pair baseline 0.94). Transcripts were complete and identical on every chain.

Other modes, High (1024×576) unless noted:

  • Keyframes, first + last, 5 s: normalised cross-correlation 1.000 at frame 0 and frame 123; ArcFace median 0.956 against the reference stills; 545 s.
  • Extend, 5 s source + 5 s: seam motion 1.60, colour 1.35, sound −0.8 dB, voice similarity 0.997; the dialogue continues; ArcFace 0.927; 606 s.
  • Lip-sync, 10 s spoken voice on a still: mouth/audio correlation 0.35 without the character LoRA, 0.47 with it (native H3 dialogue renders: −0.24…0.38); ArcFace 0.82 / 0.83; ~1110–1170 s. A song on its full mix scores −0.08 (the mouth stops after ~2 s) — songs need Voice only (the vocal stem).
  • Add sound, Draft canvas, a talking clip with its audio removed: the line comes back word for word on the lips (correlation 0.71, peak −7.9 dB); video stream byte-identical to the source; 187 s for 5 s.

Fast HD

Same Bizarro prompt, still and seed, 5 s (124 frames):

RecipeOutputTimeArcFace median (min)
Fast HD: 640×384 Fast + x2 Face Fix, 1 step1280×768181 + 158 = 339 s0.891 (0.841)
640×384 Fast + x2 Face Fix, 3 steps1280×768181 + 357 = 538 s0.850 (0.818)
High Fast (1024×576)1024×576535 s0.914 (0.839)
High Best (1024×576, 15 forwards)1024×5762253 s0.922 (0.902)
Native Best (1344×768, 8 forwards)1344×7682888 s0.926 (0.886)

The 3-step finish redraws the face and costs as much as High Fast, so Fast HD uses the 1-step “face kept” recipe. bf16 DiT on High Fast: 512 s, ArcFace 0.911, peak 42.6 GiB (Q8: 25.1 GiB) — not adopted. Skipping the 5 smallest-gate DiT blocks (--skip-blocks auto:5): 489 s, ArcFace 0.892 (min 0.814), visible expression change at the same seed — not adopted; the flag stays in the runner for research only.

Export pass — the same post-process LTX renders get

Draft writes 640×384 (5:3) and Standard 768×448 (12:7), neither of them 720p nor 1080p. The panel runs the identical ffmpeg recipe an LTX render gets: lanczos scale + libx264 with the user’s codec settings (yuv420p crf 18 by default) + +faststart, audio copied through untouched.

Since 4.16.1 a near-16:9 canvas FILLS the frame instead of getting bars. Until then a non-matching source was fitted inside the canvas and padded: Draft → 720p had 40 px of black on each side, Standard 23 px (luma-scanned on real exports). A 48 GB user on X asked whether that was a bug (2026-09-24), and for a file whose job is to be 720p it was. Now a source within EXPORT_FILL_MAX_TRIM (8%) of the target aspect is scaled to cover the canvas and centre-cropped: 5:3 → 720p trims 24 px top and bottom, 12:7 13 px, Native 7:4 → 1080p 8 px. The same rule applies to LTX exports (1280×704 → 720p trims 14 px left and right instead of 8 px bars) and to the Sharp (PiperSR) path, which carries its own copy of the constant. A source further off — square, 4:3 — still pads: cutting a third of the picture is worse than bars. The plan reports fill: True plus trim_w/trim_h.

The picture was never the problem: an Image-mode reference is already cover-cropped onto the tier canvas before conditioning (_h3_fit_first_frame, centre crop — no face detection ships with the base install), so the bars came only from this export pass. The High tier (1024×576) is exact 16:9 and gets neither a crop nor a pad.

A source that already matches the target aspect takes a pure-scale path (2026-08-06). compute_upscale_plan compares w·target_h == h·target_w; on a match it emits scale=W:H:flags=lanczos with no pad filter at all and reports pad: False on the plan (plus fit_w/fit_h, the content size inside the canvas, so a sidecar reader can tell bars from picture). wide_5s → 720p is a pure 1.25× and → 1080p a pure 1.875×. Everything that does not match keeps the fill-or-pad rule above.

The panel says which one you are about to get: _h3_export_notes() generates one sentence per export mode from compute_upscale_plan itself — “720p: pure 1.25× scale to 1280×720 — no bars, no padding.” vs “720p: 12:7 fills 1280×720 — 13 px trimmed top and bottom, no bars.” — ships it on each tier as export_note, and _h3SyncExportNote() prints it under the Export row. The copy can never disagree with the ffmpeg command because it is generated from it.

  • Form field: h3_upscale = off | fit_720p (default) | fit_1080p. It is in the make_job allowlist; an unlisted field silently no-ops on /queue/add.
  • The native file stays on disk but is set_hidden() from the gallery, exactly like the LTX upscale path. UPSCALE_TAGS (module-level) carries 1080p / v1080p so /output/delete trashes the native companion and Load Params still finds the sidecar.
  • Sidecar gains upscale (target, method, source, codec) and output_codec, and h3.chain_windows / h3.window_frames / h3.delivered_frames / h3.seams.

Dialogue on H3 — the recipe (measured 2026-09-08)

H3 speaks only what is written inside its dialogue tag. A line written as prose quotes — She says: "You said Tuesday." — leaves the voice switched off and the model guessing: a 10 s draft written that way transcribed (whisper large-v3-turbo) as “You know, for when? Please, Jack, you’re telling him…”. The same engine, same canvas, with the line in the tag, transcribed the line word for word — at 640×384 draft and at 768×448 standard alike, so a draft is enough to judge whether a line comes out.

One 5-second window, in the three-field form the Storyboard planner writes (docs/PROMPTING.md has the laws; this is the shape):

integrated_multimodal_description: [Shot 1] Live-action, vintage 1990s
multi-camera sitcom, a medium shot of GEORGE, <two or three anchors: build,
glasses, jacket>, <where he is, the light>. The camera holds a static shot,
the frame never moves - no pan, no push-in, no reframing. <What he does
first, in silence.> Then George, with a nasal, agitated New York voice (S1),
says: <d>[English] I thought it was ashwagandha!</d> Exactly as his voice
stops, his jaw ceases speaking motion and his mouth settles closed, and a
studio audience laughs. No text appears at any point.

overall_soundscape: The room tone of a television studio, then a burst of
studio-audience laughter right after the line.

non_diegetic_music: N/A

The rules that were paid for:

  • The words go in <d>[English] …</d> and nowhere else. Speaker, voice and delivery stay outside the tag; the speaker keeps one ID ((S1)) across every shot. The panel and the runner pass the tag through untouched.
  • At most about eight words per five seconds. Speech runs ~2.4 words a second and the mouth needs a second to settle (storyboard.SPEECH_WORDS_PER_SEC is the same budget the board validator enforces). A twelve-word line spills past the window.
  • Stop the mouth in the same sentence — “his jaw ceases speaking motion and his mouth settles closed” — or the model keeps it moving on nothing.
  • Voiceover has a fixed phrase. George (S1) says in an off-screen voiceover: <d>[English] …</d> while his lips remain completely closed. Without the closing clause the on-screen mouth lip-syncs the narration.
  • One face in frame while a line plays. Every mouth in frame moves to whatever voice plays. The other character’s hands may enter the frame; the reverse angle is its own shot.
  • A laugh track is a soundscape event, not dialogue: “then a burst of studio-audience laughter right after the line” gave a clean line at 0.5–2.5 s and laughter at 3–4.5 s at the same loudness; the identical prompt without it is silent after the line.
  • Music is instrumentation. “A funky slap bass riff with a bright synth pop, fast tempo, two bars, that ends before he speaks” plays as a sting under the first second; “upbeat sitcom music” is a mood word and does nothing predictable.

A chained 10 s or 15 s clip carries one line per window through the per-window prompts (h3_chain_prompts); a board shot carries one line per 5 s shot. The Saint Feld chapter (twelve 5 s draft shots, every line transcribed back word for word on the first take) is the reference render.


LoRAs

H3 takes community adapters through the same --lora PATH[:SCALE] flag Turbo rides on. Four things about that are not obvious, and each is enforced in code:

Its own library. H3 LoRAs live in <models root>/loras/, beside turbo-lora/ and the weight components — not in mlx_models/loras/, which is the LTX (and mflux image) tree. The split is a directory, not a naming convention, because the picker filters on which tree a file was found in before it looks at any metadata: a hand-dropped .safetensors with no sidecar classifies as unknown, and unknown is deliberately permissive. The two engines share no module tree, so an LTX adapter handed to H3’s loader matches zero modules — a silent no-op — and an H3 one handed to LTX fails inside the fuser. make_job scrubs the wrong lane off every job, in both directions.

One slot. generate_staged.py declares --lora with default=None, not action="append", and calls parse_spec once. So there is exactly one adapter per render and Turbo and a user LoRA cannot both run. The panel refuses to pick a winner silently: h3_lora_slot (make_job allowlist) is turbo by default, in which case picking a LoRA while Turbo is on is a hard error naming the conflict; setting it to user is the explicit opt-out — Turbo is released, the step count goes back to the tier’s, and both facts are logged. Stacking two LoRAs is refused the same way.

Stacking (runner with the repeatable --lora, 2026-09-06+). The runtime adapter keeps every delta out of the base weight, so N adapters are a sum: y = base(x) + Σ scale_i · (x @ A_iᵀ) @ B_iᵀ. generate_staged.py takes --lora PATH[:SCALE] any number of times, Turbo first, and the panel posts Turbo plus up to four user LoRAs on a runner whose --lora help carries “Repeat the flag to stack adapters” (h3_supports_lora_stack, probed from the script text like every other capability). /status.h3.loras.max_stack reports the live limit, so the slot control disappears on a stacking runner and stays for an old pack. The community rule the panel repeats in its advisory: keep the strengths’ total near 1.5 or under, and never stack two LoRAs that pull the same axis (two faces, two styles). A second apply_lora appends to the existing wrapper rather than nesting one — nesting hid the quantized base’s scales from plan() and skipped every module (tests/test_lora_stack.py in the engine tree pins this).

Key layouts. Four exist in the wild; three load, one is refused:

LayoutKeysWhat happens
bareblocks.N.attn.qkv_proj.lora_A/B.weightloads as-is
ComfyUI repackthe same, namespaced under diffusion_model.converted in place at install time — a safetensors header rewrite (the offsets are relative to the tensor buffer, so every tensor byte is untouched), recorded in the sidecar as lora_layout / lora_converted_prefix
kohya / sd-scriptslora_down/lora_up + .alphaconverted in place (2026-09-06) — the character LoRAs people actually train for H3 come out of kohya-style trainers in exactly this shape, and the two things the runner can’t read (the naming, the .alpha scalar) are both mechanical: _h3_lora_convert_kohya renames the keys onto the runner’s lora_A/lora_B and folds alpha/rank into lora_B, so strength 1.0 means “as trained” — same as every other file. Recorded in the sidecar as lora_layout: "kohya".
diffusers / PEFTsplit to_q/to_k/to_v, ff.net.*refused, by design

The diffusers refusal is not laziness. That adapter needs a runtime alpha / rank multiplier that is not in the file — LightX2V’s is 8 / 128 = 0.0625, supplied externally by its own inference script — and applying it at 1.0 renders coloured noise, not a slightly-off clip. See LIGHTX2V_LORA_FIX.md and scripts/convert_lightx2v_lora.py in the engine repo for the manual path. The panel says exactly this in the error, and since a diffusers-layout file trained for LTX is the file people most often try here by mistake, the import UI also tells them it looks like an LTX LoRA and won’t run on H3.

CivitAI. The browser’s video context has family pills (ltx / h3), preselected from the active engine, so an LTX user’s default view is unchanged. CivitAI’s base-model string is MiniMax H3 — verified against the live API; MiniMax, Hailuo and Hailuo H3 all return zero. Downloads route by the item’s own base model, not by which pill was showing, so a MiniMax H3 LoRA lands in the H3 library even if it was found under “All”.

/status.h3.loras reports {supported, dir, count, usable, max_stack}; supported is a probe of the installed runner for --lora, and the picker is hidden entirely on a pack that predates it.


What H3 does not do

  • Modes: Remix and Character are LTX-pipeline-specific; the picker snaps back to LTX with a note. Character submits mode=t2v but stacks LTX LoRAs, and those cannot load on H3 (see LoRAs above, which is about H3’s own library). Text, Image, Keyframes, Extend, Lip-sync and Add sound all render on H3 since 4.19 — see “Media modes” above.
  • LoRA STACKING: up to four adapters on a stacking runner — see LoRAs above.
  • Orientation, accel, temporal interpolation, the LTX upscale control: none apply. Those carry data-ltx-only and fold away under body[data-engine="h3"]. H3 has its own export control (h3_upscale, data-h3-only) — see the export-pass section above.
  • External audio: H3 generates its own. i2v_clean_audio stays LTX.

--first-frame (Image mode) is branch-dependent

FL2VA first-frame conditioning landed on the engine repo after the branch install_h3.js pins (codex/practical-apple-silicon). The panel probes the installed scripts/generate_staged.py for the flag (h3_supports_first_frame()), reports it as /status.h3.first_frame, and keeps Image mode on LTX when it’s absent — so an older checkout degrades to Text-only instead of dying 30 s into a render with an argparse error. (Historical: the first-frame work has long been published.)

The engine pin (4.19.0)

The engine an install runs is an exact commit: H3_PIN_SHA + H3_PIN_REF in scripts/pinokio/h3_checkout.sh, called by both install_h3.js (Install, Repair, “Update Hailuo H3 runner”) and scripts/post_update.sh (Update, and the panel’s Update now). Through 4.18.x it was the tip of codex/h3-engine-v2, so a push to that branch reached every install with no release behind it. To move the engine: push the commit to a ref on mrbizarro/minimax-h3-mlx (a new branch or tag — never force-move one), change both lines in one commit, and ship it in a Phosphene release. test_h3_engine_pin.py pins the behaviour.


Running the dev box

The campaign checkout already has the weights; don’t copy them into the Pinokio install. Two working configurations:

Full feature set (Text and Image) — the opt tree has --first-frame but no venv of its own, so borrow the sibling one:

export LTX_H3_ROOT=<campaign-checkout>/opt
export LTX_H3_PYTHON=<campaign-checkout>/minimax-h3-mlx/.venv/bin/python
export LTX_H3_MODELS=<campaign-checkout>/models

What a user gets from install_h3.js today (Text only) — the published branch, with its own venv:

export LTX_H3_ROOT=<campaign-checkout>/minimax-h3-mlx
export LTX_H3_MODELS=<campaign-checkout>/models

Add either block to the normal panel env (start.js / run_panel.sh) and restart. GET /status → .h3 tells you what resolved:

{ "capable": true, "available": true, "first_frame": true, "missing": [] }

Troubleshooting

Where the control is. The engine switcher lives in the top right of the header, beside the memory / models pills — not in the Video tab. The “Engine row” that older copy pointed at has not existed since the engine table landed (#58 is a user who installed H3 fine and went looking there).

SymptomCause
Engine switcher not visible at allUnder the 36 GB floor. h3.capable is false, so there is only one engine left to choose between and the switcher hides with its divider. A 48 GB-class Mac whose reduced-RAM Q8 DiT pack is not on disk no longer lands here — see the RAM bands below.
H3 pill dashed, “not installed”h3.missing lists exactly which component didn’t resolve
Image mode snaps back to LTXh3.first_frame false — the installed runner has no --first-frame
ffmpeg not found on PATHthe runner pipes raw RGB into ffmpeg; the panel prepends FFMPEG_BIN to the subprocess PATH, so this means the bundled binary is missing
Job cancelled but memory stays highshouldn’t happen — /stop SIGTERMs the whole process group and SIGKILLs after 8 s; check STATE["h3_pgid"]
10 s / 15 s tiers missing from the striph3.chain false — the installed runner has no --chain-windows; update the pack
A render survived a panel crashexpected of kill -9 (atexit can’t run), but the NEXT boot reaps it: state/h3_running.json names the pid/pgid and reap_orphan_subprocesses() kills it before the queue moves. Same guard exists for the warm helper (state/helper_running.json).

Metrics for every run are kept at state/h3_metrics/<job_id>.json (the runner’s own phase timings, peak GiB, packed rows), and summarised into the render’s <output>.mp4.json sidecar under the h3 key.

Source: docs/H3_ENGINE.md in the public repo. Synced on every site build. Suggest an edit.