Compare commits

...
81 Commits
Author SHA1 Message Date
scraed c70c8f926b Use GIF previews for the MiniMax H3 example in the README
GitHub cannot render mp4 files inline, so the Example_29 media is also
published as GIFs (480px wide, 62 frames, ~3.9 MB) and the README links
(news block and MiniMax H3 section) point at the GIFs; the mp4 files stay
in the folder for download.
2026-08-09 23:45:11 +08:00
scraed 64fab713a5 Bump version to 2.0.0 2026-08-09 23:41:49 +08:00
scraed b95f51910c Announce MiniMax H3 support in the README news blocks
Add the MiniMax H3 video + audio inpainting announcement at the top of the
NEW blocks (with the mask-overlay visualization), pointing at the Example_29
media and the MiniMax H3 example section; the Updates list keeps the dated
entry.
2026-08-09 23:40:56 +08:00
scraed 6b04947fc6 Add a mask-visualization video to Example_29 and the README
Render the H3 example's actual interpolated mask sequence (from the 25
painted keyframes) as a red overlay on the input video, so the README can
show what was painted. The README's MiniMax H3 table now has three
columns (masked input / mask overlay / inpainted result), matching the
Wan example style.
2026-08-09 23:37:42 +08:00
scraed 8a491bfb94 Update the example InPainted drag-me PNGs with the new workflow outputs
The InPainted_Drag_Me_to_ComfyUI.png files of the converted examples are
replaced with the actual test-run outputs (which carry the workflow +
prompt tEXt chunks SaveImage embeds), so dragging them into ComfyUI loads
the new ImageEncode/ImageDecode workflows instead of the old encode chain.
Covers Examples 2, 7, 13, 14, 15, 20, 21, 23, 24, 25, 27, 28; verified
that every embedded workflow contains the new encode/decode nodes and has
no stale VAEEncode/SetLatentNoiseMask.
2026-08-09 23:26:28 +08:00
scraed 994c620cbd Document the MiniMax H3 AV pipeline in the README
Add feature bullets for the video+audio inpainting pipeline and the
masks-in-video export, plus a MiniMax H3 section under Video Examples
covering the editor -> AVEncode -> sampler -> AVDecode flow, the
Example_29 media, the mask export/import workflow, and links to the
MiniMax H3 Hugging Face repo and ComfyUI docs.
2026-08-09 23:15:12 +08:00
scraed d1ee798b60 Convert the remaining examples to ImageEncode/ImageDecode and finish the example set
- Qwen Image Edit upgraded to the 2509 model (qwen_image_edit_2509_fp8_e4m3fn)
  with TextEncodeQwenImageEditPlus multi-image conditioning; old 2508/2509
  pairs removed.
- New Z-Image base example (z_image_bf16, Example_25 input) using default
  LanPaint parameters (NumSteps 5, Lambda 5.0, StepSize 0.2, Friction 15);
  old turbo/base pairs removed.
- Renamed the converted examples to the *_EncodeDecode_Inpaint convention
  (fresh names force the workflow gallery to reload them) and generated
  preview jpgs from the test run outputs; superseded old pairs removed,
  Qwen_Image_Outpaint and wan2_2_T2I_Partial kept as distinct use cases.
- Fixed the Qwen Image example mask reference: Example_12's file has no
  alpha channel (it is an output example), so it now points at Example_13's
  masked input (7).
- Repositioned the ImageEncode/ImageDecode nodes next to the sampler in all
  converted workflows (the conversion agents had placed them far off-canvas).
- SD3.5 and HiDream examples removed on request.
- Example_29 now ships the H3 masked input video and the inpainted output
  video (InPainted_Drag_Me_to_ComfyUI.mp4).
2026-08-09 22:46:51 +08:00
scraed 59aa08dff0 Store masks inside exported videos and convert the example set to ImageEncode/ImageDecode
Mask-in-video (the masks now travel with the video file):
- New videometa module: write/read a JSON payload under the lanpaint-mask
  mp4 metadata tag via PyAV (stream copy, movflags=use_metadata_tags), so
  exporting produces a NEW file and the source is never modified.
- Two server routes: GET /lanpaint/video_mask_meta (read the payload back)
  and POST /lanpaint/export_mask_video (remux into input/<base>_masked.mp4).
- The mask editor exports the painted keyframes (base64 PNGs) + audio
  intervals into the video; loading a video with the tag auto-restores the
  masks (editor and node preview). A video without the tag clears the
  workflow's masks - the video is the source of truth. The export also
  offers a native save dialog (showSaveFilePicker) when available.
- The video widget upload now uses video_upload so MP4s are selectable in
  the file chooser (was image_upload).
- LanPaint_ImageEncode accepts 5D latents from video VAEs encoding a single
  image (e.g. the Hunyuan video VAE) - fixes the Hunyuan example.

Example set conversion (one example per supported model, new files, old
pairs removed after being superseded):
- New *ImageEncode/*EncodeDecode example workflows for SDXL, Flux.1,
  Flux.2 Dev, Flux2 Klein, SD3.5, Hunyuan, Ideogram4, Krea2, Qwen Edit,
  Qwen Image, Z-Image, Wan 2.2 and MiniMax H3 (AV), all using
  LanPaint_ImageEncode/ImageDecode instead of VAEEncode +
  SetLatentNoiseMask + VAEDecode + MaskBlend; every example references its
  own example folder's masked input (hash-verified); preview jpgs generated
  from the test runs; the H3 example ships its output mp4 as a preview.
- HiDream example removed on request; Example_29 added with the H3 masked
  input video.

Fixes along the way: normalize node input.link/output.links fields against
the links arrays in all converted workflows (the frontend renders
null-link nodes as disconnected); stale Inpainting_mode widget removed from
the Flux.2 sampler; Lambda defaults 5.0 (first-order scheme); torchaudio
resample test assertion relaxed (edge ringing); the videometa tests updated
to the final export signature.
2026-08-09 20:41:45 +08:00
scraed aa8f4e81ac Default LanPaint_Lambda to 5.0 for the first-order scheme
The second-order damped-oscillator scheme (position + velocity) is
disabled, and the active first-order overdamped scheme has no momentum,
so the 16.0 bidirectional guidance scale that suited the damped
oscillator over-drives the masked region. Lower the default Lambda to
5.0 in the basic KSampler's hardcoded value, the Advanced sampler's
INPUT_TYPES default and default argument, and the two custom samplers.
2026-08-09 01:26:01 +08:00
scraed f590861bd5 Add image encode/decode nodes and fix preview frame alignment
LanPaint_ImageEncode replaces VAEEncode + SetLatentNoiseMask (plus the
manual mask ImageScale): the mask is snapped to the latent's actual
spatial size (nearest-exact) so VAE padding can never mismatch again.
LanPaint_ImageDecode replaces VAEDecode + LanPaint_MaskBlend (plus the
dimension-alignment round trip): the decoded image is resized to the
original's exact dimensions, then merged with the original inside the
mask with a MaskBlend-style boundary. The image workflow collapses to
LoadImage -> ImageEncode -> sampler -> ImageDecode -> SaveImage.

merge_video_with_mask now accepts a single 2D mask (broadcast over the
frame batch) and keys the merge count off the image/frame tensors
instead of the mask, so a 1-frame mask no longer truncates a batch.

Three frontend fixes for frame-accurate previews in the video mask
editor: (1) measureVideoFps now spans about 80 percent of the duration
instead of 1 second - the short span had +-1 frame of counting
uncertainty, so the editor and node preview could round to different
integers (e.g. 29 vs 30) and their frame grids drifted by a frame;
(2) the preview overlay maps playback time to the frame index with
floor (the browser presents floor(t*fps), round() mislabeled by one
whenever the video paused mid-frame); (3) the editor's frameAt seek
gets a +1e-6 nudge so index/fps float dust cannot land on the previous
frame.

Also fix a save-race crash: if the editor dialog is closed while the
keyframe uploads are in flight, _flash queried this.el after it was
nulled and the handler reported Save failed even though the save
succeeded. _flash now tolerates a closed editor.
2026-08-09 00:27:11 +08:00
scraed 7514e28048 Use scipy EDT and share mask-morph math between frontend and backend
The video mask editor's backend interpolation was dominated by the
pure-Python Felzenszwalb-Huttenlocher EDT: 20 keyframes at 864x480 took
~168s. _edt_2d now uses scipy.ndimage.distance_transform_edt (exact same
distances, C speed, ~100x faster; the Python fallback stays for
environments without scipy) - the same scenario now takes ~1.6s.

The frontend preview and the backend no longer keep two separate morph
implementations that can drift: the pure math (EDT, SDF, shifts, sigmoid,
per-frame morph) moved to web/lanpaint_mask_math.js, used by the editor
extension and pinned against the backend by tests/parity_mask_math.mjs +
test_js_python_mask_math_parity, which feeds the same 8-bit keyframes to
both sides and asserts the soft masks match within 1e-4 and the 0.5 level
sets are pixel-identical. The preview also switched from the quantized
sigmoid LUT to the exact sigmoid the backend uses.

Fixed a caller-contract bug found while verifying: computeSDFEntry
expects a pure alpha array, but the extension passed raw RGBA bytes
(getImageData data), so soft strokes were read as fully solid and the
index alignment scrambled the SDF (visible as odd strips). The caller now
extracts the alpha channel, and the function throws when handed a
non-w*h array so the mistake fails loudly.
2026-08-08 19:09:11 +08:00
scraed e8e2d2ad1d Add AV encode/decode nodes for nested-latent video+audio inpainting
LanPaint_AVEncode replaces the workflow chain GetVideoComponents ->
VAEEncode -> SetLatentNoiseMask + MiniMaxAudioEncode -> SetLatentNoiseMask
-> Concat AV Latent: it decodes the video's frames and audio track from
the VIDEO reference, encodes both streams, and returns a nested AV latent
with the video and audio masks attached per-stream (no stock
SetLatentNoiseMask, which crashes on 1D masks).

LanPaint_AVDecode is the mirror: it decodes both streams, merges the
inpainted video into the original with MaskBlend-style boundary blending
(blend_overlap), merges the audio by mask intervals with a crossfade
(audio_crossfade, replicate-padded ramp so even kernels keep the exact
length), resamples the inpainted audio to the original rate, resizes the
VAE decode output to the original dimensions, and writes the result with
the original file's fps and bit depth via VideoFromComponents. A source
without an audio track falls back to the inpainted audio.

reshape_mask rework: video masks are aggregated per latent token with a
window-4 temporal max (union) followed by nearest-exact resample, and the
frames-at-batch [F,1,H,W] convention produced by SetLatentNoiseMask is
handled; audio masks [F], [F,1] or [1,1,F,1] are resampled to the audio
latent's token grid. The stock per-stream mask prep is routed through our
prepare_mask (video_inpainting for 5D targets) inside override_sample_function,
which now guards against nested entry. Audio encode is a pure encoder.

Also: editor node fps fallback for VideoFromFile.get_frame_rate, 5D decode
batch combine, audio channel-count match, and MaskBlend.gaussian_kernel
now shares the guarded kernel helper (fixing NaN at overlap=1).
2026-08-08 17:46:21 +08:00
scraed 7708715590 Add video mask editor with audio mask intervals
LanPaint_VideoMaskEditor now outputs a per-frame video mask and an audio
mask [F] (hard 0/1 at video frame rate) painted from time intervals in
the new hidden audio_mask widget. The frontend shows an audio waveform
strip under the video preview with red masked intervals and a playhead,
and the editor dialog supports drag-to-paint / click-to-clear intervals.

LanPaint_MiniMaxAudioEncode becomes a pure encoder (mask_start/mask_end
removed); the workflow attaches the editor's audio mask to the audio
latent with SetLatentNoiseMask and Concat AV Latent nests both per-stream
masks.

Video masks get the temporal union in reshape_mask: a latent token covers
~4 video frames and is all-or-nothing, so each token takes the window-4
max of its frames (nearest-exact) instead of trilinear averaging, which
could erase sparse temporal strokes. The stock per-stream mask prep is
routed through our prepare_mask (video_inpainting for 5D targets) and
handles the 1D [F] audio mask -> audio latent tokens case. Also handles
the VideoFromFile.get_frame_rate rename in newer ComfyUI.
2026-08-08 01:27:12 +08:00
scraed c9c79988f1 Update Krea2 example with latest output and workflow 2026-06-28 10:48:50 +08:00
scraed 9ce5bbb706 Bump version to 1.5.5 2026-06-27 23:43:05 +08:00
scraed 8b630eb6eb Update Flux2 Klein inpainting example with reference conditioning workflow 2026-06-27 23:42:01 +08:00
scraed 840c962fc9 Add Krea2 Turbo inpainting support with Example_28
- Add Krea2 banner, example section, and documentation to README
- Add new Example_28 with Krea2 LanPaint KSampler workflow
- Add workflow JSON and preview to example_workflows/
2026-06-27 17:45:09 +08:00
scraed 6a41445b8a Bump version to 1.5.4 2026-06-27 12:00:27 +08:00
scraed b71f8e59b4 Add Ideogram4 inpainting support with Example_27
- Add Ideogram4 banner, example section, and documentation to README
- Add new Example_27 with Ideogram4 LanPaint Custom Sampler Advanced workflow
- Add workflow JSON to example_workflows/
- Fix widgets_values in Example_23 and Example_24 InPainted images
2026-06-27 11:21:11 +08:00
scraed d696d48146 Add Anima support examples to README 2026-05-21 11:48:37 +08:00
scraed 6a0f424f28 Add Anima inpainting example and workflow updates
Add Anima (Example_26) inpainting/outpainting support: update README with Anima example and changelog entry, add three example images (Original, Masked, Inpainted). Modify example_workflows/Qwen_Image_Inpaint.json to restructure and extend the node graph (add VAE/encode/decode, mask/image scaling/conversion, mask blending, updated links and node ids) and bump frontendVersion/node metadata for compatibility. Update .gitignore to ignore Claude-related files. Replace the Example_13 inpainted image binary with an updated version.
2026-05-21 11:26:35 +08:00
scraed ba4bb687f1 Bump version to 1.5.3 for Info display improvement 2026-04-11 11:13:17 +08:00
scraed 3bd8a431ac Merge pull request #87 from octo-patch/fix/issue-61-info-widget-fixed-height
fix: prevent LanPaint_Info widget from expanding when node is resized
2026-04-11 11:12:06 +08:00
scraed 0ce81f2a49 Shrinked info and added a button instead 2026-04-11 11:10:01 +08:00
Octopus 9e7d0ee646 fix: remove multiline from LanPaint_Info widgets to prevent node resize expansion
When a LanPaint node is resized (e.g. to make the image preview bigger),
the LanPaint_Info multiline text box expanded along with it, wasting space.

Remove 'multiline: True' from the LanPaint_Info STRING input in all four
node types (LanPaint_KSampler, LanPaint_KSamplerAdvanced,
LanPaint_SamplerCustom, LanPaint_SamplerCustomAdvanced). The widget now
uses a fixed-height single-line display that does not grow when the node
is resized.

Fixes #61
2026-04-06 21:57:11 +08:00
scraed 75e606579c bump version 2026-03-26 17:10:41 +08:00
scraed 6440a7f433 unpack 2 bug fix for custom advanced node 2026-03-26 17:09:35 +08:00
scraed b3dc7e7551 bump version 2026-03-23 19:27:52 +08:00
scraed 2522687dd5 Klein workflow update 2026-03-23 19:26:45 +08:00
scraed f3ef9dedf9 update Klein workflow for resizing problem 2026-03-23 19:24:39 +08:00
scraed f53442c8d5 Revise README for v1.5.0 release notes
Updated README to include details about v1.5.0 bug fix and new features.
2026-03-03 11:46:56 +08:00
scraed 24051fb151 update README 2026-03-02 22:28:29 +08:00
scraed a76a5da0c5 bump version 2026-03-02 22:25:37 +08:00
scraed 21a6b155e8 fix the langevin output x has not been used issue 2026-03-02 14:16:32 +08:00
scraed 8bc20d5e00 Update README with additional resources and structure
Reorganized README content for clarity and added links to Diffusers
2026-02-16 01:03:30 +08:00
scraed 84b63adb11 replace flux example mask for bug fixing 2026-02-07 12:15:29 +08:00
scraed 725fb2ba37 Replace Klein example image 2026-02-07 11:56:03 +08:00
scraed 4f19674299 Update README.md 2026-02-04 14:40:08 +08:00
scraed 558084416d version 1.4.13 2026-02-02 23:49:35 +08:00
scraed a984dcb118 Fix flux klein example workflow 2026-02-02 23:48:33 +08:00
scraed 3f9193997a Update README.md 2026-02-02 20:36:55 +08:00
scraed 7372428114 Update README.md 2026-02-02 20:18:57 +08:00
scraed ac6eece747 Update Discord link in README.md 2026-02-02 20:15:45 +08:00
scraed 95b65ed1b1 Merge pull request #74 from godnight10061/fix/semantic-stop-tail
Add semantic inner-loop early stop mechanism
2026-01-30 11:44:51 +08:00
scraed 30cfd2f2e4 add contribution info 2026-01-30 11:40:02 +08:00
scraed 2e3a566bb4 Keep a minimal set of test 2026-01-30 11:23:51 +08:00
scraed 8fe1a9d312 Move parameters to the last for backward compatibility 2026-01-30 10:45:40 +08:00
scraed 4fcbfc7b5b Merge branch 'master' into pr/74 2026-01-30 10:20:59 +08:00
scraed 651ddc2225 fix typo 2026-01-30 00:48:22 +08:00
scraed bd4606f27a bump version to 1.4.12 2026-01-30 00:46:18 +08:00
scraed 8a72294f27 Z Image Base support 2026-01-30 00:45:20 +08:00
godnight10061 9ddd5f613e Map legacy min_steps into patience 2026-01-26 21:40:36 +08:00
godnight10061 39c90bbf62 Revert benchmark workflow 2026-01-26 21:16:05 +08:00
godnight10061 6ad4a18537 Refactor earlystop to reduce duplication 2026-01-26 18:09:21 +08:00
godnight10061 5e7d472738 Use LangevinState in semantic stop test mocks 2026-01-26 14:39:47 +08:00
godnight10061 d73f3db265 Document distance_fn hook 2026-01-26 14:02:07 +08:00
godnight10061 cd06069b52 Avoid early-stop when no inpaint region 2026-01-26 10:50:39 +08:00
godnight10061 114d091965 Simplify LanPaint inner-loop glue 2026-01-26 02:50:36 +08:00
godnight10061 06da285a09 Fix LangevinState backward compat 2026-01-25 23:42:04 +08:00
godnight10061 8f52a1d378 Use NamedTuple for Langevin state 2026-01-25 20:32:51 +08:00
godnight10061 c7962bf784 Refactor semantic early-stop metric 2026-01-25 10:57:29 +08:00
godnight10061 a895d2a758 Tighten semantic early-stop robustness 2026-01-24 12:43:23 +08:00
godnight10061 34ded92d8d Simplify semantic early-stop knobs 2026-01-24 10:19:37 +08:00
scraed 1b87239553 Move Coef_C definition above advance_time
Reordered the Coef_C function definition to appear before advance_time for improved code organization and readability.
2026-01-24 10:19:37 +08:00
scraed cbc9f3abfd Refactor early-stop logic into LanPaintEarlyStopper class
Moved the early-stop logic from lanpaint.py into a new src/LanPaint/earlystop.py module as the LanPaintEarlyStopper class. This improves code organization and maintainability by encapsulating early-stop behavior, reducing complexity in the main LanPaint class.
2026-01-24 10:19:37 +08:00
godnight10061 7d46147887 Guard torch.nn.functional import 2026-01-24 10:19:37 +08:00
godnight10061 c5fe6351ea Harden semantic-stop distance hook 2026-01-24 10:19:37 +08:00
godnight10061 ac7a5a1f2f Fix semantic-stop distance_fn dispatch 2026-01-24 10:19:37 +08:00
godnight10061 73ab537e86 Make CI-only imports lighter 2026-01-24 10:19:37 +08:00
godnight10061 6fd5f60383 Adjust SHO regression test for x0 return 2026-01-24 10:19:36 +08:00
godnight10061 9c9b5555fc Tighten semantic stop in mid-noise steps 2026-01-24 10:19:36 +08:00
godnight10061 450a25e5e8 Refine semantic inner stop 2026-01-24 10:19:36 +08:00
godnight10061 e7080f9574 Test ring changes prevent premature freeze 2026-01-24 10:19:36 +08:00
godnight10061 2086f00603 Skip score model after semantic convergence
# Conflicts:
#	src/LanPaint/lanpaint.py
2026-01-24 10:19:36 +08:00
godnight10061 896729ccd4 Fix semantic early-stop distance 2026-01-24 10:19:36 +08:00
godnight10061 207860edc3 Avoid semantic early-stop at low sigma 2026-01-24 10:19:36 +08:00
godnight10061 4074473b90 Add per-case quality gates to benchmark 2026-01-24 10:19:36 +08:00
godnight10061 11c1490140 Make benchmark workflow test semantic early stop 2026-01-24 10:19:36 +08:00
godnight10061 7c79712fc8 Make LPIPS benchmark long-running e2e 2026-01-24 10:19:36 +08:00
godnight10061 fb35a1d032 Add ImageNet LPIPS benchmark workflow 2026-01-24 10:19:36 +08:00
godnight10061 060fcc4475 Add semantic early-stop for Langevin iterations 2026-01-24 10:19:36 +08:00
98 changed files with 25095 additions and 14339 deletions
+6
View File
@@ -100,3 +100,9 @@ cookiecutter-pypackage-env/
*.code-workspace
.vscode/
/.vscode
# Claude Code
CLAUDE.md
.claude/
.playwright-mcp/
debug_screenshots/
+197
View File
@@ -0,0 +1,197 @@
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## Project overview
LanPaint is a ComfyUI extension that implements a training-free diffusion inpainting sampler based on Langevin dynamics ("think mode"). It lets any diffusion model iterate multiple times within each denoising step before committing to an output, improving inpainting quality without a specialized model.
## Commands
```bash
# Run all tests
pytest
# Lint
ruff check .
# Format
ruff format .
# Type check (requires mypy)
mypy
```
There is no build step — this is installed directly as a ComfyUI custom node by cloning into `custom_nodes/LanPaint`.
## Architecture
### Entry point and ComfyUI integration
`__init__.py` is the ComfyUI entry point. When ComfyUI loads this module, it imports `NODE_CLASS_MAPPINGS` and `NODE_DISPLAY_NAME_MAPPINGS` from `src/LanPaint/nodes.py`. The `WEB_DIRECTORY = "./web"` tells ComfyUI where to find the frontend JS.
When imported **without** ComfyUI (e.g., in CI), `_install_lightweight_runtime_stubs()` creates dummy `torch`, `comfy`, `nodes`, and `comfyui_version` modules so `nodes.py` can still be imported for node discovery. The real stubs are in `src/LanPaint/types.py` (`LangevinState` NamedTuple).
### Core algorithm (`src/LanPaint/lanpaint.py`)
`LanPaint.__call__()` is the main algorithm. It runs `n_steps` Langevin dynamics sub-iterations within each outer denoising step:
1. Replaces the masked region with the noise-scaled known latent (`scale_latent_inpaint`)
2. In each inner step, computes a score function via `score_model()` — which calls the diffusion model to get `x_0` and `x_0_BIG` (high-CFG) predictions — then runs one Langevin sub-step
3. After iterations, denoises the result to produce the final `x_0` output
The `LanPaintEarlyStopper` (`src/LanPaint/earlystop.py`) can terminate inner iterations early based on semantic convergence or a custom distance function, contributed by `@godnight10061`.
### Monkey-patching mechanism (`src/LanPaint/nodes.py`)
`override_sample_function()` is a context manager that temporarily replaces three functions on ComfyUI's `comfy.samplers` module:
- `CFGGuider.outer_sample` → `CFGGuider_LanPaint.outer_sample` (handles mask preparation and WAN22 video models)
- `CFGGuider.predict_noise` → `CFGGuider_LanPaint.predict_noise` (dual CFG output — normal + BIG)
- `KSAMPLER.sample` → `KSAMPLER.sample` (injects `LanPaint` as the paint method for inpainting steps)
These are monkey-patches, not subclass overrides, because ComfyUI internally constructs `CFGGuider` and `KSAMPLER` instances directly. The monkey-patches are scoped to a single `nodes.common_ksampler()` call.
### Sampler nodes
There are four sampler nodes, two "basic" and two "advanced":
- **LanPaint_KSampler** / **LanPaint_KSamplerAdvanced** — use `nodes.common_ksampler()`. The advanced variant exposes all LanPaint hyperparameters (Lambda, StepSize, Beta, Friction, EarlyStop, InnerThreshold, InnerPatience).
- **LanPaint_SamplerCustom** / **LanPaint_SamplerCustomAdvanced** — use `comfy.sample.sample_custom()` / `guider.sample()`, for use with custom sigmas/samplers/guiders.
All sampler nodes attach LanPaint parameters to `model` (the model patcher object) and set `model_options["video_inpainting"]` for video mode.
Additional nodes:
- **LanPaint_MaskBlend** — blends before/after images with a Gaussian-smoothed mask for seamless boundaries
- **LanPaint_UpSale_LatentNoiseMask** — generates a checkerboard noise mask (currently commented out in `NODE_CLASS_MAPPINGS`)
### Numerical utilities (`src/LanPaint/utils.py`)
`StochasticHarmonicOscillator` simulates the Langevin dynamics step analytically. It computes the exact mean and covariance of the position/velocity after time `t` and samples from a multivariate normal. The module also contains numerically stable implementations of `(e^x - 1)/x`, `(e^x - 1 - x)/x^2`, hyperbolic functions, and helper coefficients (`zeta1`, `zeta2`, `Zcoefs`).
### Dual CFG
LanPaint uses two classifier-free guidance scales simultaneously:
- `cfg` — the standard CFG scale used for the known-region score
- `cfg_BIG` — a second (often higher) CFG scale used for the masked-region score via `score_model()`. In "Prompt First" mode, `cfg_BIG = 0*cfg - 0.5 = -0.5`, which effectively disables the second guidance.
### Frontend (`web/lanpaint_info.js`)
A ComfyUI extension that adds a "More Info, Bug Report, Star on GitHub" button to each LanPaint sampler node in the UI.
### Version compatibility
`COMFYUI_VERSION_060_OR_NEWER` gates behavior differences for mask reshaping between ComfyUI versions < 0.6.0 and >= 0.6.0, which changed the latent tensor dimension convention.
## Testing
Tests use pytest. The test suite is designed to run without ComfyUI installed — `conftest.py` adds the project root to `sys.path`, and `_install_lightweight_runtime_stubs()` provides dummy modules. The primary integration test (`test_package_imports_without_comfy`) validates that the package can be imported and node mappings are present.
CI uses `comfy-org/node-diff` to validate backwards compatibility of node interfaces on PRs.
## Running ComfyUI on this machine
### Installed locations
| What | Path | Version |
|---|---|---|
| User data (custom_nodes, models, output) | `E:\CompyUI` | — |
| ComfyUI Desktop app (Electron shell) | `C:\Users\scraed\AppData\Local\Programs\@comfyorgcomfyui-electron` | 0.21.1 bundled |
| **Actual ComfyUI (used by Desktop)** | `C:\Users\scraed\ComfyUI-Installs\ComfyUI\ComfyUI\` | **0.24.1** |
| Python venv | `E:\CompyUI\.venv` | Python 3.12.6 |
| Shared models | `C:\Users\scraed\ComfyUI-Shared\models\` | — |
| Extra models (Y drive) | `Y:\ComfyData\models\` | — |
### Launch headless
The ComfyUI at `ComfyUI-Installs` is the one to use — the bundled Electron version (0.21.1) is outdated and lacks nodes like `Ideogram4Scheduler`, `DualModelGuider`, `CFGOverride`.
```powershell
$env:PYTHONUTF8=1
$env:PYTHONIOENCODING='utf-8'
& E:\CompyUI\.venv\Scripts\python.exe `
C:\Users\scraed\ComfyUI-Installs\ComfyUI\ComfyUI\main.py `
--base-directory E:\CompyUI `
--listen 127.0.0.1 --port 8188 `
--disable-auto-launch
```
The `PYTHONUTF8` and `PYTHONIOENCODING` env vars are required on this machine (Chinese Windows, GBK codec chokes on emoji in custom node logs).
### Model paths
ComfyUI loads `extra_model_paths.yaml` from **the same directory as `main.py`**, not from `--base-directory`. The config at `C:\Users\scraed\ComfyUI-Installs\ComfyUI\ComfyUI\extra_model_paths.yaml` points to both the Shared and Y-drive model collections.
### GPU
Dual NVIDIA RTX A6000 (49GB VRAM each), PyTorch 2.9.1+cu130.
### Workflows
Saved workflows are at `E:\CompyUI\user\default\workflows\`. There are 74 workflows covering LanPaint inpainting, Qwen image edit, Flux, HunYuan, HiDream, Wan video, and Ideogram4 generation.
### Running a workflow — practical notes
**Workflow format:** LanPaint examples are PNGs with embedded workflow JSON (`PIL.Image.open(png).info['workflow']`). Save to `E:\CompyUI\user\default\workflows\` to load them from the web UI.
**Common errors and their fixes:**
| Error | Cause | Fix |
|---|---|---|
| Models missing | `extra_model_paths.yaml` not at the ComfyUI root (same dir as `main.py`) | Create one with `base_path` sections pointing to `C:\Users\scraed\ComfyUI-Shared\` and `Y:\ComfyData\` |
| Nodes missing | UI-only nodes (`MarkdownNote`, `PreviewAny`) | Usually safe to ignore |
| Missing image input | `LoadImage` node needs a mask | Upload `Masked_Load_Me_in_Loader.png` from the example folder |
| "Failed to convert input to FLOAT" | `widgets_values` array is too short for the current node signature — params were added/inserted since the workflow was created | Expand `widgets_values` to match the current number of non-linked inputs, using correct types (INT, FLOAT, STRING, COMBO) |
**Testing workflow changes:** The easiest way to verify a LanPaint workflow works is to load it in the headless ComfyUI web interface (Playwright or manually), upload the mask if needed, and click Run. Monitor the page title for `[N%]` progress or grep the server log for "Prompt executed".
**UTF-8 on Chinese Windows:** Always set `$env:PYTHONUTF8=1` and `$env:PYTHONIOENCODING='utf-8'` before launching — the GBK codec cannot handle emoji in custom node logs.
### Workflow JSON debugging
**Link format differs by context:** Top-level `links` are arrays `[id, from_node, from_slot, to_node, to_slot, "TYPE"]`. Subgraph links (in `definitions.subgraphs[].links`) are dicts `{"id": N, "origin_id": N, ...}`. Mixing formats causes silent failures.
**ComfyUI clears link fields on save:** After loading+saving a workflow in ComfyUI, `inputs[].link` and `outputs[].links` fields on nodes are often reset to `null`/`[]` — even though the correct links still exist in the `links` array. This makes the frontend render nodes as disconnected. After any ComfyUI save, audit and restore these fields.
**Subgraph instance ↔ definition name matching:** Instance inputs (on the subgraph node) must have `name` matching the definition `inputs[].name`. Mismatch causes "No link found in parent graph" errors.
**Virtual nodes:** Inside subgraphs, `-10` = input node, `-20` = output node. Links from `-10` use the slot matching the definition input index.
**Node ID uniqueness:** IDs must be unique across top-level AND all subgraph nodes combined. Duplicates cause silent connection failures.
**VAE dimension alignment:** Always use `VAEEncode → VAEDecode → GetImageSize` to derive target dimensions before the actual encode path that feeds the sampler. VAEs require input dimensions divisible by a model-specific factor (e.g., 8). The round-trip forces alignment and captures the clean dimensions for all downstream `ImageScale` nodes. Without this, the mask and latent may have mismatched dimensions in `SetLatentNoiseMask`, causing cryptic errors. Don't remove this pattern unless you fully understand the VAE's input constraints.
**Widget value ordering when replacing nodes:** When swapping a node (e.g., `KSampler` → `LanPaint_KSampler`), the old widget values array does not map 1:1 to the new node's `INPUT_TYPES`. Always clear the old array and set widget values to exactly match the new node's non-linked inputs — in the correct order, with the correct count. Appending new params to stale old values shifts everything and produces NaN in the UI.
### Workflow PNG metadata conventions
Example directories follow this pattern:
| File | Metadata |
|------|----------|
| `Masked_Load_Me_in_Loader.png` | Plain PNG, no metadata |
| `Original_No_Mask.png` | Plain PNG, no metadata |
| `InPainted_Drag_Me_to_ComfyUI.png` | Must have embedded `workflow` + `prompt` tEXt chunks (auto from SaveImage) |
**Strip metadata:** `img = Image.open(src); img.save(dst, 'PNG')` — Pillow drops tEXt chunks on re-save.
**example_workflows/:** Each workflow gets a `.json` + `.jpg` pair. The `.jpg` is a preview derived from the output PNG: `img.convert('RGB').save('name.jpg', 'JPEG', quality=95)`.
### Kill server safely
Match the ComfyUI install path to avoid killing other Python programs:
```powershell
Get-CimInstance Win32_Process -Filter "Name='python.exe'" | Where-Object { $_.CommandLine -match 'ComfyUI-Installs.*main\.py' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
```
### Playwright/Browser automation
- **Beforeunload dialog:** ComfyUI shows `系统可能不会保存您所做的更改` when navigating away from a modified workflow. Call `browser_handle_dialog(accept=true)` to dismiss.
- **File choosers:** LoadImage nodes with IMAGEUPLOAD widgets spawn file chooser modals on page load. Dismiss with `browser_file_upload(paths=[])` — there may be multiple.
- **IndexedDB cache:** After modifying a workflow JSON on disk, the frontend may load a cached version. Close the workflow tab and re-open it (hard refresh alone is not sufficient).
- **Run button:** Use `page.getByTestId('queue-button').click()` — more reliable than text matching.
- **Progress:** `page.evaluate('() => document.title')` — format `[N%][M%] Node`. Completed when title returns to `*WorkflowName - ComfyUI`.
- **LoadImage via JS:** `app.graph.getNodeById(id).widgets.find(w => w.name === 'image').callback('filename.png')` to set the image without file picker.
## Commit conventions
Do NOT include the `Co-Authored-By: Codex <noreply@anthropic.com>` trailer in commit messages. All commits should be attributed solely to the git user.
+155 -5
View File
@@ -13,7 +13,13 @@
Universally applicable inpainting ability for every model. LanPaint sampler lets the model "think" through multiple iterations before denoising, enabling you to invest more computation time for superior inpainting quality.
This is the official implementation of ["LanPaint: Training-Free Diffusion Inpainting with Asymptotically Exact and Fast Conditional Sampling"](https://arxiv.org/abs/2502.03491), accepted by TMLR. The repository is for ComfyUI extension. Local Python benchmark code is published here: [LanPaintBench](https://github.com/scraed/LanPaintBench).
This is the official implementation of ["LanPaint: Training-Free Diffusion Inpainting with Asymptotically Exact and Fast Conditional Sampling"](https://arxiv.org/abs/2502.03491), accepted by TMLR.
The repository is for ComfyUI extension.
Diffusers Support: [LanPaint-Diffusers](https://github.com/charrywhite/LanPaint-diffusers) by [@charrywhite](https://github.com/charrywhite/)
Benchmark code for paper reproduce: [LanPaintBench](https://github.com/scraed/LanPaintBench).
## Citation
@@ -31,14 +37,33 @@ note={}
```
**🎉 NEW 2026: Join our discord!**
[Join our Discord](https://discord.gg/aCGZutBV) to share experiences, discuss features, and explore future development.
[Join our Discord](https://discord.gg/yN5wYDE6W4) to share experiences, discuss features, and explore future development.
`v1.5.0` fixes an important hidden bug that reduced performance and could blur images (especially with `z-image-base`) and also boosts overall LanPaint performance across other models.
If your inpainting results have wierd (glowing / broken) mask boundary, check this [issue](https://github.com/scraed/LanPaint/issues/80).
**🎬 NEW: LanPaint now supports MiniMax H3 video + audio inpainting!**
| Masked Input (paint in the editor) | Mask (visible overlay) | Inpainted Result |
|:----------------------------------:|:----------------------:|:----------------:|
| ![Masked Video](https://github.com/scraed/LanPaint/blob/master/examples/Example_29/Masked_LoadMe.gif) | ![Mask Overlay](https://github.com/scraed/LanPaint/blob/master/examples/Example_29/Masked_LoadMe_MaskOverlay.gif) | ![Inpainted Video](https://github.com/scraed/LanPaint/blob/master/examples/Example_29/InPainted_Drag_Me_to_ComfyUI.gif) |
Check our latest [MiniMax H3 Example](#minimax-h3-video--audio-inpainting-av-pipeline): paint per-frame video masks and audio intervals in one editor, inpaint video + audio in a single pass, and export the masks into the video file itself.
**🎬 NEW: LanPaint now supports inpainting and outpainting based on Z-Image!**
| Original | Masked | Inpainted |
|:--------:|:------:|:---------:|
| ![Original Z-image](https://github.com/scraed/LanPaint/blob/master/examples/Example_21/Original_No_Mask.png) | ![Masked Z-image](https://github.com/scraed/LanPaint/blob/master/examples/Example_21/Masked_Load_Me_in_Loader.png) | ![Inpainted Z-image](https://github.com/scraed/LanPaint/blob/master/examples/Example_21/InPainted_Drag_Me_to_ComfyUI.png) |
**🎬 NEW: LanPaint now supports Z-Image-Base too!**
| Original | Masked | Inpainted |
|:--------:|:------:|:---------:|
| ![Original Z-image-base](https://github.com/scraed/LanPaint/blob/master/examples/Example_25/Original_No_Mask.png) | ![Masked Z-image-base](https://github.com/scraed/LanPaint/blob/master/examples/Example_25/Masked_Load_Me_in_Loader.png) | ![Inpainted Z-image-base](https://github.com/scraed/LanPaint/blob/master/examples/Example_25/InPainted_Drag_Me_to_ComfyUI.png) |
**🎬 NEW: LanPaint now supports video inpainting and outpainting based on Wan 2.2!**
@@ -52,7 +77,25 @@ note={}
</div>
Check our latest [Wan 2.2 Video Examples](#video-examples-beta), [Wan 2.2 Image Examples](#example-wan22-inpaintlanpaint-k-sampler-5-steps-of-thinking), and
**🎬 NEW: LanPaint now supports Anima!**
| Original | Masked | Inpainted |
|:--------:|:------:|:---------:|
| ![Original Anima](https://github.com/scraed/LanPaint/blob/master/examples/Example_26/Original_No_Mask.png) | ![Masked Anima](https://github.com/scraed/LanPaint/blob/master/examples/Example_26/Masked_Load_Me_in_Loader.png) | ![Inpainted Anima](https://github.com/scraed/LanPaint/blob/master/examples/Example_26/InPainted_Drag_Me_to_ComfyUI.png) |
**🎬 NEW: LanPaint now supports Ideogram4!**
| Original | Masked | Inpainted |
|:--------:|:------:|:---------:|
| ![Original Ideogram4](https://github.com/scraed/LanPaint/blob/master/examples/Example_27/Original_No_Mask.png) | ![Masked Ideogram4](https://github.com/scraed/LanPaint/blob/master/examples/Example_27/Masked_Load_Me_in_Loader.png) | ![Inpainted Ideogram4](https://github.com/scraed/LanPaint/blob/master/examples/Example_27/InPainted_Drag_Me_to_ComfyUI.png) |
**🎬 NEW: LanPaint now supports Krea2!**
| Original | Masked | Inpainted |
|:--------:|:------:|:---------:|
| ![Original Krea2](https://github.com/scraed/LanPaint/blob/master/examples/Example_28/Original_No_Mask.png) | ![Masked Krea2](https://github.com/scraed/LanPaint/blob/master/examples/Example_28/Masked_Load_Me_in_Loader.png) | ![Inpainted Krea2](https://github.com/scraed/LanPaint/blob/master/examples/Example_28/InPainted_Drag_Me_to_ComfyUI.png) |
Check our latest [Krea2 Example](#example-krea2-inpaintlanpaint-k-sampler-3-steps-of-thinking), [Ideogram4 Example](#example-ideogram4-inpaintlanpaint-custom-sampler-advanced-5-steps-of-thinking), [Anima Example](#example-anima-inpaintlanpaint-k-sampler-5-steps-of-thinking), [Wan 2.2 Video Examples](#video-examples-beta), [Wan 2.2 Image Examples](#example-wan22-inpaintlanpaint-k-sampler-5-steps-of-thinking), and
[Qwen Image Edit 2509](#example-qwen-edit-2509-inpaint) support.
@@ -69,6 +112,10 @@ Check our latest [Wan 2.2 Video Examples](#video-examples-beta), [Wan 2.2 Image
- [Flux.2.Dev](#example-flux2dev-inpaintlanpaint-k-sampler-5-steps-of-thinking)
- [Flux 2 klein](#example-flux-2-klein-inpaintlanpaint-k-sampler-2-steps-of-thinking)
- [Z-image](#example-z-image-inpaintlanpaint-k-sampler-5-steps-of-thinking)
- [Z-image-base](#example-z-image-base-inpaintlanpaint-k-sampler-3-steps-of-thinking)
- [Ideogram4](#example-ideogram4-inpaintlanpaint-custom-sampler-advanced-5-steps-of-thinking)
- [Krea2](#example-krea2-inpaintlanpaint-k-sampler-3-steps-of-thinking)
- [Anima](#example-anima-inpaintlanpaint-k-sampler-5-steps-of-thinking)
- [Hunyuan T2I](#example-hunyuan-t2i-inpaintlanpaint-k-sampler-5-steps-of-thinking)
- [Wan 2.2 T2I](#example-wan22-inpaintlanpaint-k-sampler-5-steps-of-thinking)
- [Wan 2.2 T2I with reference](#example-wan22-partial-inpaintlanpaint-k-sampler-5-steps-of-thinking)
@@ -91,13 +138,16 @@ Check our latest [Wan 2.2 Video Examples](#video-examples-beta), [Wan 2.2 Image
## Features
- **Universal Compatibility** – Works instantly with almost any model (**Z-image, Hunyuan, Wan 2.2, Qwen Image/Edit, HiDream, SD 3.5, Flux-series, SDXL, SD 1.5 or custom LoRAs**) and ControlNet.
- **Universal Compatibility** – Works instantly with almost any model (**Ideogram4, Krea2, Z-image, Z-image-base, Hunyuan, Wan 2.2, Qwen Image/Edit, Anima, HiDream, SD 3.5, Flux-series, SDXL, SD 1.5 or custom LoRAs**) and ControlNet.
![Inpainting Result 13](https://github.com/scraed/LanPaint/blob/master/examples/InpaintChara_13.jpg)
- **No Training Needed** – Works out of the box with your existing model.
- **Easy to Use** – Same workflow as standard ComfyUI KSampler.
- **Flexible Masking** – Supports any mask shape, size, or position for inpainting/outpainting.
- **No Workarounds** – Generates 100% new content (no blending or smoothing) without relying on partial denoising.
- **Beyond Inpainting** – You can even use it as a simple way to generate consistent characters.
- **Video Mask Editor** – Paint per-frame inpainting masks directly on a video inside ComfyUI (`LanPaint_VideoMaskEditor`): pick the video file, open the editor, paint masks on keyframes, and the masks in between are interpolated automatically with a live preview. Mask = 1 regenerates, 0 keeps.
- **MiniMax H3 Video + Audio Inpainting** – Inpaint video **and audio** in one pass: paint per-frame video masks and audio intervals in the same editor, encode both streams into one nested latent (`LanPaint_AVEncode`), sample, then `LanPaint_AVDecode` merges the inpainted video/audio back into the original with a mask-blended boundary, preserving the source fps and bit depth.
- **Masks Live in the Video** – The editor can export the painted masks into the video file itself (mp4 metadata, via a new "masked" copy - the original is never modified). Share that single mp4 and the recipient gets the masks back automatically when they load it.
**Warning**: LanPaint has degraded performance on distillation models, such as Flux.dev, due to a similar [issue with LORA training](https://medium.com/@zhiwangshi28/why-flux-lora-so-hard-to-train-and-how-to-overcome-it-a0c70bc59eaf). Please use low flux guidance (1.0-2.0) to mitigate this [issue](https://github.com/scraed/LanPaint/issues/30).
@@ -163,6 +213,28 @@ Extend your videos beyond their original boundaries with LanPaint's video outpai
You need to follow the ComfyUI version of [Wan2.2 T2V workflow](https://docs.comfy.org/tutorials/video/wan/wan2_2) to download and install the T2V model.
### MiniMax H3 Video + Audio Inpainting (AV pipeline)
LanPaint's AV pipeline inpaints video **and audio** together with the MiniMax H3 model. Paint both masks in one editor session, run a single sampler pass on the nested AV latent, and get back a merged video at the original fps and bit depth.
*Example: MiniMax H3, 864x480, 124 frames, LanPaint Sampler Custom (Advanced)*
| Masked Input (paint in the editor) | Mask (visible overlay) | Inpainted Result |
|:----------------------------------:|:----------------------:|:----------------:|
| ![Masked Video](https://github.com/scraed/LanPaint/blob/master/examples/Example_29/Masked_LoadMe.gif) | ![Mask Overlay](https://github.com/scraed/LanPaint/blob/master/examples/Example_29/Masked_LoadMe_MaskOverlay.gif) | ![Inpainted Video](https://github.com/scraed/LanPaint/blob/master/examples/Example_29/InPainted_Drag_Me_to_ComfyUI.gif) |
[View Workflow & Masks](https://github.com/scraed/LanPaint/tree/master/examples/Example_29) · [Workflow JSON](https://github.com/scraed/LanPaint/blob/master/example_workflows/MiniMax_H3_AV_EncodeDecode_Inpaint.json)
**How it works:**
1. `LanPaint_VideoMaskEditor` – pick the video, open the editor, paint per-frame video masks on keyframes (SDF-interpolated in between) and drag audio intervals on the waveform. Mask = 1 regenerates, 0 keeps.
2. `LanPaint_AVEncode` – encodes the video frames and the audio track into one nested latent with the masks attached.
3. Run the sampler as usual (the audio stream runs on its own shifted sigma schedule).
4. `LanPaint_AVDecode` – decodes the nested latent, merges the inpainted video with the original (mask-blended boundary) and the inpainted audio inside the masked intervals (with a short crossfade), and writes the result at the original fps and bit depth.
**Export masks into the video:** the editor's **Export mask video** button remuxes a new `<name>_masked.mp4` copy with the masks embedded in the file metadata (the original file is never modified). Open that file in the editor later - or share it with someone - and the masks are restored automatically.
Download the models from [MiniMax H3 on Hugging Face](https://huggingface.co/MiniMaxAI/MiniMax-H3) and follow the [ComfyUI MiniMax H3 docs](https://docs.comfy.org/tutorials/video/minimax/minimax_h3).
### Resource Consumption
@@ -230,6 +302,38 @@ You need to follow the ComfyUI version of [Wan2.2 T2V workflow](https://docs.com
## Image Examples
### Example Anima: InPaint(LanPaint K Sampler, 5 steps of thinking)
We are excited to announce that LanPaint now supports inpainting with the Anima text-to-image model.
<details open>
<summary>View Original / Masked / Inpainted Comparison</summary>
| Original | Masked | Inpainted |
|:--------:|:------:|:---------:|
| ![Original Anima](https://github.com/scraed/LanPaint/blob/master/examples/Example_26/Original_No_Mask.png) | ![Masked Anima](https://github.com/scraed/LanPaint/blob/master/examples/Example_26/Masked_Load_Me_in_Loader.png) | ![Inpainted Anima](https://github.com/scraed/LanPaint/blob/master/examples/Example_26/InPainted_Drag_Me_to_ComfyUI.png) |
</details>
[View Workflow & Masks](https://github.com/scraed/LanPaint/tree/master/examples/Example_26)
[Model Used in This Example](https://huggingface.co/circlestone-labs/Anima)
### Example Ideogram4: InPaint(LanPaint Custom Sampler Advanced, 5 steps of thinking)
We are excited to announce that LanPaint now supports inpainting with the Ideogram4 text-to-image model.
<details open>
<summary>View Original / Masked / Inpainted Comparison</summary>
| Original | Masked | Inpainted |
|:--------:|:------:|:---------:|
| ![Original Ideogram4](https://github.com/scraed/LanPaint/blob/master/examples/Example_27/Original_No_Mask.png) | ![Masked Ideogram4](https://github.com/scraed/LanPaint/blob/master/examples/Example_27/Masked_Load_Me_in_Loader.png) | ![Inpainted Ideogram4](https://github.com/scraed/LanPaint/blob/master/examples/Example_27/InPainted_Drag_Me_to_ComfyUI.png) |
</details>
[View Workflow & Masks](https://github.com/scraed/LanPaint/tree/master/examples/Example_27)
[Model Used in This Example](https://huggingface.co/Comfy-Org/Ideogram-4)
### Example Hunyuan T2I: InPaint(LanPaint K Sampler, 5 steps of thinking)
We are excited to announce that LanPaint now supports inpainting with Hunyuan text to image generation.
@@ -274,6 +378,40 @@ LanPaint also supports inpainting with the Z-image text-to-image model.
You can download the Z-image model for ComfyUI from [Z-image](https://docs.comfy.org/zh-CN/tutorials/image/z-image/z-image-turbo).
### Example Z-image-base: InPaint(LanPaint K Sampler, 3 steps of thinking)
LanPaint also supports inpainting with the Z-image-base model.
**Warning (stability)**: Z-image-base can easily diverge with LanPaint. Start with **small `LanPaint_StepSize`** and **fewer thinking iterations** (lower `LanPaint_NumSteps`) and increase gradually only if stable.
<details open>
<summary>View Original / Masked / Inpainted Comparison</summary>
| Original | Masked | Inpainted |
|:--------:|:------:|:---------:|
| ![Original Z-image-base](https://github.com/scraed/LanPaint/blob/master/examples/Example_25/Original_No_Mask.png) | ![Masked Z-image-base](https://github.com/scraed/LanPaint/blob/master/examples/Example_25/Masked_Load_Me_in_Loader.png) | ![Inpainted Z-image-base](https://github.com/scraed/LanPaint/blob/master/examples/Example_25/InPainted_Drag_Me_to_ComfyUI.png) |
</details>
[View Workflow & Masks](https://github.com/scraed/LanPaint/tree/master/examples/Example_25)
Workflow template (JSON): [Z_image_base_Inpaint.json](https://github.com/scraed/LanPaint/blob/master/example_workflows/Z_image_base_Inpaint.json)
### Example Krea2: InPaint(LanPaint K Sampler, 3 steps of thinking)
We are excited to announce that LanPaint now supports inpainting with the Krea2 Turbo text-to-image model.
<details open>
<summary>View Original / Masked / Inpainted Comparison</summary>
| Original | Masked | Inpainted |
|:--------:|:------:|:---------:|
| ![Original Krea2](https://github.com/scraed/LanPaint/blob/master/examples/Example_28/Original_No_Mask.png) | ![Masked Krea2](https://github.com/scraed/LanPaint/blob/master/examples/Example_28/Masked_Load_Me_in_Loader.png) | ![Inpainted Krea2](https://github.com/scraed/LanPaint/blob/master/examples/Example_28/InPainted_Drag_Me_to_ComfyUI.png) |
</details>
[View Workflow & Masks](https://github.com/scraed/LanPaint/tree/master/examples/Example_28)
[Model Used in This Example](https://huggingface.co/Comfy-Org/Krea-2)
### Example Wan2.2: Partial InPaint(LanPaint K Sampler, 5 steps of thinking)
Sometimes we don't want to inpaint completely new content, but rather let the inpainted image reference the original image. One option to achieve this is to inpaint with an edit model like Qwen Image Edit. Another option is to perform a partial inpaint: allowing the diffusion process to start at some middle steps rather than from 0.
@@ -356,7 +494,7 @@ You need to follow the ComfyUI version of [SD 3.5 workflow](https://comfyui-wiki
[View Workflow & Masks](https://github.com/scraed/LanPaint/tree/master/examples/Example_24)
[Model Used in This Example](https://docs.comfy.org/zh-CN/tutorials/flux/flux-2-klein)
[Model Used in This Example](https://docs.comfy.org/zh-CN/tutorials/flux/flux-2-klein). If you have quality problem on Comfy 0.11 and 0.12, check [this issue](https://github.com/scraed/LanPaint/issues/80).
### Example Flux: InPaint(LanPaint K Sampler, 5 steps of thinking)
@@ -479,6 +617,18 @@ Submit a PR to add your tutorial/video here, or open an [Issue](https://github.c
[Working togather with crop&stitch](https://github.com/scraed/LanPaint/issues/46)
## Updates
- 2026/08/09
- Add MiniMax H3 video + audio inpainting support (Example_29): paint per-frame video masks and audio intervals in one editor session, encode both streams into a nested AV latent, sample once, and decode back with the source fps and bit depth preserved.
- The mask editor can export the masks into the video itself (mp4 metadata) - share a single video file and the masks travel with it.
- 2026/06/27
- Add Krea2 inpainting support with LanPaint KSampler (Example_28).
- Add Ideogram4 inpainting support with LanPaint Custom Sampler Advanced (Example_27).
- 2026/05/20
- Add Anima inpainting and outpainting support (Example_26).
- 2026/03/02
- `v1.5.0`: Fixed a hidden bug that hurt performance and caused image blur (especially on `z-image-base`), and improved overall LanPaint performance on other models too.
- 2026/01/30
- Add Z-image-base documentation and Example_25 workflow images.
- 2025/08/08
- Add Qwen image support
- 2025/06/21
+14
View File
@@ -92,3 +92,17 @@ except ModuleNotFoundError:
from .src.LanPaint.nodes import NODE_DISPLAY_NAME_MAPPINGS
WEB_DIRECTORY = "./web"
# ---------------------------------------------------------------------------
# Server routes — registered only when running inside ComfyUI (the ``server``
# module and ``folder_paths`` are ComfyUI internals not available in CI/tests).
# ---------------------------------------------------------------------------
try:
from server import PromptServer # noqa: F811 (re-export for convenience)
from .src.LanPaint.videometa import register_routes
register_routes(PromptServer.instance)
except Exception:
pass # not running inside ComfyUI — routes are not needed
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

@@ -0,0 +1,671 @@
{
"id": "978d3a45-3d13-43c6-8ef9-89dc3e74d6ba",
"revision": 0,
"last_node_id": 84,
"last_link_id": 229,
"nodes": [
{
"id": 78,
"type": "CLIPTextEncode",
"pos": [
314.8565368652344,
255.63235473632812
],
"size": [
422.84503173828125,
164.31304931640625
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"label": "clip",
"name": "clip",
"type": "CLIP",
"link": 194
}
],
"outputs": [
{
"label": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
195
]
}
],
"title": "CLIP Text Encode (Positive Prompt)",
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.26",
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"cute anime girl with massive fluffy fennec ears and a big fluffy tail blonde messy long hair blue eyes wearing a maid outfit with a long black gold leaf pattern dress and a white apron mouth open placing a fancy black forest cake with candles on top of a dinner table of an old dark Victorian mansion lit by candlelight with a bright window to the foggy forest and very expensive stuff everywhere there are paintings on the walls"
],
"color": "#232",
"bgcolor": "#353"
},
{
"id": 80,
"type": "CLIPTextEncode",
"pos": [
362.7684020996094,
481.0662536621094
],
"size": [
422.84503173828125,
164.31304931640625
],
"flags": {
"collapsed": true
},
"order": 3,
"mode": 0,
"inputs": [
{
"label": "clip",
"name": "clip",
"type": "CLIP",
"link": 196
}
],
"outputs": [
{
"label": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
210
]
}
],
"title": "CLIP Text Encode (Negative Prompt)",
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.26",
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
""
],
"color": "#322",
"bgcolor": "#533"
},
{
"id": 79,
"type": "FluxGuidance",
"pos": [
529.1380615234375,
156.20236206054688
],
"size": [
211.60000610351562,
58
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"label": "conditioning",
"name": "conditioning",
"type": "CONDITIONING",
"link": 195
}
],
"outputs": [
{
"label": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
207
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.26",
"Node name for S&R": "FluxGuidance"
},
"widgets_values": [
3.5
]
},
{
"id": 77,
"type": "CheckpointLoaderSimple",
"pos": [
-66.25785064697266,
484.081787109375
],
"size": [
315,
98
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"label": "MODEL",
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [
197
]
},
{
"label": "CLIP",
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [
194,
196
]
},
{
"label": "VAE",
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [
222,
226
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.26",
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"flux1-dev-fp8.safetensors"
]
},
{
"id": 73,
"type": "LanPaint_KSampler",
"pos": [
1005.0399780273438,
296.9553527832031
],
"size": [
413.6495666503906,
596
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 197
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 207
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 210
},
{
"name": "latent_image",
"type": "LATENT",
"link": 224
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
225
]
}
],
"properties": {
"cnr_id": "LanPaint",
"ver": "56bd6c04e89124cd06682b304245d6ddf8b20522",
"Node name for S&R": "LanPaint_KSampler"
},
"widgets_values": [
0,
"fixed",
30,
1,
"euler",
"simple",
1,
5,
"Image First",
"LanPaint KSampler. Recommend steps 50, LanPaint NumSteps 1-20 depending on the difficulty of task. LanPaint_EndSigma = 3.0 for anime style, 0.6 for realistic style. For more information, visit https://github.com/scraed/LanPaint",
"\ud83d\uddbc\ufe0f Image Inpainting"
]
},
{
"id": 48,
"type": "SaveImage",
"pos": [
1075.49169921875,
1167.2703857421875
],
"size": [
311.2532653808594,
484.7096252441406
],
"flags": {},
"order": 9,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 229
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.23"
},
"widgets_values": [
"ComfyUI"
]
},
{
"id": 75,
"type": "LoadImage",
"pos": [
45.954593658447266,
1150.45556640625
],
"size": [
266.13720703125,
487.1314697265625
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
221,
227
]
},
{
"name": "MASK",
"type": "MASK",
"links": [
221,
227
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.27",
"Node name for S&R": "LoadImage"
},
"widgets_values": [
"Example_7_Masked.png",
"image"
]
},
{
"id": 83,
"type": "LanPaint_ImageEncode",
"pos": [
635.0,
317.0
],
"size": [
195.3150390625,
66
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"link": 221
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 222
},
{
"localized_name": "mask",
"name": "mask",
"shape": 7,
"type": "MASK",
"link": 223
}
],
"outputs": [
{
"localized_name": "latent",
"name": "latent",
"type": "LATENT",
"links": [
224
]
}
],
"properties": {
"Node name for S&R": "LanPaint_ImageEncode"
}
},
{
"id": 84,
"type": "LanPaint_ImageDecode",
"pos": [
1355.0,
317.0
],
"size": [
270,
118
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 225
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 226
},
{
"localized_name": "image",
"name": "image",
"shape": 7,
"type": "IMAGE",
"link": 227
},
{
"localized_name": "mask",
"name": "mask",
"shape": 7,
"type": "MASK",
"link": 228
},
{
"localized_name": "blend_overlap",
"name": "blend_overlap",
"shape": 7,
"type": "INT",
"widget": {
"name": "blend_overlap"
},
"link": null
}
],
"outputs": [
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"links": [
229
]
}
],
"properties": {
"Node name for S&R": "LanPaint_ImageDecode"
},
"widgets_values": [
9
]
}
],
"links": [
[
194,
77,
1,
78,
0,
"CLIP"
],
[
195,
78,
0,
79,
0,
"CONDITIONING"
],
[
196,
77,
1,
80,
0,
"CLIP"
],
[
197,
77,
0,
73,
0,
"MODEL"
],
[
207,
79,
0,
73,
1,
"CONDITIONING"
],
[
210,
80,
0,
73,
2,
"CONDITIONING"
],
[
221,
75,
0,
83,
0,
"IMAGE"
],
[
222,
77,
2,
83,
1,
"VAE"
],
[
223,
75,
1,
83,
2,
"MASK"
],
[
224,
83,
0,
73,
3,
"LATENT"
],
[
225,
73,
0,
84,
0,
"LATENT"
],
[
226,
77,
2,
84,
1,
"VAE"
],
[
227,
75,
0,
84,
2,
"IMAGE"
],
[
228,
75,
1,
84,
3,
"MASK"
],
[
229,
84,
0,
48,
0,
"IMAGE"
]
],
"groups": [
{
"id": 1,
"title": "Mask image for inpainting.",
"bounding": [
36.04227828979492,
989.7313232421875,
278.89093017578125,
669.3414916992188
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 2,
"title": "Convert Latents for LanPaint",
"bounding": [
286.0640563964844,
714.343505859375,
489.16796875,
197.81044006347656
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 3,
"title": "Load Model and Set Prompts",
"bounding": [
-78.9311294555664,
176.08712768554688,
1006.1721801757812,
514.258544921875
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 4,
"title": "Inpaint with the LanPaint KSampler",
"bounding": [
960.8922729492188,
179.17588806152344,
474.8909606933594,
630.4742431640625
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 5,
"title": "LanPaint OutPut",
"bounding": [
1085.6029052734375,
994.0775756835938,
345.4561767578125,
669.4969482421875
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 11,
"title": "LanPaint",
"bounding": [
-262.59381103515625,
140.46656799316406,
1737.328857421875,
797.4443359375
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 0.35049389948139237,
"offset": [
348.866804381099,
308.65057628971834
]
},
"frontendVersion": "1.27.10",
"node_versions": {
"comfy-core": "0.3.18",
"LanPaint": "0f509469ed2cd60c6032f739e282aad5dfc06166"
}
},
"version": 0.4
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

-786
View File
@@ -1,786 +0,0 @@
{
"id": "978d3a45-3d13-43c6-8ef9-89dc3e74d6ba",
"revision": 0,
"last_node_id": 82,
"last_link_id": 220,
"nodes": [
{
"id": 66,
"type": "SetLatentNoiseMask",
"pos": [
514.1915893554688,
781.9396362304688
],
"size": [
264.5999755859375,
46
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 216
},
{
"name": "mask",
"type": "MASK",
"link": 189
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
186
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.23",
"Node name for S&R": "SetLatentNoiseMask"
},
"widgets_values": []
},
{
"id": 78,
"type": "CLIPTextEncode",
"pos": [
314.8565368652344,
255.63235473632812
],
"size": [
422.84503173828125,
164.31304931640625
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"label": "clip",
"name": "clip",
"type": "CLIP",
"link": 194
}
],
"outputs": [
{
"label": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
195
]
}
],
"title": "CLIP Text Encode (Positive Prompt)",
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.26",
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"cute anime girl with massive fluffy fennec ears and a big fluffy tail blonde messy long hair blue eyes wearing a maid outfit with a long black gold leaf pattern dress and a white apron mouth open placing a fancy black forest cake with candles on top of a dinner table of an old dark Victorian mansion lit by candlelight with a bright window to the foggy forest and very expensive stuff everywhere there are paintings on the walls"
],
"color": "#232",
"bgcolor": "#353"
},
{
"id": 80,
"type": "CLIPTextEncode",
"pos": [
362.7684020996094,
481.0662536621094
],
"size": [
422.84503173828125,
164.31304931640625
],
"flags": {
"collapsed": true
},
"order": 3,
"mode": 0,
"inputs": [
{
"label": "clip",
"name": "clip",
"type": "CLIP",
"link": 196
}
],
"outputs": [
{
"label": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
210
]
}
],
"title": "CLIP Text Encode (Negative Prompt)",
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.26",
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
""
],
"color": "#322",
"bgcolor": "#533"
},
{
"id": 79,
"type": "FluxGuidance",
"pos": [
529.1380615234375,
156.20236206054688
],
"size": [
211.60000610351562,
58
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"label": "conditioning",
"name": "conditioning",
"type": "CONDITIONING",
"link": 195
}
],
"outputs": [
{
"label": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
207
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.26",
"Node name for S&R": "FluxGuidance"
},
"widgets_values": [
3.5
]
},
{
"id": 77,
"type": "CheckpointLoaderSimple",
"pos": [
-66.25785064697266,
484.081787109375
],
"size": [
315,
98
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"label": "MODEL",
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [
197
]
},
{
"label": "CLIP",
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [
194,
196
]
},
{
"label": "VAE",
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [
200,
203
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.26",
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"flux1-dev-fp8.safetensors"
]
},
{
"id": 73,
"type": "LanPaint_KSampler",
"pos": [
1005.0399780273438,
296.9553527832031
],
"size": [
413.6495666503906,
596
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 197
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 207
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 210
},
{
"name": "latent_image",
"type": "LATENT",
"link": 186
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
187
]
}
],
"properties": {
"cnr_id": "LanPaint",
"ver": "56bd6c04e89124cd06682b304245d6ddf8b20522",
"Node name for S&R": "LanPaint_KSampler"
},
"widgets_values": [
0,
"fixed",
30,
1,
"euler",
"simple",
1,
5,
"Image First",
"LanPaint KSampler. Recommend steps 50, LanPaint NumSteps 1-20 depending on the difficulty of task. LanPaint_EndSigma = 3.0 for anime style, 0.6 for realistic style. For more information, visit https://github.com/scraed/LanPaint",
"🖼️ Image Inpainting"
]
},
{
"id": 65,
"type": "VAEEncode",
"pos": [
200.1034698486328,
776.3561401367188
],
"size": [
210,
46
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "pixels",
"type": "IMAGE",
"link": 188
},
{
"name": "vae",
"type": "VAE",
"link": 203
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
216
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.23",
"Node name for S&R": "VAEEncode"
},
"widgets_values": []
},
{
"id": 48,
"type": "SaveImage",
"pos": [
1075.49169921875,
1167.2703857421875
],
"size": [
311.2532653808594,
484.7096252441406
],
"flags": {},
"order": 9,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 103
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.23"
},
"widgets_values": [
"ComfyUI"
]
},
{
"id": 81,
"type": "LanPaint_MaskBlend",
"pos": [
1773.0189208984375,
1222.9478759765625
],
"size": [
210,
98
],
"flags": {},
"order": 10,
"mode": 0,
"inputs": [
{
"name": "image1",
"type": "IMAGE",
"link": 219
},
{
"name": "image2",
"type": "IMAGE",
"link": 218
},
{
"name": "mask",
"type": "MASK",
"link": 220
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
217
]
}
],
"properties": {
"cnr_id": "LanPaint",
"ver": "4d3d5d17f0105b673df92da5b084cce567c9c712",
"Node name for S&R": "LanPaint_MaskBlend"
},
"widgets_values": [
9
]
},
{
"id": 82,
"type": "SaveImage",
"pos": [
2106.45166015625,
1090.682861328125
],
"size": [
311.2532653808594,
484.7096252441406
],
"flags": {},
"order": 11,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 217
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.23"
},
"widgets_values": [
"ComfyUI"
]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [
1211.46484375,
1065.318359375
],
"size": [
210,
46
],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 187
},
{
"name": "vae",
"type": "VAE",
"link": 200
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [
103,
218
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.23",
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 75,
"type": "LoadImage",
"pos": [
45.954593658447266,
1150.45556640625
],
"size": [
266.13720703125,
487.1314697265625
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
188,
219
]
},
{
"name": "MASK",
"type": "MASK",
"links": [
189,
220
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.27",
"Node name for S&R": "LoadImage"
},
"widgets_values": [
"clipspace/clipspace-mask-8865503.5.png [input]",
"image"
]
}
],
"links": [
[
103,
8,
0,
48,
0,
"IMAGE"
],
[
186,
66,
0,
73,
3,
"LATENT"
],
[
187,
73,
0,
8,
0,
"LATENT"
],
[
188,
75,
0,
65,
0,
"IMAGE"
],
[
189,
75,
1,
66,
1,
"MASK"
],
[
194,
77,
1,
78,
0,
"CLIP"
],
[
195,
78,
0,
79,
0,
"CONDITIONING"
],
[
196,
77,
1,
80,
0,
"CLIP"
],
[
197,
77,
0,
73,
0,
"MODEL"
],
[
200,
77,
2,
8,
1,
"VAE"
],
[
203,
77,
2,
65,
1,
"VAE"
],
[
207,
79,
0,
73,
1,
"CONDITIONING"
],
[
210,
80,
0,
73,
2,
"CONDITIONING"
],
[
216,
65,
0,
66,
0,
"LATENT"
],
[
217,
81,
0,
82,
0,
"IMAGE"
],
[
218,
8,
0,
81,
1,
"IMAGE"
],
[
219,
75,
0,
81,
0,
"IMAGE"
],
[
220,
75,
1,
81,
2,
"MASK"
]
],
"groups": [
{
"id": 1,
"title": "Mask image for inpainting.",
"bounding": [
36.04227828979492,
989.7313232421875,
278.89093017578125,
669.3414916992188
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 2,
"title": "Convert Latents for LanPaint",
"bounding": [
286.0640563964844,
714.343505859375,
489.16796875,
197.81044006347656
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 3,
"title": "Load Model and Set Prompts",
"bounding": [
-78.9311294555664,
176.08712768554688,
1006.1721801757812,
514.258544921875
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 4,
"title": "Inpaint with the LanPaint KSampler",
"bounding": [
960.8922729492188,
179.17588806152344,
474.8909606933594,
630.4742431640625
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 5,
"title": "LanPaint OutPut",
"bounding": [
1085.6029052734375,
994.0775756835938,
345.4561767578125,
669.4969482421875
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 11,
"title": "LanPaint",
"bounding": [
-262.59381103515625,
140.46656799316406,
1737.328857421875,
797.4443359375
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 0.35049389948139237,
"offset": [
348.866804381099,
308.65057628971834
]
},
"frontendVersion": "1.27.10",
"node_versions": {
"comfy-core": "0.3.18",
"LanPaint": "0f509469ed2cd60c6032f739e282aad5dfc06166"
}
},
"version": 0.4
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

@@ -0,0 +1,998 @@
{
"id": "054799b4-76bf-4b4e-b7b7-615d2914285b",
"revision": 0,
"last_node_id": 119,
"last_link_id": 274,
"nodes": [
{
"id": 88,
"type": "Note",
"pos": [
180,
440
],
"size": [
350,
110
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.1",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"Select a fp8 weight_dtype if you are running out of memory."
],
"color": "#432",
"bgcolor": "#653"
},
{
"id": 103,
"type": "UNETLoader",
"pos": [
180,
310
],
"size": [
350,
82
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [
250
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.23",
"Node name for S&R": "UNETLoader",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.1",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"hunyuan_video_t2v_720p_bf16.safetensors",
"default"
],
"color": "#223",
"bgcolor": "#335"
},
{
"id": 94,
"type": "DualCLIPLoader",
"pos": [
-160,
-60
],
"size": [
350,
130
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 0,
"links": [
252,
255
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.23",
"Node name for S&R": "DualCLIPLoader",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.1",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"clip_l.safetensors",
"llava_llama3_fp8_scaled.safetensors",
"hunyuan_video",
"default"
],
"color": "#223",
"bgcolor": "#335"
},
{
"id": 114,
"type": "FluxGuidance",
"pos": [
653.5101318359375,
-73.20354461669922
],
"size": [
270,
58
],
"flags": {},
"order": 11,
"mode": 0,
"inputs": [
{
"name": "conditioning",
"type": "CONDITIONING",
"link": 258
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
259
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.65",
"Node name for S&R": "FluxGuidance"
},
"widgets_values": [
6
]
},
{
"id": 105,
"type": "LoadImage",
"pos": [
171.56072998046875,
795.9495239257812
],
"size": [
274.080078125,
314
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
266,
272
]
},
{
"name": "MASK",
"type": "MASK",
"links": [
266,
272
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.59",
"Node name for S&R": "LoadImage",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.1",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"Masked_Load_Me_in_Loader (8).png",
"image"
]
},
{
"id": 104,
"type": "CLIPTextEncode",
"pos": [
226.1235809326172,
-161.8229522705078
],
"size": [
310,
240
],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 252
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
258
]
}
],
"title": "CLIP Text Encode (Positive Prompt)",
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.23",
"Node name for S&R": "CLIPTextEncode",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.1",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"Close up Portrait of a futuristic cowgirl wearing jeans rides a mechanical horse across a vast cyberpunk desert. The dunes shimmer with holographic projections, glowing in blues and purples. cacti and two massive moons hang low on the horizon, illuminating the dusty air. Cyberpunk western fusion, cinematic, 8K, highly detailed, atmospheric perspective, Blade Runner style."
],
"color": "#232",
"bgcolor": "#353"
},
{
"id": 113,
"type": "CLIPTextEncode",
"pos": [
233.61778259277344,
122.75636291503906
],
"size": [
319.00726318359375,
88
],
"flags": {},
"order": 9,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 255
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
254
]
}
],
"title": "CLIP Text Encode (Positive Prompt)",
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.23",
"Node name for S&R": "CLIPTextEncode",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.1",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
""
],
"color": "#232",
"bgcolor": "#353"
},
{
"id": 92,
"type": "Note",
"pos": [
1340,
250
],
"size": [
210,
170
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.1",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"Use the tiled decode node by default because most people will need it.\n\nLower the tile_size and overlap if you run out of memory."
],
"color": "#432",
"bgcolor": "#653"
},
{
"id": 116,
"type": "Note",
"pos": [
1324.0262451171875,
583.7811889648438
],
"size": [
210,
170
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.1",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"Decrease LanPaint_NumSteps to accelerate"
],
"color": "#432",
"bgcolor": "#653"
},
{
"id": 85,
"type": "VAEDecodeTiled",
"pos": [
1341.9100341796875,
49.04500961303711
],
"size": [
210,
150
],
"flags": {},
"order": 14,
"mode": 4,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 248
},
{
"name": "vae",
"type": "VAE",
"link": 237
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [
251
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.18",
"Node name for S&R": "VAEDecodeTiled",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.1",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
256,
64,
64,
8
]
},
{
"id": 86,
"type": "SaveAnimatedWEBP",
"pos": [
1661.5201416015625,
68.0674819946289
],
"size": [
380,
366
],
"flags": {},
"order": 16,
"mode": 4,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 251
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.18",
"Node name for S&R": "SaveAnimatedWEBP",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.1",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"ComfyUI",
24,
false,
80,
"default"
]
},
{
"id": 110,
"type": "LanPaint_KSampler",
"pos": [
829.425537109375,
218.98995971679688
],
"size": [
400,
596
],
"flags": {},
"order": 13,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 239
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 259
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 254
},
{
"name": "latent_image",
"type": "LATENT",
"link": 269
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [
248,
270
]
}
],
"properties": {
"cnr_id": "LanPaint",
"ver": "6109df6591a4cf2bc9d3b113d03f7297fa9248e9",
"Node name for S&R": "LanPaint_KSampler"
},
"widgets_values": [
534861079790570,
"randomize",
20,
1,
"euler",
"simple",
1,
5,
"Image First",
"LanPaint KSampler. For more info, visit https://github.com/scraed/LanPaint. If you find it useful, please give a star \u2b50\ufe0f!",
"\ud83d\uddbc\ufe0f Image Inpainting"
]
},
{
"id": 78,
"type": "VAELoader",
"pos": [
910,
-230
],
"size": [
350.65509033203125,
58
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "VAE",
"type": "VAE",
"slot_index": 0,
"links": [
237,
267,
271
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.18",
"Node name for S&R": "VAELoader",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.1",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
"hunyuan_video_vae_bf16.safetensors"
]
},
{
"id": 81,
"type": "ModelSamplingSD3",
"pos": [
613.9942626953125,
59.9338493347168
],
"size": [
210,
58
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 250
}
],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [
239
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.18",
"Node name for S&R": "ModelSamplingSD3",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.1",
"input_ue_unconnectable": {}
}
},
"widgets_values": [
7
]
},
{
"id": 117,
"type": "SaveImage",
"pos": [
1670.939697265625,
-104.6227798461914
],
"size": [
531.0060424804688,
550.45361328125
],
"flags": {},
"order": 17,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 274
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.65"
},
"widgets_values": [
"ComfyUI"
]
},
{
"id": 118,
"type": "LanPaint_ImageEncode",
"pos": [
459.4,
239.0
],
"size": [
300,
150
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 266
},
{
"name": "vae",
"type": "VAE",
"link": 267
},
{
"name": "mask",
"type": "MASK",
"link": 268
}
],
"outputs": [
{
"name": "latent",
"type": "LATENT",
"links": [
269
]
}
],
"widgets_values": [],
"properties": {
"Node name for S&R": "LanPaint_ImageEncode",
"cnr_id": "LanPaint"
}
},
{
"id": 119,
"type": "LanPaint_ImageDecode",
"pos": [
1179.4,
239.0
],
"size": [
300,
200
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 270
},
{
"name": "vae",
"type": "VAE",
"link": 271
},
{
"name": "image",
"type": "IMAGE",
"link": 272
},
{
"name": "mask",
"type": "MASK",
"link": 273
},
{
"name": "blend_overlap",
"type": "INT",
"widget": {
"name": "blend_overlap"
}
}
],
"outputs": [
{
"name": "image",
"type": "IMAGE",
"links": [
274
]
}
],
"widgets_values": [
9
],
"properties": {
"Node name for S&R": "LanPaint_ImageDecode",
"cnr_id": "LanPaint"
}
}
],
"links": [
[
237,
78,
0,
85,
1,
"VAE"
],
[
239,
81,
0,
110,
0,
"MODEL"
],
[
248,
110,
0,
85,
0,
"LATENT"
],
[
250,
103,
0,
81,
0,
"MODEL"
],
[
251,
85,
0,
86,
0,
"IMAGE"
],
[
252,
94,
0,
104,
0,
"CLIP"
],
[
254,
113,
0,
110,
2,
"CONDITIONING"
],
[
255,
94,
0,
113,
0,
"CLIP"
],
[
258,
104,
0,
114,
0,
"CONDITIONING"
],
[
259,
114,
0,
110,
1,
"CONDITIONING"
],
[
266,
105,
0,
118,
0,
"IMAGE"
],
[
267,
78,
0,
118,
1,
"VAE"
],
[
268,
105,
1,
118,
2,
"MASK"
],
[
269,
118,
0,
110,
3,
"LATENT"
],
[
270,
110,
0,
119,
0,
"LATENT"
],
[
271,
78,
0,
119,
1,
"VAE"
],
[
272,
105,
0,
119,
2,
"IMAGE"
],
[
273,
105,
1,
119,
3,
"MASK"
],
[
274,
119,
0,
117,
0,
"IMAGE"
]
],
"groups": [
{
"id": 1,
"title": "Text Encoding",
"bounding": [
-190,
-140,
730,
340
],
"color": "#8A8",
"font_size": 24,
"flags": {}
},
{
"id": 2,
"title": "VAE Decoding & Output",
"bounding": [
1320,
-140,
1110,
600
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 4,
"title": "Sampling Process",
"bounding": [
570,
-140,
730,
980
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 5,
"title": "HunyuanVideo Model",
"bounding": [
170,
230,
370.5005798339844,
330.7360534667969
],
"color": "#88A",
"font_size": 24,
"flags": {}
},
{
"id": 6,
"title": "Guidance",
"bounding": [
590,
-90,
680,
260
],
"color": "#8AA",
"font_size": 24,
"flags": {}
},
{
"id": 7,
"title": "sampling",
"bounding": [
590,
200,
680,
440
],
"color": "#b06634",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 0.4870776476432723,
"offset": [
85.03251058761712,
779.6524460150363
]
},
"frontendVersion": "1.27.10",
"groupNodes": {},
"ue_links": [],
"links_added_by_ue": [],
"VHS_latentpreview": false,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true
},
"version": 0.4
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 670 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 598 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 674 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 674 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

@@ -0,0 +1,731 @@
{
"id": "11cce4ab-536b-4f42-a95c-0be437d04ace",
"revision": 0,
"last_node_id": 137,
"last_link_id": 364,
"nodes": [
{
"id": 74,
"type": "LanPaint_KSampler",
"pos": [
276.219970703125,
179.55892944335938
],
"size": [
388.97625732421875,
572
],
"flags": {},
"order": 17,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 325
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 323
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 324
},
{
"name": "latent_image",
"type": "LATENT",
"link": 359
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
360
]
}
],
"properties": {
"Node name for S&R": "LanPaint_KSampler",
"cnr_id": "LanPaint",
"ver": "56bd6c04e89124cd06682b304245d6ddf8b20522"
},
"widgets_values": [
0,
"fixed",
20,
4,
"euler",
"simple",
1,
5,
"Image First",
"LanPaint KSampler. For more info, visit https://github.com/scraed/LanPaint. If you find it useful, please give a star \u2b50\ufe0f!",
"\ud83d\uddbc\ufe0f Image Inpainting",
"lanpaint_star_button"
]
},
{
"id": 113,
"type": "SaveImage",
"pos": [
807.1268310546875,
868.395263671875
],
"size": [
311.2532653808594,
484.7096252441406
],
"flags": {},
"order": 20,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 364
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.23"
},
"widgets_values": [
"ComfyUI"
]
},
{
"id": 117,
"type": "CLIPLoader",
"pos": [
-824.4296875,
177.9814910888672
],
"size": [
330,
110
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 0,
"links": [
318,
319
]
}
],
"properties": {
"Node name for S&R": "CLIPLoader",
"cnr_id": "comfy-core",
"ver": "0.3.48",
"models": [
{
"name": "qwen_2.5_vl_7b_fp8_scaled.safetensors",
"url": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors",
"directory": "text_encoders"
}
],
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65,
"widget_ue_connectable": {}
},
"widgets_values": [
"qwen_2.5_vl_7b_fp8_scaled.safetensors",
"qwen_image",
"default"
]
},
{
"id": 122,
"type": "ModelSamplingAuraFlow",
"pos": [
-34.14249038696289,
-43.64523696899414
],
"size": [
300,
58
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 320
}
],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": [
325
]
}
],
"properties": {
"Node name for S&R": "ModelSamplingAuraFlow",
"cnr_id": "comfy-core",
"ver": "0.3.48",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65,
"widget_ue_connectable": {}
},
"widgets_values": [
3.5
]
},
{
"id": 119,
"type": "UNETLoader",
"pos": [
-824.4296875,
37.98154830932617
],
"size": [
330,
90
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [
320
]
}
],
"properties": {
"Node name for S&R": "UNETLoader",
"cnr_id": "comfy-core",
"ver": "0.3.48",
"models": [
{
"name": "qwen_image_fp8_e4m3fn.safetensors",
"url": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_fp8_e4m3fn.safetensors",
"directory": "diffusion_models"
}
],
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65,
"widget_ue_connectable": {}
},
"widgets_values": [
"qwen_image_fp8_e4m3fn.safetensors",
"default"
]
},
{
"id": 128,
"type": "MarkdownNote",
"pos": [
715.929931640625,
371.1071472167969
],
"size": [
300,
190
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [],
"title": "KSampler settings",
"properties": {},
"widgets_values": [
"Decrease **LanPaint_NumSteps** for faster generation. \n"
],
"color": "#432",
"bgcolor": "#653"
},
{
"id": 123,
"type": "LoadImage",
"pos": [
-935.2606748101325,
786.6170753867922
],
"size": [
262.12347412109375,
487.22296142578125
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [
356,
362
]
},
{
"name": "MASK",
"type": "MASK",
"slot_index": 1,
"links": [
357,
363
]
}
],
"properties": {
"Node name for S&R": "LoadImage",
"cnr_id": "comfy-core",
"ver": "0.3.23"
},
"widgets_values": [
"Masked_Load_Me_in_Loader (7).png",
"image"
]
},
{
"id": 118,
"type": "VAELoader",
"pos": [
-824.4296875,
327.9817199707031
],
"size": [
330,
60
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "VAE",
"type": "VAE",
"slot_index": 0,
"links": [
358,
361
]
}
],
"properties": {
"Node name for S&R": "VAELoader",
"cnr_id": "comfy-core",
"ver": "0.3.48",
"models": [
{
"name": "qwen_image_vae.safetensors",
"url": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/vae/qwen_image_vae.safetensors",
"directory": "vae"
}
],
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65,
"widget_ue_connectable": {}
},
"widgets_values": [
"qwen_image_vae.safetensors"
]
},
{
"id": 121,
"type": "CLIPTextEncode",
"pos": [
-454.4298095703125,
247.9816436767578
],
"size": [
425.27801513671875,
180.6060791015625
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 319
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
324
]
}
],
"title": "CLIP Text Encode (Negative Prompt)",
"properties": {
"Node name for S&R": "CLIPTextEncode",
"cnr_id": "comfy-core",
"ver": "0.3.48",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65,
"widget_ue_connectable": {}
},
"widgets_values": [
" low quality, bad anatomy, extra digits, missing digits, extra limbs, missing limbs"
],
"color": "#322",
"bgcolor": "#533"
},
{
"id": 120,
"type": "CLIPTextEncode",
"pos": [
-454.86480712890625,
41.89194869995117
],
"size": [
422.84503173828125,
164.31304931640625
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 318
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
323
]
}
],
"title": "CLIP Text Encode (Positive Prompt)",
"properties": {
"Node name for S&R": "CLIPTextEncode",
"cnr_id": "comfy-core",
"ver": "0.3.48",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65,
"widget_ue_connectable": {}
},
"widgets_values": [
"Cyberpunk-style Einstein portrait: He wears a sleek black coat with glowing cyan circuit patterns, silver-rimmed cybernetic glasses (lenses display faint data streams), and his hair has subtle neon blue highlights. His expression is calm, with a faint smile. Behind him: a dark, rain-washed cybercity backdrop\u2014towering skyscrapers with flickering holographic ads, wet pavement reflecting neon pink/magenta lights. In front of him: giant, glowing white 3D text of \"LanPaint\", with electric blue energy pulses swirling around the equation. Cinematic lighting, hyper-detailed textures, rain droplets visible in the air."
],
"color": "#232",
"bgcolor": "#353"
},
{
"id": 136,
"type": "LanPaint_ImageEncode",
"pos": [
-93.8,
199.6
],
"size": [
270,
110
],
"flags": {},
"order": 50,
"mode": 0,
"inputs": [
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"link": 356
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 358
},
{
"localized_name": "mask",
"name": "mask",
"type": "MASK",
"link": 357
}
],
"outputs": [
{
"localized_name": "latent",
"name": "latent",
"type": "LATENT",
"links": [
359
]
}
],
"properties": {
"Node name for S&R": "LanPaint_ImageEncode",
"cnr_id": "LanPaint"
},
"widgets_values": []
},
{
"id": 137,
"type": "LanPaint_ImageDecode",
"pos": [
626.2,
199.6
],
"size": [
270,
150
],
"flags": {},
"order": 51,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 360
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 361
},
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"link": 362
},
{
"localized_name": "mask",
"name": "mask",
"type": "MASK",
"link": 363
},
{
"localized_name": "blend_overlap",
"name": "blend_overlap",
"type": "INT",
"widget": {
"name": "blend_overlap"
},
"link": null
}
],
"outputs": [
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"links": [
364
]
}
],
"properties": {
"Node name for S&R": "LanPaint_ImageDecode",
"cnr_id": "LanPaint"
},
"widgets_values": [
9
]
}
],
"links": [
[
318,
117,
0,
120,
0,
"CLIP"
],
[
319,
117,
0,
121,
0,
"CLIP"
],
[
320,
119,
0,
122,
0,
"MODEL"
],
[
323,
120,
0,
74,
1,
"CONDITIONING"
],
[
324,
121,
0,
74,
2,
"CONDITIONING"
],
[
325,
122,
0,
74,
0,
"MODEL"
],
[
356,
123,
0,
136,
0,
"IMAGE"
],
[
357,
123,
1,
136,
2,
"MASK"
],
[
358,
118,
0,
136,
1,
"VAE"
],
[
359,
136,
0,
74,
3,
"LATENT"
],
[
360,
74,
0,
137,
0,
"LATENT"
],
[
361,
118,
0,
137,
1,
"VAE"
],
[
362,
123,
0,
137,
2,
"IMAGE"
],
[
363,
123,
1,
137,
3,
"MASK"
],
[
364,
137,
0,
113,
0,
"IMAGE"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.6722341729170042,
"offset": [
1421.2190992818914,
-80.75631642004547
]
},
"frontendVersion": "1.43.18",
"node_versions": {
"comfy-core": "0.3.18",
"LanPaint": "0f509469ed2cd60c6032f739e282aad5dfc06166"
},
"groupNodes": {}
},
"version": 0.4
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 921 KiB

-806
View File
@@ -1,806 +0,0 @@
{
"id": "11cce4ab-536b-4f42-a95c-0be437d04ace",
"revision": 0,
"last_node_id": 128,
"last_link_id": 338,
"nodes": [
{
"id": 74,
"type": "LanPaint_KSampler",
"pos": [
276.219970703125,
179.55892944335938
],
"size": [
388.97625732421875,
572
],
"flags": {},
"order": 10,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 325
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 323
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 324
},
{
"name": "latent_image",
"type": "LATENT",
"link": 332
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
334
]
}
],
"properties": {
"cnr_id": "LanPaint",
"ver": "56bd6c04e89124cd06682b304245d6ddf8b20522",
"Node name for S&R": "LanPaint_KSampler"
},
"widgets_values": [
0,
"fixed",
20,
4,
"euler",
"simple",
1,
5,
"Image First",
"LanPaint KSampler. For more info, visit https://github.com/scraed/LanPaint. If you find it useful, please give a star ⭐️!"
]
},
{
"id": 113,
"type": "SaveImage",
"pos": [
807.1268310546875,
868.395263671875
],
"size": [
311.2532653808594,
484.7096252441406
],
"flags": {},
"order": 13,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 338
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.23"
},
"widgets_values": [
"ComfyUI"
]
},
{
"id": 117,
"type": "CLIPLoader",
"pos": [
-824.4296875,
177.9814910888672
],
"size": [
330,
110
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 0,
"links": [
318,
319
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.48",
"Node name for S&R": "CLIPLoader",
"models": [
{
"name": "qwen_2.5_vl_7b_fp8_scaled.safetensors",
"url": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors",
"directory": "text_encoders"
}
],
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65,
"widget_ue_connectable": {}
},
"widgets_values": [
"qwen_2.5_vl_7b_fp8_scaled.safetensors",
"qwen_image",
"default"
]
},
{
"id": 118,
"type": "VAELoader",
"pos": [
-824.4296875,
327.9817199707031
],
"size": [
330,
60
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "VAE",
"type": "VAE",
"slot_index": 0,
"links": [
329,
335
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.48",
"Node name for S&R": "VAELoader",
"models": [
{
"name": "qwen_image_vae.safetensors",
"url": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/vae/qwen_image_vae.safetensors",
"directory": "vae"
}
],
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65,
"widget_ue_connectable": {}
},
"widgets_values": [
"qwen_image_vae.safetensors"
]
},
{
"id": 121,
"type": "CLIPTextEncode",
"pos": [
-454.4298095703125,
247.9816436767578
],
"size": [
425.27801513671875,
180.6060791015625
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 319
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
324
]
}
],
"title": "CLIP Text Encode (Negative Prompt)",
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.48",
"Node name for S&R": "CLIPTextEncode",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65,
"widget_ue_connectable": {}
},
"widgets_values": [
" low quality, bad anatomy, extra digits, missing digits, extra limbs, missing limbs"
],
"color": "#322",
"bgcolor": "#533"
},
{
"id": 122,
"type": "ModelSamplingAuraFlow",
"pos": [
-34.14249038696289,
-43.64523696899414
],
"size": [
300,
58
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 320
}
],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": [
325
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.48",
"Node name for S&R": "ModelSamplingAuraFlow",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65,
"widget_ue_connectable": {}
},
"widgets_values": [
3.5
]
},
{
"id": 119,
"type": "UNETLoader",
"pos": [
-824.4296875,
37.98154830932617
],
"size": [
330,
90
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [
320
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.48",
"Node name for S&R": "UNETLoader",
"models": [
{
"name": "qwen_image_fp8_e4m3fn.safetensors",
"url": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_fp8_e4m3fn.safetensors",
"directory": "diffusion_models"
}
],
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65,
"widget_ue_connectable": {}
},
"widgets_values": [
"qwen_image_fp8_e4m3fn.safetensors",
"default"
]
},
{
"id": 124,
"type": "VAEEncode",
"pos": [
-530.8583984375,
708.7066650390625
],
"size": [
210,
46
],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{
"name": "pixels",
"type": "IMAGE",
"link": 326
},
{
"name": "vae",
"type": "VAE",
"link": 329
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
327
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.23",
"Node name for S&R": "VAEEncode"
},
"widgets_values": []
},
{
"id": 125,
"type": "SetLatentNoiseMask",
"pos": [
-234.4196014404297,
705.1629638671875
],
"size": [
264.5999755859375,
46
],
"flags": {},
"order": 9,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 327
},
{
"name": "mask",
"type": "MASK",
"link": 328
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
332
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.23",
"Node name for S&R": "SetLatentNoiseMask"
},
"widgets_values": []
},
{
"id": 127,
"type": "LanPaint_MaskBlend",
"pos": [
405.3840637207031,
938.8120727539062
],
"size": [
210,
98
],
"flags": {},
"order": 12,
"mode": 0,
"inputs": [
{
"name": "image1",
"type": "IMAGE",
"link": 336
},
{
"name": "image2",
"type": "IMAGE",
"link": 333
},
{
"name": "mask",
"type": "MASK",
"link": 337
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
338
]
}
],
"properties": {
"cnr_id": "LanPaint",
"ver": "4d3d5d17f0105b673df92da5b084cce567c9c712",
"Node name for S&R": "LanPaint_MaskBlend"
},
"widgets_values": [
9
]
},
{
"id": 126,
"type": "VAEDecode",
"pos": [
115.07575225830078,
878.4630737304688
],
"size": [
210,
46
],
"flags": {},
"order": 11,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 334
},
{
"name": "vae",
"type": "VAE",
"link": 335
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [
333
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.23",
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 123,
"type": "LoadImage",
"pos": [
-543.5358276367188,
851.759765625
],
"size": [
262.12347412109375,
487.22296142578125
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [
326,
336
]
},
{
"name": "MASK",
"type": "MASK",
"slot_index": 1,
"links": [
328,
337
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.23",
"Node name for S&R": "LoadImage"
},
"widgets_values": [
"Masked_Load_Me_in_Loader (7).png",
"image"
]
},
{
"id": 120,
"type": "CLIPTextEncode",
"pos": [
-454.86480712890625,
41.89194869995117
],
"size": [
422.84503173828125,
164.31304931640625
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 318
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
323
]
}
],
"title": "CLIP Text Encode (Positive Prompt)",
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.48",
"Node name for S&R": "CLIPTextEncode",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65,
"widget_ue_connectable": {}
},
"widgets_values": [
"Cyberpunk-style Einstein portrait: He wears a sleek black coat with glowing cyan circuit patterns, silver-rimmed cybernetic glasses (lenses display faint data streams), and his hair has subtle neon blue highlights. His expression is calm, with a faint smile. Behind him: a dark, rain-washed cybercity backdrop—towering skyscrapers with flickering holographic ads, wet pavement reflecting neon pink/magenta lights. In front of him: giant, glowing white 3D text of \"LanPaint\", with electric blue energy pulses swirling around the equation. Cinematic lighting, hyper-detailed textures, rain droplets visible in the air."
],
"color": "#232",
"bgcolor": "#353"
},
{
"id": 128,
"type": "MarkdownNote",
"pos": [
715.929931640625,
371.1071472167969
],
"size": [
300,
190
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [],
"outputs": [],
"title": "KSampler settings",
"properties": {},
"widgets_values": [
"Decrease **LanPaint_NumSteps** for faster generation. \n"
],
"color": "#432",
"bgcolor": "#653"
}
],
"links": [
[
318,
117,
0,
120,
0,
"CLIP"
],
[
319,
117,
0,
121,
0,
"CLIP"
],
[
320,
119,
0,
122,
0,
"MODEL"
],
[
323,
120,
0,
74,
1,
"CONDITIONING"
],
[
324,
121,
0,
74,
2,
"CONDITIONING"
],
[
325,
122,
0,
74,
0,
"MODEL"
],
[
326,
123,
0,
124,
0,
"IMAGE"
],
[
327,
124,
0,
125,
0,
"LATENT"
],
[
328,
123,
1,
125,
1,
"MASK"
],
[
329,
118,
0,
124,
1,
"VAE"
],
[
332,
125,
0,
74,
3,
"LATENT"
],
[
333,
126,
0,
127,
1,
"IMAGE"
],
[
334,
74,
0,
126,
0,
"LATENT"
],
[
335,
118,
0,
126,
1,
"VAE"
],
[
336,
123,
0,
127,
0,
"IMAGE"
],
[
337,
123,
1,
127,
2,
"MASK"
],
[
338,
127,
0,
113,
0,
"IMAGE"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.7162766973052638,
"offset": [
1084.0595529886727,
5.084234529384386
]
},
"frontendVersion": "1.25.10",
"node_versions": {
"comfy-core": "0.3.18",
"LanPaint": "0f509469ed2cd60c6032f739e282aad5dfc06166"
},
"groupNodes": {}
},
"version": 0.4
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

@@ -0,0 +1,759 @@
{
"id": "26fb90cb-eb4a-422e-97d0-8b84dd6c3302",
"revision": 0,
"last_node_id": 79,
"last_link_id": 211,
"nodes": [
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [
333.06903076171875,
249.68698120117188
],
"size": [
422.84503173828125,
164.31304931640625
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 81
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
199
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode",
"cnr_id": "comfy-core",
"ver": "0.3.23"
},
"widgets_values": [
"1girl, blue shirt, masterpiece, high score, great score, absurdres"
]
},
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [
335.06903076171875,
462.68701171875
],
"size": [
425.27801513671875,
180.6060791015625
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 82
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
200
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode",
"cnr_id": "comfy-core",
"ver": "0.3.23"
},
"widgets_values": [
"lowres, bad anatomy, bad hands, text, error, missing finger, extra digits, fewer digits, cropped, worst quality, low quality, low score, bad score, average score, signature, watermark, username, blurry, nude, NSFW"
]
},
{
"id": 78,
"type": "LanPaint_ImageEncode",
"pos": [
623.0,
288.7
],
"size": [
195.3150390625,
66
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"link": 204
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 203
},
{
"localized_name": "mask",
"name": "mask",
"shape": 7,
"type": "MASK",
"link": 205
}
],
"outputs": [
{
"localized_name": "latent",
"name": "latent",
"type": "LATENT",
"links": [
206
]
}
],
"properties": {
"Node name for S&R": "LanPaint_ImageEncode"
}
},
{
"id": 73,
"type": "LanPaint_KSampler",
"pos": [
992.965419921875,
268.686944885254
],
"size": [
413.6495666503906,
572
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 198
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 199
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 200
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 206
},
{
"localized_name": "seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": null
},
{
"localized_name": "steps",
"name": "steps",
"type": "INT",
"widget": {
"name": "steps"
},
"link": null
},
{
"localized_name": "cfg",
"name": "cfg",
"type": "FLOAT",
"widget": {
"name": "cfg"
},
"link": null
},
{
"localized_name": "sampler_name",
"name": "sampler_name",
"type": "COMBO",
"widget": {
"name": "sampler_name"
},
"link": null
},
{
"localized_name": "scheduler",
"name": "scheduler",
"type": "COMBO",
"widget": {
"name": "scheduler"
},
"link": null
},
{
"localized_name": "denoise",
"name": "denoise",
"type": "FLOAT",
"widget": {
"name": "denoise"
},
"link": null
},
{
"localized_name": "LanPaint_NumSteps",
"name": "LanPaint_NumSteps",
"type": "INT",
"widget": {
"name": "LanPaint_NumSteps"
},
"link": null
},
{
"localized_name": "LanPaint_PromptMode",
"name": "LanPaint_PromptMode",
"type": "COMBO",
"widget": {
"name": "LanPaint_PromptMode"
},
"link": null
},
{
"localized_name": "LanPaint_Info",
"name": "LanPaint_Info",
"type": "STRING",
"widget": {
"name": "LanPaint_Info"
},
"link": null
},
{
"localized_name": "Inpainting_mode",
"name": "Inpainting_mode",
"type": "COMBO",
"widget": {
"name": "Inpainting_mode"
},
"link": null
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
207
]
}
],
"properties": {
"Node name for S&R": "LanPaint_KSampler",
"cnr_id": "LanPaint",
"ver": "56bd6c04e89124cd06682b304245d6ddf8b20522"
},
"widgets_values": [
0,
"fixed",
20,
5,
"euler",
"karras",
1,
5,
"Image First",
"LanPaint KSampler. For more info, visit https://github.com/scraed/LanPaint. If you find it useful, please give a star \u2b50\ufe0f!",
"\ud83d\uddbc\ufe0f Image Inpainting",
"lanpaint_star_button"
]
},
{
"id": 29,
"type": "CheckpointLoaderSimple",
"pos": [
-62.627220153808594,
407.01416015625
],
"size": [
315,
98
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "ckpt_name",
"name": "ckpt_name",
"type": "COMBO",
"widget": {
"name": "ckpt_name"
},
"link": null
}
],
"outputs": [
{
"localized_name": "MODEL",
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [
198
]
},
{
"localized_name": "CLIP",
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [
81,
82
]
},
{
"localized_name": "VAE",
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [
203,
208
]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple",
"cnr_id": "comfy-core",
"ver": "0.3.23"
},
"widgets_values": [
"animagineXL40_v4Opt.safetensors"
]
},
{
"id": 20,
"type": "LoadImage",
"pos": [
45.70227813720703,
1147.2928466796875
],
"size": [
262.12347412109375,
487.22296142578125
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "image",
"name": "image",
"type": "COMBO",
"widget": {
"name": "image"
},
"link": null
},
{
"localized_name": "choose file to upload",
"name": "upload",
"type": "IMAGEUPLOAD",
"widget": {
"name": "upload"
},
"link": null
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [
204,
209
]
},
{
"localized_name": "MASK",
"name": "MASK",
"type": "MASK",
"slot_index": 1,
"links": [
205,
210
]
}
],
"properties": {
"Node name for S&R": "LoadImage",
"cnr_id": "comfy-core",
"ver": "0.3.23"
},
"widgets_values": [
"Masked_Load_Me_in_Loader (19).png",
"image"
]
},
{
"id": 75,
"type": "SaveImage",
"pos": [
2279.375686842185,
747.0061447670435
],
"size": [
311.2532653808594,
484.7096252441406
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"link": 211
},
{
"localized_name": "filename_prefix",
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
},
"link": null
}
],
"outputs": [
{
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"links": null
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.23"
},
"widgets_values": [
"ComfyUI"
]
},
{
"id": 79,
"type": "LanPaint_ImageDecode",
"pos": [
1343.0,
288.7
],
"size": [
270,
118
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"localized_name": "samples",
"name": "samples",
"type": "LATENT",
"link": 207
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": 208
},
{
"localized_name": "image",
"name": "image",
"shape": 7,
"type": "IMAGE",
"link": 209
},
{
"localized_name": "mask",
"name": "mask",
"shape": 7,
"type": "MASK",
"link": 210
},
{
"localized_name": "blend_overlap",
"name": "blend_overlap",
"shape": 7,
"type": "INT",
"widget": {
"name": "blend_overlap"
},
"link": null
}
],
"outputs": [
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"links": [
211
]
}
],
"properties": {
"Node name for S&R": "LanPaint_ImageDecode"
},
"widgets_values": [
9
]
}
],
"links": [
[
81,
29,
1,
6,
0,
"CLIP"
],
[
82,
29,
1,
7,
0,
"CLIP"
],
[
198,
29,
0,
73,
0,
"MODEL"
],
[
199,
6,
0,
73,
1,
"CONDITIONING"
],
[
200,
7,
0,
73,
2,
"CONDITIONING"
],
[
203,
29,
2,
78,
1,
"VAE"
],
[
204,
20,
0,
78,
0,
"IMAGE"
],
[
205,
20,
1,
78,
2,
"MASK"
],
[
206,
78,
0,
73,
3,
"LATENT"
],
[
207,
73,
0,
79,
0,
"LATENT"
],
[
208,
29,
2,
79,
1,
"VAE"
],
[
209,
20,
0,
79,
2,
"IMAGE"
],
[
210,
20,
1,
79,
3,
"MASK"
],
[
211,
79,
0,
75,
0,
"IMAGE"
]
],
"groups": [
{
"id": 1,
"title": "Mask image for inpainting.",
"bounding": [
36.04227828979492,
989.7313232421875,
278.89093017578125,
669.3414916992188
],
"color": "#3f789e",
"flags": {}
},
{
"id": 2,
"title": "Convert Latents for LanPaint",
"bounding": [
286.0640563964844,
714.343505859375,
489.16796875,
197.81044006347656
],
"color": "#3f789e",
"flags": {}
},
{
"id": 3,
"title": "Load Model and Set Prompts",
"bounding": [
-78.9311294555664,
176.08712768554688,
1006.1721801757812,
514.258544921875
],
"color": "#3f789e",
"flags": {}
},
{
"id": 4,
"title": "Inpaint with the LanPaint KSampler",
"bounding": [
960.8922729492188,
179.17588806152344,
474.8909606933594,
630.4742431640625
],
"color": "#3f789e",
"flags": {}
},
{
"id": 11,
"title": "LanPaint",
"bounding": [
-262.59381103515625,
140.46656799316406,
1737.328857421875,
797.4443359375
],
"color": "#3f789e",
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 0.8954302432552493,
"offset": [
-1381.79269847794,
-521.6998945185028
]
},
"frontendVersion": "1.25.10",
"node_versions": {
"comfy-core": "0.3.18",
"LanPaint": "0f509469ed2cd60c6032f739e282aad5dfc06166"
}
},
"version": 0.4
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

-735
View File
@@ -1,735 +0,0 @@
{
"id": "26fb90cb-eb4a-422e-97d0-8b84dd6c3302",
"revision": 0,
"last_node_id": 75,
"last_link_id": 192,
"nodes": [
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [
333.06903076171875,
249.68698120117188
],
"size": [
422.84503173828125,
164.31304931640625
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 81
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
184
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.23",
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"1girl, blue shirt, masterpiece, high score, great score, absurdres"
]
},
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [
335.06903076171875,
462.68701171875
],
"size": [
425.27801513671875,
180.6060791015625
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 82
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
185
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.23",
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"lowres, bad anatomy, bad hands, text, error, missing finger, extra digits, fewer digits, cropped, worst quality, low quality, low score, bad score, average score, signature, watermark, username, blurry, nude, NSFW"
]
},
{
"id": 29,
"type": "CheckpointLoaderSimple",
"pos": [
-62.627220153808594,
407.01416015625
],
"size": [
315,
98
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [
183
]
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [
81,
82
]
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [
84,
157
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.23",
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"animagineXL40_v4Opt.safetensors"
]
},
{
"id": 48,
"type": "SaveImage",
"pos": [
1091.036376953125,
1158.526611328125
],
"size": [
311.2532653808594,
484.7096252441406
],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 103
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.23"
},
"widgets_values": [
"ComfyUI"
]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [
1211.46484375,
1065.318359375
],
"size": [
210,
46
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 187
},
{
"name": "vae",
"type": "VAE",
"link": 84
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [
103,
189
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.23",
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 20,
"type": "LoadImage",
"pos": [
45.70227813720703,
1147.2928466796875
],
"size": [
262.12347412109375,
487.22296142578125
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [
156,
190
]
},
{
"name": "MASK",
"type": "MASK",
"slot_index": 1,
"links": [
155,
191
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.23",
"Node name for S&R": "LoadImage"
},
"widgets_values": [
"clipspace/clipspace-mask-2620525.399999976.png [input]",
"image"
]
},
{
"id": 75,
"type": "SaveImage",
"pos": [
1845.7059326171875,
1044.06201171875
],
"size": [
311.2532653808594,
484.7096252441406
],
"flags": {},
"order": 10,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 188
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.23"
},
"widgets_values": [
"ComfyUI"
]
},
{
"id": 73,
"type": "LanPaint_KSampler",
"pos": [
996.4982299804688,
299.599365234375
],
"size": [
413.6495666503906,
572
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 183
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 184
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 185
},
{
"name": "latent_image",
"type": "LATENT",
"link": 186
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
187
]
}
],
"properties": {
"cnr_id": "LanPaint",
"ver": "56bd6c04e89124cd06682b304245d6ddf8b20522",
"Node name for S&R": "LanPaint_KSampler"
},
"widgets_values": [
0,
"fixed",
30,
5,
"euler",
"karras",
1,
5,
"Image First",
"LanPaint KSampler. For more info, visit https://github.com/scraed/LanPaint. If you find it useful, please give a star ⭐️!"
]
},
{
"id": 74,
"type": "LanPaint_MaskBlend",
"pos": [
1512.2730712890625,
1176.3270263671875
],
"size": [
210,
98
],
"flags": {},
"order": 9,
"mode": 0,
"inputs": [
{
"name": "image1",
"type": "IMAGE",
"link": 190
},
{
"name": "image2",
"type": "IMAGE",
"link": 189
},
{
"name": "mask",
"type": "MASK",
"link": 191
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
188
]
}
],
"properties": {
"cnr_id": "LanPaint",
"ver": "4d3d5d17f0105b673df92da5b084cce567c9c712",
"Node name for S&R": "LanPaint_MaskBlend"
},
"widgets_values": [
9
]
},
{
"id": 66,
"type": "SetLatentNoiseMask",
"pos": [
480.30780029296875,
821.3880004882812
],
"size": [
264.5999755859375,
46
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 192
},
{
"name": "mask",
"type": "MASK",
"link": 155
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
186
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.23",
"Node name for S&R": "SetLatentNoiseMask"
},
"widgets_values": []
},
{
"id": 65,
"type": "VAEEncode",
"pos": [
206.43878173828125,
818.9985961914062
],
"size": [
210,
46
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "pixels",
"type": "IMAGE",
"link": 156
},
{
"name": "vae",
"type": "VAE",
"link": 157
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
192
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.23",
"Node name for S&R": "VAEEncode"
},
"widgets_values": []
}
],
"links": [
[
81,
29,
1,
6,
0,
"CLIP"
],
[
82,
29,
1,
7,
0,
"CLIP"
],
[
84,
29,
2,
8,
1,
"VAE"
],
[
103,
8,
0,
48,
0,
"IMAGE"
],
[
155,
20,
1,
66,
1,
"MASK"
],
[
156,
20,
0,
65,
0,
"IMAGE"
],
[
157,
29,
2,
65,
1,
"VAE"
],
[
183,
29,
0,
73,
0,
"MODEL"
],
[
184,
6,
0,
73,
1,
"CONDITIONING"
],
[
185,
7,
0,
73,
2,
"CONDITIONING"
],
[
186,
66,
0,
73,
3,
"LATENT"
],
[
187,
73,
0,
8,
0,
"LATENT"
],
[
188,
74,
0,
75,
0,
"IMAGE"
],
[
189,
8,
0,
74,
1,
"IMAGE"
],
[
190,
20,
0,
74,
0,
"IMAGE"
],
[
191,
20,
1,
74,
2,
"MASK"
],
[
192,
65,
0,
66,
0,
"LATENT"
]
],
"groups": [
{
"id": 1,
"title": "Mask image for inpainting.",
"bounding": [
36.04227828979492,
989.7313232421875,
278.89093017578125,
669.3414916992188
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 2,
"title": "Convert Latents for LanPaint",
"bounding": [
286.0640563964844,
714.343505859375,
489.16796875,
197.81044006347656
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 3,
"title": "Load Model and Set Prompts",
"bounding": [
-78.9311294555664,
176.08712768554688,
1006.1721801757812,
514.258544921875
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 4,
"title": "Inpaint with the LanPaint KSampler",
"bounding": [
960.8922729492188,
179.17588806152344,
474.8909606933594,
630.4742431640625
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 5,
"title": "LanPaint OutPut",
"bounding": [
1085.6029052734375,
994.0775756835938,
345.4561767578125,
669.4969482421875
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 11,
"title": "LanPaint",
"bounding": [
-262.59381103515625,
140.46656799316406,
1737.328857421875,
797.4443359375
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 13,
"title": "Paste back the original to preserve it exactly, if you want",
"bounding": [
1491.586181640625,
984.3547973632812,
749.66455078125,
637.360595703125
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 0.6209213230591556,
"offset": [
850.0803535605011,
86.6432096141053
]
},
"frontendVersion": "1.25.10",
"node_versions": {
"comfy-core": "0.3.18",
"LanPaint": "0f509469ed2cd60c6032f739e282aad5dfc06166"
}
},
"version": 0.4
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 673 KiB

@@ -0,0 +1,998 @@
{
"id": "9ae6082b-c7f4-433c-9971-7a8f65a3ea65",
"revision": 0,
"last_node_id": 78,
"last_link_id": 112,
"nodes": [
{
"id": 40,
"type": "VAELoader",
"pos": [
131.0846434356025,
576.6065838350738
],
"size": [
269.77864583333337,
67.82552083333334
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "VAE",
"type": "VAE",
"links": [
104,
108
]
}
],
"properties": {
"Node name for S&R": "VAELoader",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"models": [
{
"name": "ae.safetensors",
"url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors",
"directory": "vae"
}
],
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [
"ae.safetensors"
]
},
{
"id": 41,
"type": "EmptySD3LatentImage",
"pos": [
130.26337981400314,
705.2539588236402
],
"size": [
259.77864583333337,
124.4921875
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": []
}
],
"properties": {
"Node name for S&R": "EmptySD3LatentImage",
"cnr_id": "comfy-core",
"ver": "0.3.64",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [
1024,
1024,
1
]
},
{
"id": 48,
"type": "LoraLoaderModelOnly",
"pos": [
459.99957646933433,
109.99990579628181
],
"size": [
369.7916666666667,
96.15885416666667
],
"flags": {},
"order": 8,
"mode": 4,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 54
}
],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": [
60
]
}
],
"properties": {
"Node name for S&R": "LoraLoaderModelOnly",
"cnr_id": "comfy-core",
"ver": "0.3.75",
"models": [
{
"name": "pixel_art_style_z_image_turbo.safetensors",
"url": "https://huggingface.co/tarn59/pixel_art_style_lora_z_image_turbo/resolve/main/pixel_art_style_z_image_turbo.safetensors",
"directory": "loras"
}
],
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [
"pixel_art_style_z_image_turbo.safetensors",
1
]
},
{
"id": 35,
"type": "MarkdownNote",
"pos": [
-390.00009654097465,
239.99994184235885
],
"size": [
489.77864583333337,
617.3828125
],
"flags": {
"collapsed": false
},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [],
"title": "Model link",
"properties": {},
"widgets_values": [
"## Report workflow issue\n\nIf you found any issues when running this workflow, [report template issue here](https://github.com/Comfy-Org/workflow_templates/issues)\n\n\n## Model links\n\n**text_encoders**\n\n- [qwen_3_4b.safetensors](https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/text_encoders/qwen_3_4b.safetensors)\n\n**loras**\n\n- [pixel_art_style_z_image_turbo.safetensors](https://huggingface.co/tarn59/pixel_art_style_lora_z_image_turbo/resolve/main/pixel_art_style_z_image_turbo.safetensors)\n\n**diffusion_models**\n\n- [z_image_turbo_bf16.safetensors](https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/diffusion_models/z_image_turbo_bf16.safetensors)\n\n**vae**\n\n- [ae.safetensors](https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors)\n\n\nModel Storage Location\n\n```\n\ud83d\udcc2 ComfyUI/\n\u251c\u2500\u2500 \ud83d\udcc2 models/\n\u2502 \u251c\u2500\u2500 \ud83d\udcc2 text_encoders/\n\u2502 \u2502 \u2514\u2500\u2500 qwen_3_4b.safetensors\n\u2502 \u251c\u2500\u2500 \ud83d\udcc2 loras/\n\u2502 \u2502 \u2514\u2500\u2500 pixel_art_style_z_image_turbo.safetensors\n\u2502 \u251c\u2500\u2500 \ud83d\udcc2 diffusion_models/\n\u2502 \u2502 \u2514\u2500\u2500 z_image_turbo_bf16.safetensors\n\u2502 \u2514\u2500\u2500 \ud83d\udcc2 vae/\n\u2502 \u2514\u2500\u2500 ae.safetensors\n```\n"
],
"color": "#432",
"bgcolor": "#653"
},
{
"id": 61,
"type": "SaveImage",
"pos": [
2759.9685719809763,
991.0132526957497
],
"size": [
1086.7317708333335,
1170.0130208333335
],
"flags": {},
"order": 25,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 111
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.23"
},
"widgets_values": [
"ComfyUI"
]
},
{
"id": 72,
"type": "Note",
"pos": [
1072.3258089884341,
1251.3163769049763
],
"size": [
210,
88
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [
"Reshape image to suit VAE "
],
"color": "#432",
"bgcolor": "#653"
},
{
"id": 46,
"type": "UNETLoader",
"pos": [
130.8803835176007,
238.85087590367888
],
"size": [
269.77864583333337,
96.15885416666667
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": [
54
]
}
],
"properties": {
"Node name for S&R": "UNETLoader",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"models": [
{
"name": "z_image_turbo_bf16.safetensors",
"url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/diffusion_models/z_image_turbo_bf16.safetensors",
"directory": "diffusion_models"
}
],
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [
"z_image_bf16.safetensors",
"default"
]
},
{
"id": 57,
"type": "LoadImage",
"pos": [
6.077161599140862,
923.8378757225194
],
"size": [
273.88020833333337,
546.8098958333334
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
103,
109
]
},
{
"name": "MASK",
"type": "MASK",
"links": [
105,
110
]
}
],
"properties": {
"Node name for S&R": "LoadImage",
"cnr_id": "comfy-core",
"ver": "0.3.59",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.1",
"input_ue_unconnectable": {}
},
"image": "Masked_Load_Me_in_Loader (13).png [input]"
},
"widgets_values": [
"Masked_Load_Me_in_Loader (13).png",
"image"
]
},
{
"id": 39,
"type": "CLIPLoader",
"pos": [
130.26337981400314,
405.2542176588747
],
"size": [
269.77864583333337,
124.4921875
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "CLIP",
"type": "CLIP",
"links": [
44,
98
]
}
],
"properties": {
"Node name for S&R": "CLIPLoader",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"models": [
{
"name": "qwen_3_4b.safetensors",
"url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/text_encoders/qwen_3_4b.safetensors",
"directory": "text_encoders"
}
],
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [
"qwen_3_4b.safetensors",
"lumina2",
"default"
]
},
{
"id": 75,
"type": "CLIPTextEncode",
"pos": [
448.7872367553159,
690.9253024127064
],
"size": [
390,
125
],
"flags": {},
"order": 12,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 98
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
102
]
}
],
"title": "CLIP Text Encode (Negative Prompt)",
"properties": {
"Node name for S&R": "CLIPTextEncode",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [
""
],
"color": "#323",
"bgcolor": "#535"
},
{
"id": 73,
"type": "PreviewImage",
"pos": [
1828.9318713728562,
114.5680075231259
],
"size": [
187.5,
246
],
"flags": {},
"order": 24,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 112
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewImage",
"cnr_id": "comfy-core",
"ver": "0.3.76"
},
"widgets_values": []
},
{
"id": 47,
"type": "ModelSamplingAuraFlow",
"pos": [
920.7037987240392,
248.48064332359456
],
"size": [
309.89583333333337,
67.82552083333334
],
"flags": {},
"order": 13,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 60
}
],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [
100
]
}
],
"properties": {
"Node name for S&R": "ModelSamplingAuraFlow",
"cnr_id": "comfy-core",
"ver": "0.3.64",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [
3
]
},
{
"id": 56,
"type": "LanPaint_KSampler",
"pos": [
1869.2934574860883,
468.13621723594605
],
"size": [
372.64694675564056,
406.22742984745275
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": []
}
],
"properties": {
"Node name for S&R": "LanPaint_KSampler",
"cnr_id": "LanPaint",
"ver": "6109df6591a4cf2bc9d3b113d03f7297fa9248e9"
},
"widgets_values": [
1011254864048366,
"randomize",
20,
4,
"euler",
"simple",
1,
5,
"Prompt First",
"LanPaint KSampler. For more info, visit https://github.com/scraed/LanPaint. If you find it useful, please give a star \u2b50\ufe0f!",
"\ud83d\uddbc\ufe0f Image Inpainting"
]
},
{
"id": 45,
"type": "CLIPTextEncode",
"pos": [
450.2633140828448,
275.25446846701436
],
"size": [
409.77864583333337,
374.77864583333337
],
"flags": {},
"order": 11,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 44
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
101
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [
"A fashion photography work full of surreal romanticism, using a low-angle upward shooting composition, with a clear light blue sky as the background, and the visual focus concentrated on the fantasy blue vegetation and the cute gaint panda walking through it.\n\nThe vegetation in the picture is processed into varying shades of blue, from light ice blue to deep cobalt blue. The textures of the leaves and branches are delicate and realistic. The warm brown tree trunks form a sharp contrast with the cool blue leaves, resembling a dreamy forest from another world. A cute gaint panda walks slowly on the sand. The warm tones of the dress echo with the surrounding cool blue vegetation. The noon sun casts clear shadows on the sand, enhancing the sense of space and reality in the picture.\n\nThe entire scene, with its clean and transparent colors and fantasy settings, not only exudes the vastness of the natural wilderness but also presents a quiet and poetic high-fashion sense due to the surreal vegetation."
],
"color": "#232",
"bgcolor": "#353"
},
{
"id": 76,
"type": "LanPaint_KSamplerAdvanced",
"pos": [
1234.9129727307572,
465.17562131822933
],
"size": [
400,
580
],
"flags": {},
"order": 21,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 100
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 101
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 102
},
{
"name": "latent_image",
"type": "LATENT",
"link": 106
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [
107
]
}
],
"properties": {
"Node name for S&R": "LanPaint_KSamplerAdvanced"
},
"widgets_values": [
"enable",
0,
"fixed",
20,
4,
"euler",
"simple",
0,
10000,
"disable",
5,
5.0,
0.2,
1,
15,
"Image First",
1,
"LanPaint KSampler Adv. For more info, visit https://github.com/scraed/LanPaint. If you find it useful, please give a star \u2b50\ufe0f!",
"\ud83d\uddbc\ufe0f Image Inpainting"
]
},
{
"id": 77,
"type": "LanPaint_ImageEncode",
"pos": [
600.0,
500.0
],
"size": [
300,
150
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 103
},
{
"name": "vae",
"type": "VAE",
"link": 104
},
{
"name": "mask",
"type": "MASK",
"link": 105
}
],
"outputs": [
{
"name": "latent",
"type": "LATENT",
"links": [
106
]
}
],
"widgets_values": [],
"properties": {
"Node name for S&R": "LanPaint_ImageEncode",
"cnr_id": "LanPaint"
}
},
{
"id": 78,
"type": "LanPaint_ImageDecode",
"pos": [
1900.0,
500.0
],
"size": [
300,
200
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 107
},
{
"name": "vae",
"type": "VAE",
"link": 108
},
{
"name": "image",
"type": "IMAGE",
"link": 109
},
{
"name": "mask",
"type": "MASK",
"link": 110
},
{
"name": "blend_overlap",
"type": "INT",
"widget": {
"name": "blend_overlap"
}
}
],
"outputs": [
{
"name": "image",
"type": "IMAGE",
"links": [
111,
112
]
}
],
"widgets_values": [
9
],
"properties": {
"Node name for S&R": "LanPaint_ImageDecode",
"cnr_id": "LanPaint"
}
}
],
"links": [
[
44,
39,
0,
45,
0,
"CLIP"
],
[
54,
46,
0,
48,
0,
"MODEL"
],
[
60,
48,
0,
47,
0,
"MODEL"
],
[
98,
39,
0,
75,
0,
"CLIP"
],
[
100,
47,
0,
76,
0,
"MODEL"
],
[
101,
45,
0,
76,
1,
"CONDITIONING"
],
[
102,
75,
0,
76,
2,
"CONDITIONING"
],
[
103,
57,
0,
77,
0,
"IMAGE"
],
[
104,
40,
0,
77,
1,
"VAE"
],
[
105,
57,
1,
77,
2,
"MASK"
],
[
106,
77,
0,
76,
3,
"LATENT"
],
[
107,
76,
0,
78,
0,
"LATENT"
],
[
108,
40,
0,
78,
1,
"VAE"
],
[
109,
57,
0,
78,
2,
"IMAGE"
],
[
110,
57,
1,
78,
3,
"MASK"
],
[
111,
78,
0,
61,
0,
"IMAGE"
],
[
112,
78,
0,
73,
0,
"IMAGE"
]
],
"groups": [
{
"id": 2,
"title": "Step2 - Image size",
"bounding": [
120,
640,
290,
200
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 3,
"title": "Step3 - Prompt",
"bounding": [
430,
210,
450,
540
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 4,
"title": "Step1 - Load models",
"bounding": [
120,
210,
290,
413.6
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 5,
"title": "Ctrl-B to enable LoRA input",
"bounding": [
430,
40,
440,
160
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 0.2673486306413776,
"offset": [
853.4341376683553,
289.09247282952845
]
},
"frontendVersion": "1.37.11",
"VHS_latentpreview": false,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true,
"workflowRendererVersion": "LG"
},
"version": 0.4
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

@@ -0,0 +1,925 @@
{
"id": "9ae6082b-c7f4-433c-9971-7a8f65a3ea65",
"revision": 0,
"last_node_id": 75,
"last_link_id": 105,
"nodes": [
{
"id": 39,
"type": "CLIPLoader",
"pos": [
158.31605577680375,
488.30506119064967
],
"size": [
323.734375,
179.390625
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "CLIP",
"type": "CLIP",
"links": [
44
]
}
],
"properties": {
"Node name for S&R": "CLIPLoader",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"models": [
{
"name": "qwen_3_4b.safetensors",
"url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/text_encoders/qwen_3_4b.safetensors",
"directory": "text_encoders"
}
],
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [
"qwen_3_4b.safetensors",
"lumina2",
"default"
]
},
{
"id": 40,
"type": "VAELoader",
"pos": [
159.30157212272297,
693.9279006020886
],
"size": [
323.734375,
111.390625
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "VAE",
"type": "VAE",
"links": [
97,
101
]
}
],
"properties": {
"Node name for S&R": "VAELoader",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"models": [
{
"name": "ae.safetensors",
"url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors",
"directory": "vae"
}
],
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [
"ae.safetensors"
]
},
{
"id": 42,
"type": "ConditioningZeroOut",
"pos": [
794.3164307166835,
836.3052371197768
],
"size": [
236.984375,
77.421875
],
"flags": {},
"order": 11,
"mode": 0,
"inputs": [
{
"name": "conditioning",
"type": "CONDITIONING",
"link": 36
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
64
]
}
],
"properties": {
"Node name for S&R": "ConditioningZeroOut",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": []
},
{
"id": 41,
"type": "EmptySD3LatentImage",
"pos": [
158.31605577680375,
848.3047505883683
],
"size": [
311.734375,
179.390625
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": []
}
],
"properties": {
"Node name for S&R": "EmptySD3LatentImage",
"cnr_id": "comfy-core",
"ver": "0.3.64",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [
1024,
1024,
1
]
},
{
"id": 47,
"type": "ModelSamplingAuraFlow",
"pos": [
1106.844558468847,
300.1767719883135
],
"size": [
371.875,
111.390625
],
"flags": {},
"order": 13,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 60
}
],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [
62
]
}
],
"properties": {
"Node name for S&R": "ModelSamplingAuraFlow",
"cnr_id": "comfy-core",
"ver": "0.3.64",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [
3
]
},
{
"id": 45,
"type": "CLIPTextEncode",
"pos": [
542.3159768994137,
332.3053621604172
],
"size": [
491.734375,
479.734375
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 44
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
36,
63
]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [
"Latina female with thick wavy hair, white shirt, harbor boats and pastel houses behind. Breezy seaside light, warm tones, cinematic close-up."
],
"color": "#232",
"bgcolor": "#353"
},
{
"id": 48,
"type": "LoraLoaderModelOnly",
"pos": [
553.9994917632011,
133.99988695553816
],
"size": [
443.75,
145.390625
],
"flags": {},
"order": 10,
"mode": 4,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 54
}
],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": [
60
]
}
],
"properties": {
"Node name for S&R": "LoraLoaderModelOnly",
"cnr_id": "comfy-core",
"ver": "0.3.75",
"models": [
{
"name": "pixel_art_style_z_image_turbo.safetensors",
"url": "https://huggingface.co/tarn59/pixel_art_style_lora_z_image_turbo/resolve/main/pixel_art_style_z_image_turbo.safetensors",
"directory": "loras"
}
],
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [
"pixel_art_style_z_image_turbo.safetensors",
1
]
},
{
"id": 35,
"type": "MarkdownNote",
"pos": [
-466.00011584916956,
289.9999302108306
],
"size": [
587.734375,
770.859375
],
"flags": {
"collapsed": false
},
"order": 3,
"mode": 0,
"inputs": [],
"outputs": [],
"title": "Model link",
"properties": {},
"widgets_values": [
"## Report workflow issue\n\nIf you found any issues when running this workflow, [report template issue here](https://github.com/Comfy-Org/workflow_templates/issues)\n\n\n## Model links\n\n**text_encoders**\n\n- [qwen_3_4b.safetensors](https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/text_encoders/qwen_3_4b.safetensors)\n\n**loras**\n\n- [pixel_art_style_z_image_turbo.safetensors](https://huggingface.co/tarn59/pixel_art_style_lora_z_image_turbo/resolve/main/pixel_art_style_z_image_turbo.safetensors)\n\n**diffusion_models**\n\n- [z_image_turbo_bf16.safetensors](https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/diffusion_models/z_image_turbo_bf16.safetensors)\n\n**vae**\n\n- [ae.safetensors](https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors)\n\n\nModel Storage Location\n\n```\n\ud83d\udcc2 ComfyUI/\n\u251c\u2500\u2500 \ud83d\udcc2 models/\n\u2502 \u251c\u2500\u2500 \ud83d\udcc2 text_encoders/\n\u2502 \u2502 \u2514\u2500\u2500 qwen_3_4b.safetensors\n\u2502 \u251c\u2500\u2500 \ud83d\udcc2 loras/\n\u2502 \u2502 \u2514\u2500\u2500 pixel_art_style_z_image_turbo.safetensors\n\u2502 \u251c\u2500\u2500 \ud83d\udcc2 diffusion_models/\n\u2502 \u2502 \u2514\u2500\u2500 z_image_turbo_bf16.safetensors\n\u2502 \u2514\u2500\u2500 \ud83d\udcc2 vae/\n\u2502 \u2514\u2500\u2500 ae.safetensors\n```\n"
],
"color": "#432",
"bgcolor": "#653"
},
{
"id": 56,
"type": "LanPaint_KSampler",
"pos": [
1280.6434468860161,
480.42471689895115
],
"size": [
479.765625,
887.59375
],
"flags": {},
"order": 20,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 62
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 63
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 64
},
{
"name": "latent_image",
"type": "LATENT",
"link": 99
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [
100
]
}
],
"properties": {
"Node name for S&R": "LanPaint_KSampler",
"cnr_id": "LanPaint",
"ver": "6109df6591a4cf2bc9d3b113d03f7297fa9248e9"
},
"widgets_values": [
880311146947153,
"randomize",
9,
1,
"euler",
"simple",
1,
5,
"Image First",
"LanPaint KSampler. For more info, visit https://github.com/scraed/LanPaint. If you find it useful, please give a star \u2b50\ufe0f!",
"\ud83d\uddbc\ufe0f Image Inpainting"
]
},
{
"id": 57,
"type": "LoadImage",
"pos": [
9.292593918969033,
1110.6054508670231
],
"size": [
328.65625,
686.171875
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
96,
102
]
},
{
"name": "MASK",
"type": "MASK",
"links": [
96,
102
]
}
],
"properties": {
"Node name for S&R": "LoadImage",
"cnr_id": "comfy-core",
"ver": "0.3.59",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.1",
"input_ue_unconnectable": {}
},
"image": "clipspace/clipspace-painted-masked-1764827669471.png [input]"
},
"widgets_values": [
"Example_21_Masked.png",
"image"
]
},
{
"id": 61,
"type": "SaveImage",
"pos": [
3313.9622863771715,
1191.2159032348995
],
"size": [
1304.078125,
1434.015625
],
"flags": {},
"order": 24,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 104
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.23"
},
"widgets_values": [
"ComfyUI"
]
},
{
"id": 73,
"type": "PreviewImage",
"pos": [
2226.506787053337,
107.57278250350078
],
"size": [
225,
246
],
"flags": {},
"order": 23,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 105
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewImage",
"cnr_id": "comfy-core",
"ver": "0.3.76"
},
"widgets_values": []
},
{
"id": 46,
"type": "UNETLoader",
"pos": [
159.05646022112083,
288.62105108441466
],
"size": [
323.734375,
145.390625
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": [
54
]
}
],
"properties": {
"Node name for S&R": "UNETLoader",
"cnr_id": "comfy-core",
"ver": "0.3.73",
"models": [
{
"name": "z_image_turbo_bf16.safetensors",
"url": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/diffusion_models/z_image_turbo_bf16.safetensors",
"directory": "diffusion_models"
}
],
"enableTabs": false,
"tabWidth": 65,
"tabXOffset": 10,
"hasSecondTab": false,
"secondTabText": "Send Back",
"secondTabOffset": 80,
"secondTabWidth": 65
},
"widgets_values": [
"z_image_turbo_bf16.safetensors",
"default"
]
},
{
"id": 72,
"type": "Note",
"pos": [
1288.7909707861209,
1503.5796522859714
],
"size": [
225,
109.765625
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [
"Reshape image to suit VAE "
],
"color": "#432",
"bgcolor": "#653"
},
{
"id": 74,
"type": "LanPaint_ImageEncode",
"pos": [
910.6,
500.4
],
"size": [
300,
150
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 96
},
{
"name": "vae",
"type": "VAE",
"link": 97
},
{
"name": "mask",
"type": "MASK",
"link": 98
}
],
"outputs": [
{
"name": "latent",
"type": "LATENT",
"links": [
99
]
}
],
"widgets_values": [],
"properties": {
"Node name for S&R": "LanPaint_ImageEncode",
"cnr_id": "LanPaint"
}
},
{
"id": 75,
"type": "LanPaint_ImageDecode",
"pos": [
1630.6,
500.4
],
"size": [
300,
200
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 100
},
{
"name": "vae",
"type": "VAE",
"link": 101
},
{
"name": "image",
"type": "IMAGE",
"link": 102
},
{
"name": "mask",
"type": "MASK",
"link": 103
},
{
"name": "blend_overlap",
"type": "INT",
"widget": {
"name": "blend_overlap"
}
}
],
"outputs": [
{
"name": "image",
"type": "IMAGE",
"links": [
104,
105
]
}
],
"widgets_values": [
9
],
"properties": {
"Node name for S&R": "LanPaint_ImageDecode",
"cnr_id": "LanPaint"
}
}
],
"links": [
[
36,
45,
0,
42,
0,
"CONDITIONING"
],
[
44,
39,
0,
45,
0,
"CLIP"
],
[
54,
46,
0,
48,
0,
"MODEL"
],
[
60,
48,
0,
47,
0,
"MODEL"
],
[
62,
47,
0,
56,
0,
"MODEL"
],
[
63,
45,
0,
56,
1,
"CONDITIONING"
],
[
64,
42,
0,
56,
2,
"CONDITIONING"
],
[
96,
57,
0,
74,
0,
"IMAGE"
],
[
97,
40,
0,
74,
1,
"VAE"
],
[
98,
57,
1,
74,
2,
"MASK"
],
[
99,
74,
0,
56,
3,
"LATENT"
],
[
100,
56,
0,
75,
0,
"LATENT"
],
[
101,
40,
0,
75,
1,
"VAE"
],
[
102,
57,
0,
75,
2,
"IMAGE"
],
[
103,
57,
1,
75,
3,
"MASK"
],
[
104,
75,
0,
61,
0,
"IMAGE"
],
[
105,
75,
0,
73,
0,
"IMAGE"
]
],
"groups": [
{
"id": 2,
"title": "Step2 - Image size",
"bounding": [
146,
770,
348,
240
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 3,
"title": "Step3 - Prompt",
"bounding": [
518,
254,
540,
648
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 4,
"title": "Step1 - Load models",
"bounding": [
146,
254,
348,
496.32
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 5,
"title": "Ctrl-B to enable LoRA input",
"bounding": [
518,
50,
528,
192
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 0.3800835362432756,
"offset": [
-902.897494105397,
-396.4805064253962
]
},
"frontendVersion": "1.33.14",
"VHS_latentpreview": false,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true,
"workflowRendererVersion": "Vue"
},
"version": 0.4
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 683 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "LanPaint"
version = "1.4.11"
version = "2.0.0"
description = "Achieve seamless inpainting results without needing a specialized inpainting model."
authors = [
{name = "LanPaint", email = "czhengac@connect.ust.hk"}
+337
View File
@@ -0,0 +1,337 @@
"""
Early Stop Logic Contributed by `https://github.com/godnight10061`.
"""
import inspect
from typing import Any, Callable, Optional
import torch
from .types import LangevinState
def _clamp01(val: float) -> float:
if val <= 0.0:
return 0.0
if val >= 1.0:
return 1.0
return val
def _abt_scale(abt_val: float) -> float:
"""
Smooth, parameter-free scale based on outer-step noise level.
- 0 at abt=0/1 (disable at extreme noise / extreme tail)
- 1 at abt=0.5 (mid-schedule)
"""
abt_val = _clamp01(abt_val)
return _clamp01(4.0 * abt_val * (1.0 - abt_val))
def _boundary_weight(latent_mask: torch.Tensor, inpaint_weight: torch.Tensor) -> Optional[torch.Tensor]:
"""
Return a 4-neighbor boundary weight: unknown pixels adjacent to known pixels.
This replaces the previous dilation-based "ring" (kernel/padding) and has no tunable hyperparameters.
"""
if latent_mask.dim() != 4:
return None
known = latent_mask > 0.5
neighbor_known = torch.zeros_like(known)
neighbor_known[:, :, 1:, :] |= known[:, :, :-1, :]
neighbor_known[:, :, :-1, :] |= known[:, :, 1:, :]
neighbor_known[:, :, :, 1:] |= known[:, :, :, :-1]
neighbor_known[:, :, :, :-1] |= known[:, :, :, 1:]
boundary = (~known) & neighbor_known
return boundary.to(dtype=torch.float32) * inpaint_weight
def _weighted_mse(t1: torch.Tensor, t2: torch.Tensor, weight: torch.Tensor) -> float:
diff_sq = (t1.to(dtype=torch.float32) - t2.to(dtype=torch.float32)) ** 2
denom = torch.sum(weight) + 1e-12
return float((torch.sum(diff_sq * weight) / denom).item())
class LanPaintEarlyStopper:
"""
Per-step early-stop logic for LanPaint inner (Langevin) iterations.
"""
@classmethod
def from_options(
cls,
*,
model_options: Optional[dict],
latent_mask: torch.Tensor,
abt: torch.Tensor,
default_threshold: float,
default_patience: int,
default_distance_fn: Optional[Callable[..., Any]],
) -> Optional["LanPaintEarlyStopper"]:
semantic_stop = model_options.get("lanpaint_semantic_stop") if isinstance(model_options, dict) else None
threshold = float(default_threshold)
patience = int(default_patience)
distance_fn = default_distance_fn
# distance_fn contract: return None (use default metric) or a scalar (Python number / 0-d (1-element) torch.Tensor)
if isinstance(semantic_stop, dict):
threshold = float(semantic_stop.get("threshold", threshold))
patience = int(semantic_stop.get("patience", patience))
distance_fn = semantic_stop.get("distance_fn", distance_fn)
# Backward compatibility: map legacy 'min_steps' to a patience floor so it is not an independent knob.
if patience > 0:
min_steps = semantic_stop.get("min_steps")
if min_steps is not None:
try:
min_steps_int = int(min_steps)
except (TypeError, ValueError):
min_steps_int = 0
if min_steps_int > 1:
patience = max(patience, min_steps_int - 1)
enabled_early_stop = (threshold > 0.0) and (patience > 0)
# Require N+1 consecutive stable checks:
# - the first stable step sets patience_counter to 1
# - `patience=1` therefore stops after 2 stable steps
patience_eff = max(1, patience) + 1
threshold_eff = threshold
inpaint_weight = ring_weight = trace = abt_val = None
if enabled_early_stop:
try:
abt_val = float(torch.mean(abt).item())
except (TypeError, ValueError):
abt_val = 0.0
threshold_eff = threshold * _abt_scale(abt_val)
if threshold_eff <= 0.0:
enabled_early_stop = False
else:
inpaint_weight = (1 - latent_mask).to(dtype=torch.float32)
if float(torch.sum(inpaint_weight).item()) < 1e-6:
enabled_early_stop = False
else:
ring_weight = _boundary_weight(latent_mask, inpaint_weight)
if isinstance(model_options, dict):
trace = model_options.get("lanpaint_semantic_trace")
if not enabled_early_stop:
return None
# Pre-fetch trace keys to avoid repeated dict lookups
bench_case_id = bench_outer_step = bench_timestep = None
if isinstance(trace, list) and isinstance(model_options, dict):
bench_case_id = model_options.get("bench_case_id")
bench_outer_step = model_options.get("bench_outer_step")
bench_timestep = model_options.get("bench_timestep")
return cls(
enabled=enabled_early_stop,
threshold=threshold,
threshold_eff=threshold_eff,
patience_eff=patience_eff,
inpaint_weight=inpaint_weight,
ring_weight=ring_weight,
distance_fn=distance_fn,
trace=trace,
bench_case_id=bench_case_id,
bench_outer_step=bench_outer_step,
bench_timestep=bench_timestep,
abt_val=abt_val,
)
def __init__(
self,
*,
enabled: bool,
threshold: float,
threshold_eff: float,
patience_eff: int,
inpaint_weight: Optional[torch.Tensor],
ring_weight: Optional[torch.Tensor],
distance_fn: Optional[Callable[..., Any]] = None,
trace: Optional[list] = None,
bench_case_id: Any = None,
bench_outer_step: Any = None,
bench_timestep: Any = None,
abt_val: Optional[float] = None,
) -> None:
self.enabled = bool(enabled)
self.threshold = float(threshold)
self.threshold_eff = float(threshold_eff)
self.patience_eff = int(patience_eff)
self.inpaint_weight = inpaint_weight
self.ring_weight = ring_weight
self.trace = trace
self.bench_case_id = bench_case_id
self.bench_outer_step = bench_outer_step
self.bench_timestep = bench_timestep
self.abt_val = abt_val
self.patience_counter = 0
self.x0_anchor = None
self._dist_wrapper = self._wrap_distance_fn(distance_fn) if self.enabled else None
@property
def has_custom_distance_fn(self) -> bool:
return self._dist_wrapper is not None
@staticmethod
def _wrap_distance_fn(distance_fn: Optional[Callable[..., Any]]):
"""
Wrap a user-provided `distance_fn` into a normalized callable: fn(prev, cur, ctx) -> dist|None.
Supported signatures:
- 3+ positional (or *args): `distance_fn(prev, cur, ctx)`
- explicit / **kwargs ctx: `distance_fn(prev, cur, ctx=ctx)`
- default 2-arg: `distance_fn(cur, prev)`
Return contract: None (use default metric) or a scalar (Python number / 0-d (1-element) torch.Tensor).
"""
if not callable(distance_fn):
return None
try:
sig = inspect.signature(distance_fn)
params = list(sig.parameters.values())
has_ctx_param = "ctx" in sig.parameters
has_var_kw = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params)
has_var_pos = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in params)
pos_params = [
p
for p in params
if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
]
if len(pos_params) >= 3 or has_var_pos:
# 3-arg positional: fn(prev, cur, ctx)
return lambda p, c, ctx: distance_fn(p, c, ctx)
if has_ctx_param or has_var_kw:
# keyword ctx: fn(prev, cur, ctx=ctx)
return lambda p, c, ctx: distance_fn(p, c, ctx=ctx)
# Default 2-arg: fn(cur, prev)
return lambda p, c, ctx: distance_fn(c, p)
except (ValueError, TypeError):
# Fallback for built-ins or complex callables.
def fallback_wrapper(p, c, ctx):
try:
return distance_fn(p, c, ctx)
except TypeError as e:
tb = e.__traceback__
if tb is not None and tb.tb_frame.f_code is not fallback_wrapper.__code__:
raise
return distance_fn(c, p)
return fallback_wrapper
def step(
self,
*,
i: int,
n_steps: int,
x_t_before: torch.Tensor,
x_t_after: torch.Tensor,
x_t_prev_for_custom: Optional[torch.Tensor],
prev_args: Any,
args: Any,
ctx: dict,
) -> bool:
if not self.enabled:
return False
# 'inpaint_weight' is guaranteed to be set when enabled is True in the caller.
inpaint = self.inpaint_weight
if inpaint is None:
return False
dist = None
custom_dist = False
dist_inpaint = dist_ring = dist_drift = x0_prev = x0_cur = None
if self._dist_wrapper is not None:
dist = self._dist_wrapper(x_t_prev_for_custom, x_t_after, ctx)
if dist is not None:
if isinstance(dist, torch.Tensor):
if dist.numel() != 1:
raise TypeError("distance_fn must return None or a scalar / 0-d (1-element) tensor")
dist = float(dist.item())
else:
dist = float(dist)
custom_dist = dist is not None
if dist is None:
def _get_x0(arg: Any) -> Optional[torch.Tensor]:
if isinstance(arg, LangevinState):
return arg.x0
if isinstance(arg, tuple) and len(arg) >= 3:
return arg[2]
return None
x0_prev = _get_x0(prev_args)
x0_cur = _get_x0(args)
if x0_prev is not None and x0_cur is not None:
dist_inpaint = _weighted_mse(x0_cur, x0_prev, inpaint)
dist_ring = _weighted_mse(x0_cur, x0_prev, self.ring_weight) if self.ring_weight is not None else None
dist = dist_inpaint if dist_ring is None else max(dist_inpaint, dist_ring)
else:
dist_inpaint = _weighted_mse(x_t_after, x_t_before, inpaint)
dist = dist_inpaint
threshold_used = self.threshold if custom_dist else self.threshold_eff
# Drift guard (only for default metric with x0_cur).
if x0_cur is not None and not custom_dist:
if dist <= threshold_used:
if self.x0_anchor is None:
self.x0_anchor = x0_cur.detach()
else:
drift_inpaint = _weighted_mse(x0_cur, self.x0_anchor, inpaint)
drift_ring = _weighted_mse(x0_cur, self.x0_anchor, self.ring_weight) if self.ring_weight is not None else None
dist_drift = drift_inpaint if drift_ring is None else max(drift_inpaint, drift_ring)
dist = max(dist, dist_drift)
else:
self.x0_anchor = None
if dist <= threshold_used:
self.patience_counter += 1
else:
self.patience_counter = 0
self.x0_anchor = None
should_stop = self.patience_counter >= self.patience_eff
if isinstance(self.trace, list):
self.trace.append(
{
"case_id": self.bench_case_id,
"outer_step": self.bench_outer_step,
"bench_timestep": self.bench_timestep,
"inner_step": i + 1,
"dist": dist,
"dist_inpaint": None if dist_inpaint is None else float(dist_inpaint),
"dist_ring": None if dist_ring is None else float(dist_ring),
"dist_drift": None if dist_drift is None else float(dist_drift),
"threshold": float(threshold_used),
"threshold_eff": float(self.threshold_eff),
"patience_counter": int(self.patience_counter),
"patience_eff": int(self.patience_eff),
"abt": None if self.abt_val is None else float(self.abt_val),
"custom_dist": bool(custom_dist),
"stopped": bool(should_stop),
}
)
return bool(should_stop)
+158 -60
View File
@@ -1,9 +1,11 @@
import torch
from .utils import StochasticHarmonicOscillator
# from .utils import StochasticHarmonicOscillator # second-order scheme, not used
from functools import partial
from .earlystop import LanPaintEarlyStopper
from .types import LangevinState
class LanPaint():
def __init__(self, Model, NSteps, Friction, Lambda, Beta, StepSize, IS_FLUX = False, IS_FLOW = False):
def __init__(self, Model, NSteps, Friction, Lambda, Beta, StepSize, IS_FLUX = False, IS_FLOW = False, EarlyStopThreshold = 0.0, EarlyStopPatience = 1, EarlyStopHook = None):
self.n_steps = NSteps
self.chara_lamb = Lambda
self.IS_FLUX = IS_FLUX
@@ -13,35 +15,77 @@ class LanPaint():
self.friction = Friction
self.chara_beta = Beta
self.img_dim_size = None
self.early_stop_threshold = EarlyStopThreshold
self.early_stop_patience = EarlyStopPatience
self.early_stop_hook = EarlyStopHook
def add_none_dims(self, array):
# Create a tuple with ':' for the first dimension and 'None' repeated num_nones times
index = (slice(None),) + (None,) * (self.img_dim_size-1)
return array[index]
# Broadcast to the latent's dimensionality. Identical to the tuple-index
# form for scalar/[B] inputs; per-row (already broadcast) tensors pass
# through unchanged.
while array.ndim < self.img_dim_size:
array = array.unsqueeze(array.ndim)
return array
def remove_none_dims(self, array):
# Create a tuple with ':' for the first dimension and 'None' repeated num_nones times
index = (slice(None),) + (0,) * (self.img_dim_size-1)
return array[index]
def __call__(self, x, latent_image, noise, sigma, latent_mask, current_times, model_options, seed, n_steps=None):
def unpack_model_output(self, output):
# Some guider/model wrappers return one denoised latent, others return
# both the normal and BIG-guidance denoised latents.
if isinstance(output, (tuple, list)):
if len(output) >= 2:
return output[0], output[1]
if len(output) == 1:
return output[0], output[0]
raise ValueError("Model output is empty")
return output, output
def __call__(self, x, latent_image, noise, sigma, latent_mask, current_times, model_options, seed, n_steps=None, current_times_audio=None, audio_indicator=None, audio_correction=None):
self.img_dim_size = len(x.shape)
self.latent_image = latent_image
self.noise = noise
self.audio_indicator = audio_indicator
self.current_times_audio = current_times_audio
self.audio_correction = audio_correction
if torch.mean(torch.abs(self.noise)) < 1e-8:
self.noise = torch.randn_like(self.noise)
if n_steps is None:
n_steps = self.n_steps
return self.LanPaint(x, sigma, latent_mask, current_times, n_steps, model_options, seed, self.IS_FLUX, self.IS_FLOW)
def LanPaint(self, x, sigma, latent_mask, current_times, n_steps, model_options, seed, IS_FLUX, IS_FLOW):
input_x = x
VE_Sigma, abt, Flow_t = current_times
# MiniMax H3 AV packs: the audio rows of the flat pack run on their own
# shifted sigma schedule (sigma_audio = time_shift_sigma(sigma_video,
# shift_v, shift_a)). Blend the per-stream times so every downstream
# consumer (x_t conversions, score, dynamics coefficients, replace
# step) uses the audio schedule on the audio rows and the video
# schedule elsewhere. Flow_t stays the video timestep -- the DiT
# derives the audio schedule from it internally.
replace_sigma = sigma
if self.audio_indicator is not None and self.current_times_audio is not None:
VE_a, abt_a, Flow_a = self.current_times_audio
ai = self.audio_indicator
VE_Sigma = VE_Sigma * (1 - ai) + VE_a * ai
abt = abt * (1 - ai) + abt_a * ai
replace_sigma = sigma * (1 - ai) + Flow_a * ai
current_times = (VE_Sigma, abt, Flow_t)
step_size = self.step_size * (1 - abt)
step_size = self.add_none_dims(step_size)
# self.inner_model.inner_model.scale_latent_inpaint returns variance exploding x_t values
# This is the replace step
def scale_latent_inpaint(x, sigma, noise, latent_image):
return self.inner_model.inner_model.model_sampling.noise_scaling(sigma.reshape([sigma.shape[0]] + [1] * (len(noise.shape) - 1)), noise, latent_image)
s = self.add_none_dims(sigma)
if s.numel() == 1:
return self.inner_model.inner_model.model_sampling.noise_scaling(s, noise, latent_image)
# per-row (audio) sigma: model_sampling.noise_scaling requires a
# scalar sigma, so emulate its flow form elementwise
ns = getattr(self.inner_model.inner_model.model_sampling, "noise_scale", 1.0)
return s * (ns * noise) + (1.0 - s) * latent_image
x = x * (1 - latent_mask) + scale_latent_inpaint(x=x, sigma=sigma, noise=self.noise, latent_image=self.latent_image)* latent_mask
x = x * (1 - latent_mask) + scale_latent_inpaint(x=x, sigma=replace_sigma, noise=self.noise, latent_image=self.latent_image)* latent_mask
if IS_FLUX or IS_FLOW:
x_t = x * ( self.add_none_dims(abt)**0.5 + (1-self.add_none_dims(abt))**0.5 )
@@ -51,17 +95,59 @@ class LanPaint():
############ LanPaint Iterations Start ###############
# after noise_scaling, noise = latent_image + noise * sigma, which is x_t in the variance exploding diffusion model notation for the known region.
args = None
stopper = LanPaintEarlyStopper.from_options(
model_options=model_options if isinstance(model_options, dict) else None,
latent_mask=latent_mask,
abt=abt,
default_threshold=self.early_stop_threshold,
default_patience=self.early_stop_patience,
default_distance_fn=self.early_stop_hook,
)
for i in range(n_steps):
score_func = partial( self.score_model, y = self.latent_image, mask = latent_mask, abt = self.add_none_dims(abt), sigma = self.add_none_dims(VE_Sigma), tflow = self.add_none_dims(Flow_t), model_options = model_options, seed = seed )
prev_args = args
x_t_prev = x_t.detach() if (stopper is not None and stopper.has_custom_distance_fn) else None
x_t_before = x_t if (stopper is not None and stopper.enabled) else None
x_t, args = self.langevin_dynamics(x_t, score_func , latent_mask, step_size , current_times, sigma_x = self.add_none_dims(self.sigma_x(abt)), sigma_y = self.add_none_dims(self.sigma_y(abt)), args = args)
if stopper is not None:
ctx = {
"step": i,
"steps_done": i + 1,
"n_steps": n_steps,
"mask": latent_mask,
"latent_image": self.latent_image,
"current_times": current_times,
"seed": seed,
}
if stopper.step(
i=i,
n_steps=n_steps,
x_t_before=x_t_before,
x_t_after=x_t,
x_t_prev_for_custom=x_t_prev,
prev_args=prev_args,
args=args,
ctx=ctx,
):
break
if IS_FLUX or IS_FLOW:
x = x_t / ( self.add_none_dims(abt)**0.5 + (1-self.add_none_dims(abt))**0.5 )
else:
x = x_t * ( 1+self.add_none_dims(VE_Sigma)**2 )**0.5 # switch to variance perserving x_t values
############ LanPaint Iterations End ###############
# out is x_0
out, _ = self.inner_model(x, sigma, model_options=model_options, seed=seed)
out, _ = self.unpack_model_output(
self.inner_model(x, sigma, model_options=model_options, seed=seed)
)
out = out * (1-latent_mask) + self.latent_image * latent_mask
input_x.copy_(x)
return out
def score_model(self, x_t, y, mask, abt, sigma, tflow, model_options, seed):
@@ -69,13 +155,26 @@ class LanPaint():
if self.IS_FLUX or self.IS_FLOW:
# compute t for flow model, with a small epsilon compensating for numerical error.
x = x_t / ( abt**0.5 + (1-abt)**0.5 ) # switch to Gaussian flow matching
x_0, x_0_BIG = self.inner_model(x, self.remove_none_dims(tflow), model_options=model_options, seed=seed)
x_0, x_0_BIG = self.unpack_model_output(
self.inner_model(x, self.remove_none_dims(tflow), model_options=model_options, seed=seed)
)
else:
x = x_t * ( 1+sigma**2 )**0.5 # switch to variance exploding
x_0, x_0_BIG = self.inner_model(x, self.remove_none_dims(sigma), model_options=model_options, seed=seed)
x_0, x_0_BIG = self.unpack_model_output(
self.inner_model(x, self.remove_none_dims(sigma), model_options=model_options, seed=seed)
)
if getattr(self, "audio_correction", None) is not None:
# The flat-grid model output for the audio rows is the slope-scaled
# velocity estimate, which overshoots the true denoised audio by
# sigma_v*slope/sigma_a. Pull the Langevin target back to the true
# audio denoised: x0_true = x + c*(x0_flat - x), c = 1 on video rows
# (video is untouched).
x_0 = x + self.audio_correction * (x_0 - x)
x_0_BIG = x + self.audio_correction * (x_0_BIG - x)
score_x = -(x_t - x_0)
score_y = - (1 + lamb) * ( x_t - y ) + lamb * (x_t - x_0_BIG)
score_y = - (1 + lamb) * ( x_t - y ) + lamb * (x_t - x_0_BIG)
return score_x * (1 - mask) + score_y * mask
def sigma_x(self, abt):
# the time scale for the x_t update
@@ -85,6 +184,13 @@ class LanPaint():
return beta
def langevin_dynamics(self, x_t, score, mask, step_size, current_times, sigma_x=1, sigma_y=0, args=None):
if args is not None and not isinstance(args, LangevinState):
if isinstance(args, tuple):
if len(args) == 2:
# Backwards compat: older state was (v, C) without x0.
args = LangevinState(args[0], args[1], None)
elif len(args) >= 3:
args = LangevinState(args[0], args[1], args[2])
# prepare the step size and time parameters
with torch.autocast(device_type=x_t.device.type, dtype=torch.float32):
step_sizes = self.prepare_step_size(current_times, step_size, sigma_x, sigma_y)
@@ -100,21 +206,22 @@ class LanPaint():
A = A_x * (1-mask) + A_y * mask
D = D_x * (1-mask) + D_y * mask
dt = dtx * (1-mask) + dty * mask
Gamma = Gamma_x * (1-mask) + Gamma_y * mask
# Gamma = Gamma_x * (1-mask) + Gamma_y * mask # only used by the disabled second-order scheme
def Coef_C(x_t):
x0 = self.x0_evalutation(x_t, score, sigma, args)
x0 = x_t + score(x_t)
C = (abt**0.5 * x0 - x_t )/ (1-abt) + A * x_t
return C
def advance_time(x_t, v, dt, Gamma, A, C, D):
dtype = x_t.dtype
with torch.autocast(device_type=x_t.device.type, dtype=torch.float32):
osc = StochasticHarmonicOscillator(Gamma, A, C, D )
x_t, v = osc.dynamics(x_t, v, dt )
x_t = x_t.to(dtype)
v = v.to(dtype)
return x_t, v
return C, x0
# Second-order damped-oscillator update (position + velocity via
# StochasticHarmonicOscillator) -- kept for reference, not used.
# def advance_time(x_t, v, dt, Gamma, A, C, D):
# dtype = x_t.dtype
# with torch.autocast(device_type=x_t.device.type, dtype=torch.float32):
# osc = StochasticHarmonicOscillator(Gamma, A, C, D )
# x_t, v = osc.dynamics(x_t, v, dt )
# x_t = x_t.to(dtype)
# v = v.to(dtype)
# return x_t, v
def advance_time_overdamped(x_t, dt, A, C, D):
"""
@@ -140,47 +247,44 @@ class LanPaint():
x_t = mean + noise
return x_t.to(dtype)
def run_damped(x_t, args):
if args is None:
v = None
C = Coef_C(x_t)
x_t, v = advance_time(x_t, v, dt, Gamma, A, C, D)
else:
v, C = args
x_t, v = advance_time(x_t, v, dt/2, Gamma, A, C, D)
C_new = Coef_C(x_t)
v = v + Gamma**0.5 * ( C_new - C) *dt
x_t, v = advance_time(x_t, v, dt/2, Gamma, A, C, D)
C = C_new
return x_t, (v, C)
# Second-order damped-oscillator scheme (position + velocity) -- kept
# for reference, not used.
# def run_damped(x_t, args):
# if args is None:
# v = None
# C, x0 = Coef_C(x_t)
# x_t, v = advance_time(x_t, v, dt, Gamma, A, C, D)
# else:
# v = args.v
# C = args.C
# x_t, v = advance_time(x_t, v, dt/2, Gamma, A, C, D)
# C_new, x0 = Coef_C(x_t)
# v = v + Gamma**0.5 * ( C_new - C) *dt
# x_t, v = advance_time(x_t, v, dt/2, Gamma, A, C, D)
# C = C_new
# # args is (v, C, x0) for the next inner step.
# return x_t, LangevinState(v, C, x0)
def run_overdamped(x_t, args):
if args is None:
C = Coef_C(x_t)
C, x0 = Coef_C(x_t)
x_t = advance_time_overdamped(x_t, dt, A, C, D)
else:
_, C = args
C = args.C
x_t = advance_time_overdamped(x_t, dt / 2, A, C, D)
C_new = Coef_C(x_t)
C_new, x0 = Coef_C(x_t)
x_t = x_t + (C_new - C) * dt
x_t = advance_time_overdamped(x_t, dt / 2, A, C, D)
C = C_new
return x_t, (None, C)
# args is (v, C, x0); v is None in the overdamped fallback.
return x_t, LangevinState(None, C, x0)
try:
x_t_next, (v_next, C_next) = run_damped(x_t, args)
# Only the first-order (overdamped) scheme is used; the second-order
# damped-oscillator scheme is kept commented out above.
x_t, state = run_overdamped(x_t, args)
if torch.isnan(x_t_next).any() or torch.isnan(v_next).any():
raise ValueError("NaN detected")
x_t = x_t_next
v = v_next
C = C_next
except Exception:
x_t, (v, C) = run_overdamped(x_t, args)
return x_t, (v, C)
# args is (v, C, x0); v is always None in the overdamped scheme.
return x_t, state
def prepare_step_size(self, current_times, step_size, sigma_x, sigma_y):
# -------------------------------------------------------------------------
@@ -216,9 +320,3 @@ class LanPaint():
D_x = (2 * abt**0 )**0.5
D_y = (2 * abt**0 )**0.5
return sigma, abt, dtx/2, dty/2, Gamma_x, Gamma_y, A_x, A_y, D_x, D_y
def x0_evalutation(self, x_t, score, sigma, args):
x0 = x_t + score(x_t)
return x0
+740 -58
View File
@@ -1,3 +1,5 @@
import json
import os
from contextlib import contextmanager
import math
# import nodes.py
@@ -10,7 +12,39 @@ from comfy.samplers import *
from comfy.model_base import ModelType
from .lanpaint import LanPaint
from comfy.model_base import WAN22
import comfyui_version
import comfyui_version
try:
import torchaudio
except ImportError: # torchaudio may be absent in CI/stub environments
torchaudio = None
try:
from comfy.ldm.minimax.model import time_shift_sigma, time_shift_slope
except Exception: # not present in stub/CI environments
time_shift_sigma = None
time_shift_slope = None
def _detect_minimax_h3_audio(model_patcher, model_options, latent_shapes):
"""Detect a MiniMax H3 AV pack: a nested (video, audio) latent whose audio
stream rides on a shifted sigma schedule. The diffusion model's own
sigma_shift_video/sigma_shift_audio attributes mark the H3 schedule
machinery; every other model keeps the plain video-schedule dynamics.
Returns (latent_shapes, shift_v, shift_a) or None."""
if latent_shapes is None or len(latent_shapes) < 2:
return None
model = getattr(model_patcher, "model", None)
diff_model = getattr(model, "diffusion_model", None)
shift_v = getattr(diff_model, "sigma_shift_video", None)
shift_a = getattr(diff_model, "sigma_shift_audio", None)
if shift_v is None or shift_a is None:
return None
# MiniMaxH3SigmaShift overrides live in transformer_options
topts = model_options.get("transformer_options", {}) if isinstance(model_options, dict) else {}
shift_v = topts.get("minimax_h3_sigma_shift_video", shift_v)
shift_a = topts.get("minimax_h3_sigma_shift_audio", shift_a)
return (latent_shapes, float(shift_v), float(shift_a))
def _version_tuple(value):
return tuple(int(part) if part.isdigit() else 0 for part in value.split("."))
@@ -19,14 +53,36 @@ COMFYUI_VERSION_060_OR_NEWER = _version_tuple(comfyui_version.__version__) >= (0
def reshape_mask(input_mask, output_shape,video_inpainting=False):
dims = len(output_shape) - 2
print('output shape',output_shape)
scale_mode = "nearest-exact"
print('input mask',input_mask.shape,type(input_mask),torch.max(input_mask),torch.min(input_mask))
print('target output_shape',output_shape)
print('input_mask.ndim:', input_mask.ndim, 'output_shape len:', len(output_shape))
# Handle input mask dimensions
if input_mask.ndim == 2:
if video_inpainting:
# video masks -> (1, 1, F, H, W): raw [F, H, W], or the [F, 1, H, W]
# shape SetLatentNoiseMask produces (frames at batch); a plain
# [H, W] image mask is broadcast over the whole video; 5D passes
if input_mask.ndim == 3:
input_mask = input_mask.unsqueeze(0).unsqueeze(1)
elif input_mask.ndim == 4:
input_mask = input_mask.permute(1, 0, 2, 3).unsqueeze(0)
elif input_mask.ndim == 2:
input_mask = input_mask.unsqueeze(0).unsqueeze(0).unsqueeze(0)
elif input_mask.ndim == 1 and len(output_shape) == 4:
# audio mask [F] at video frame rate -> the audio latent's token grid:
# nearest-exact along time, then expanded to the (ch, tokens) layout
f, t = input_mask.shape[0], output_shape[-1]
input_mask = torch.nn.functional.interpolate(
input_mask.float().unsqueeze(0).unsqueeze(0),
size=(t,),
mode="nearest-exact",
)
input_mask = input_mask.expand(1, 1, output_shape[-2], t)
elif input_mask.ndim == 4 and len(output_shape) == 4 and input_mask.shape[1] == 1 and input_mask.shape[3] == 1:
# audio mask [F, 1] that SetLatentNoiseMask reshaped to [1, 1, F, 1]:
# nearest F -> T, then expanded to (1, 1, ch, T)
f, t = input_mask.shape[2], output_shape[-1]
input_mask = torch.nn.functional.interpolate(input_mask, size=(t, 1), mode="nearest-exact")
input_mask = input_mask.permute(0, 1, 3, 2).expand(1, 1, output_shape[-2], t)
elif input_mask.ndim == 2:
input_mask = input_mask.unsqueeze(0).unsqueeze(0)
elif input_mask.ndim == 3:
input_mask = input_mask.unsqueeze(1)
@@ -36,34 +92,37 @@ def reshape_mask(input_mask, output_shape,video_inpainting=False):
if COMFYUI_VERSION_060_OR_NEWER:
input_mask = input_mask.unsqueeze(2) # (B, C, 1, H, W)
# Handle video case with temporal dimension
if video_inpainting: # Video case: (batch, channels, frames, height, width)
# Temporal union: a latent token covers ~4 video frames (the VAE's
# nominal temporal stride), and a token-level mask is all-or-nothing
# (binarized at 0.5 downstream). Averaging the frames into a token
# (trilinear) can erase sparse temporal strokes entirely; instead the
# token takes the UNION (max) of its ~4 frames - it regenerates iff
# any of them is painted. The last window is partial; short sequences
# (1-4 frames) collapse to a single window holding the max, which is
# exactly the union.
f = input_mask.shape[2]
n_win = (f + 3) // 4
pooled = torch.zeros(
(input_mask.shape[0], input_mask.shape[1], n_win, input_mask.shape[3], input_mask.shape[4]),
dtype=input_mask.dtype, device=input_mask.device)
for i in range(n_win):
pooled[:, :, i] = input_mask[:, :, i * 4 : (i + 1) * 4].amax(dim=2)
input_mask = pooled
target_frames = output_shape[2]
target_height, target_width = output_shape[-2:]
print('Video case - input_mask initial shape:', input_mask.shape)
# First reshape input_mask to have proper dimensions for video processing
# Assume input is (frames, channels, height, width) -> (1, channels, frames, height, width)
## if comfy version < 0.6.0
if not COMFYUI_VERSION_060_OR_NEWER:
input_mask = input_mask.permute(1, 0, 2, 3).unsqueeze(0)
print('Video case - input_mask after reshaping:', input_mask.shape)
# Ensure we have the correct 5D shape: (batch, channels, frames, height, width)
batch_size, channels, frames, height, width = input_mask.shape
print('Video case - dimensions: batch_size={}, channels={}, frames={}, height={}, width={}'.format(batch_size, channels, frames, height, width))
print('Video case - target size:', (target_frames, target_height, target_width))
# 3D nearest-exact interpolation: (batch, channels, frames, height, width) -> (batch, channels, target_frames, target_height, target_width)
temp_mask = torch.nn.functional.interpolate(
input_mask,
size=(target_frames, target_height, target_width),
mode=scale_mode,
input_mask,
size=(target_frames, target_height, target_width),
mode=scale_mode,
)
# temp_mask is already 5D: (batch, channels, target_frames, target_height, target_width)
mask = temp_mask
print('after mask',mask.shape)
# Handle channel dimension expansion if needed
if mask.shape[1] < output_shape[1]:
mask = mask.repeat(1, output_shape[1], 1, 1, 1)[:, :output_shape[1]]
@@ -109,9 +168,17 @@ class CFGGuider_LanPaint:
print("WAN22 detected")
self.inner_model.extra_conds = super(WAN22, self.inner_model).extra_conds
# MiniMax H3 AV packs carry an audio stream on a shifted sigma schedule;
# the paint loop needs the per-stream times only for that exact model
self.minimax_h3_audio = _detect_minimax_h3_audio(
self.model_patcher, self.model_options, kwargs.get("latent_shapes", None))
if denoise_mask is not None:
video_inpainting = self.model_options.get("video_inpainting", False)
denoise_mask = prepare_mask(denoise_mask, noise.shape, device, video_inpainting)
if tuple(denoise_mask.shape) != tuple(noise.shape):
# mask arrives already prepared to the latent shape (per-stream for
# nested AV latents, packed flat); only re-reshape when it differs
denoise_mask = prepare_mask(denoise_mask, noise.shape, device, video_inpainting)
noise = noise.to(device)
latent_image = latent_image.to(device)
@@ -138,6 +205,8 @@ class KSamplerX0Inpaint:
def __init__(self, model, sigmas):
self.inner_model = model
self.sigmas = sigmas
self.audio_indicator = None # [1, 1, N] flat pack: 1 on audio rows (MiniMax H3 only)
self.audio_shifts = None # (shift_video, shift_audio) for the audio sigma schedule
#self.model_sigmas = torch.cat( (torch.tensor([0.], device = sigmas.device) , torch.tensor( self.inner_model.model_patcher.get_model_object("model_sampling").sigmas, device = sigmas.device) ) )
#self.model_sigmas = torch.tensor( self.model_sigmas, dtype = self.sigmas.dtype )
def __call__(self, x, sigma, denoise_mask, model_options={}, seed=None,**kwargs):
@@ -161,10 +230,33 @@ class KSamplerX0Inpaint:
else:
VE_Sigma = sigma
VE_Sigma = sigma
abt = 1/( 1+VE_Sigma**2 )
Flow_t = (1-abt)**0.5 / ( (1-abt)**0.5 + abt**0.5 )
# MiniMax H3 audio stream runs on its own shifted sigma schedule:
# sigma_audio = time_shift_sigma(sigma_video, shift_video, shift_audio).
# The paint loop needs these per-stream times so the audio rows evolve
# at their own noise level instead of the video's.
current_times_audio = None
audio_correction = None
if self.audio_indicator is not None and self.audio_shifts is not None and time_shift_sigma is not None:
shift_v, shift_a = self.audio_shifts
Flow_a = time_shift_sigma(Flow_t, shift_v, shift_a)
abt_a = (1 - Flow_a)**2 / ((1 - Flow_a)**2 + Flow_a**2)
VE_a = Flow_a / (1 - Flow_a)
current_times_audio = (VE_a, abt_a, Flow_a)
# The flat-grid model output for the audio rows is the slope-scaled
# velocity estimate, which overshoots the true denoised audio. The
# Langevin target correction: x0_true = x + c*(x0_flat - x) with
# c = sigma_a / (sigma_v * slope_a) on audio rows, 1 on video rows.
ft = float(Flow_t)
c = 1.0
if ft > 1e-4 and time_shift_slope is not None:
slope_a = time_shift_slope(Flow_t, shift_v, shift_a)
c = float(Flow_a) / (ft * float(slope_a))
audio_correction = (1.0 - self.audio_indicator) + c * self.audio_indicator
if denoise_mask is not None:
if "denoise_mask_function" in model_options:
denoise_mask = model_options["denoise_mask_function"](sigma, denoise_mask, extra_options={"model": self.inner_model, "sigmas": self.sigmas})
@@ -178,9 +270,9 @@ class KSamplerX0Inpaint:
total_steps = len(self.sigmas)-1
if total_steps - current_step <= self.LanPaint_early_stop:
out = self.PaintMethod(x, self.latent_image, self.noise, sigma, latent_mask, current_times, model_options, seed, n_steps=0)
out = self.PaintMethod(x, self.latent_image, self.noise, sigma, latent_mask, current_times, model_options, seed, n_steps=0, current_times_audio=current_times_audio, audio_indicator=self.audio_indicator, audio_correction=audio_correction)
else:
out = self.PaintMethod(x, self.latent_image, self.noise, sigma, latent_mask, current_times, model_options, seed)
out = self.PaintMethod(x, self.latent_image, self.noise, sigma, latent_mask, current_times, model_options, seed, current_times_audio=current_times_audio, audio_indicator=self.audio_indicator, audio_correction=audio_correction)
else:
out, _ = self.inner_model(x, sigma, model_options=model_options, seed=seed)
@@ -220,14 +312,28 @@ class KSAMPLER(comfy.samplers.KSAMPLER):
model_wrap.cfg_BIG = model_wrap.model_patcher.LanPaint_cfg_BIG
noise = model_wrap.inner_model.model_sampling.noise_scaling(sigmas[0], noise, latent_image, self.max_denoise(model_wrap, sigmas))
model_k.PaintMethod = LanPaint(model_k.inner_model,
# MiniMax H3 AV pack: mark the audio rows of the flat pack so the paint
# loop can apply the audio's own shifted sigma schedule to them
audio_layout = getattr(model_wrap, "minimax_h3_audio", None)
if audio_layout is not None and time_shift_sigma is not None:
latent_shapes, shift_v, shift_a = audio_layout
video_n = math.prod(latent_shapes[0][1:])
audio_indicator = torch.zeros(noise.shape, dtype=torch.float32, device=noise.device)
audio_indicator[..., video_n:] = 1.0
model_k.audio_indicator = audio_indicator
model_k.audio_shifts = (shift_v, shift_a)
model_k.PaintMethod = LanPaint(model_k.inner_model,
model_wrap.model_patcher.LanPaint_NumSteps,
model_wrap.model_patcher.LanPaint_Friction,
model_wrap.model_patcher.LanPaint_Lambda,
model_wrap.model_patcher.LanPaint_Beta,
model_wrap.model_patcher.LanPaint_StepSize,
IS_FLUX = IS_FLUX,
IS_FLOW = IS_FLOW)
IS_FLOW = IS_FLOW,
EarlyStopThreshold = getattr(model_wrap.model_patcher, "LanPaint_InnerThreshold", 0.0),
EarlyStopPatience = getattr(model_wrap.model_patcher, "LanPaint_InnerPatience", 1),
EarlyStopHook = extra_args.get("model_options", {}).get("lanpaint_semantic_hook", None))
model_k.LanPaint_early_stop = model_wrap.model_patcher.LanPaint_EarlyStop
#if not inpainting, after noise_scaling, noise = noise * sigma, which is the noise added to the clean latent image in the variance exploding diffusion model notation.
#if inpainting, after noise_scaling, noise = latent_image + noise * sigma, which is x_t in the variance exploding diffusion model notation for the known region.
@@ -245,23 +351,47 @@ class KSAMPLER(comfy.samplers.KSAMPLER):
samples = model_wrap.inner_model.model_sampling.inverse_noise_scaling(sigmas[-1], samples)
return samples
_override_active = False
@contextmanager
def override_sample_function():
original_outer_sample = comfy.samplers.CFGGuider.outer_sample
comfy.samplers.CFGGuider.outer_sample = CFGGuider_LanPaint.outer_sample
original_predict_noise = comfy.samplers.CFGGuider.predict_noise
comfy.samplers.CFGGuider.predict_noise = CFGGuider_LanPaint.predict_noise
original_sample = comfy.samplers.KSAMPLER.sample
comfy.samplers.KSAMPLER.sample = KSAMPLER.sample
# guard against nested entry: a second entry must not capture the already
# patched functions as "originals" (that would restore the patches, not
# the real functions, on exit)
global _override_active
if _override_active:
yield
return
_override_active = True
try:
original_outer_sample = comfy.samplers.CFGGuider.outer_sample
comfy.samplers.CFGGuider.outer_sample = CFGGuider_LanPaint.outer_sample
original_predict_noise = comfy.samplers.CFGGuider.predict_noise
comfy.samplers.CFGGuider.predict_noise = CFGGuider_LanPaint.predict_noise
original_sample = comfy.samplers.KSAMPLER.sample
comfy.samplers.KSAMPLER.sample = KSAMPLER.sample
# Route the stock per-stream mask prep (inside CFGGuider.sample) through
# our prepare_mask so video masks get the temporal-union aggregation
# (window-4 max + nearest-exact) from reshape_mask. 5D targets (video)
# use the union path; everything else keeps the existing behavior.
original_prepare_mask = comfy.sampler_helpers.prepare_mask
def _prepare_mask_with_union(noise_mask, shape, device):
return prepare_mask(noise_mask, shape, device, video_inpainting=(len(shape) == 5))
comfy.sampler_helpers.prepare_mask = _prepare_mask_with_union
yield
finally:
comfy.sampler_helpers.prepare_mask = original_prepare_mask
comfy.samplers.KSAMPLER.sample = original_sample
comfy.samplers.CFGGuider.predict_noise = original_predict_noise
comfy.samplers.CFGGuider.outer_sample = original_outer_sample
_override_active = False
class LanPaint_UpSale_LatentNoiseMask:
@@ -309,7 +439,7 @@ class LanPaint_KSampler():
"denoise": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "The amount of denoising applied, lower values will maintain the structure of the initial image allowing for image to image sampling."}),
"LanPaint_NumSteps": ("INT", {"default": 5, "min": 0, "max": 100, "tooltip": "The number of steps for the Langevin dynamics, representing the turns of thinking per step."}),
"LanPaint_PromptMode": (["Image First", "Prompt First"], {"tooltip": "Image First: emphasis image quality, Prompt First: emphasis prompt following"}),
"LanPaint_Info": ("STRING", {"default": "LanPaint KSampler. For more info, visit https://github.com/scraed/LanPaint. If you find it useful, please give a star ⭐️!", "multiline": True}),
"LanPaint_Info": ("STRING", {"default": "LanPaint KSampler.", "tooltip": "For more info, visit https://github.com/scraed/LanPaint. If you find it useful, please give a star ⭐️!"}),
"Inpainting_mode": (["🖼️ Image Inpainting", "🎬 Video Inpainting"], {"default": "🖼️ Image Inpainting", "tooltip": "Choose Image mode for photos or Video mode for video frames with temporal consistency"}),
}
}
@@ -324,11 +454,13 @@ class LanPaint_KSampler():
def sample(self, model, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent_image, denoise=1.0, LanPaint_NumSteps=5, LanPaint_PromptMode="Image First", LanPaint_Info="",Inpainting_mode="🖼️ Image Inpainting"):
model.LanPaint_StepSize = 0.2
model.LanPaint_Lambda = 16.0
model.LanPaint_Lambda = 5.0
model.LanPaint_Beta = 1.
model.LanPaint_NumSteps = LanPaint_NumSteps
model.LanPaint_Friction = 15.
model.LanPaint_EarlyStop = 1
model.LanPaint_InnerThreshold = 0.0
model.LanPaint_InnerPatience = 1
if LanPaint_PromptMode == "Image First":
model.LanPaint_cfg_BIG = cfg
else:
@@ -360,14 +492,16 @@ class LanPaint_KSamplerAdvanced:
"end_at_step": ("INT", {"default": 10000, "min": 0, "max": 10000}),
"return_with_leftover_noise": (["disable", "enable"], ),
"LanPaint_NumSteps": ("INT", {"default": 5, "min": 0, "max": 100, "tooltip": "The number of steps for the Langevin dynamics, representing the turns of thinking per step."}),
"LanPaint_Lambda": ("FLOAT", {"default": 16., "min": 0.1, "max": 50.0, "step": 0.1, "round": 0.1, "tooltip": "The bidirectional guidance scale. Higher values align with known regions more closely, but may result in instability."}),
"LanPaint_Lambda": ("FLOAT", {"default": 5.0, "min": 0.1, "max": 50.0, "step": 0.1, "round": 0.1, "tooltip": "The bidirectional guidance scale. Higher values align with known regions more closely, but may result in instability."}),
"LanPaint_StepSize": ("FLOAT", {"default": 0.2, "min": 0.0001, "max": 1., "step": 0.01, "round": 0.001, "tooltip": "The step size for the Langevin dynamics. Higher values result in faster convergence but may be unstable."}),
"LanPaint_Beta": ("FLOAT", {"default": 1., "min": 0.0001, "max": 5, "step": 0.1, "round": 0.1, "tooltip": "The step size ratio between masked / unmasked regions. Lower value can compensate high values of LanPaint_Lambda."}),
"LanPaint_Friction": ("FLOAT", {"default": 15, "min": 0., "max": 50.0, "step": 0.1, "round": 0.1, "tooltip": "The friction parameter for fast langevin, lower values result in faster convergence but may be unstable."}),
"LanPaint_PromptMode": (["Image First", "Prompt First"], {"tooltip": "Image First: emphasis image quality, Prompt First: emphasis prompt following"}),
"LanPaint_EarlyStop": ("INT", {"default": 1, "min": 0, "max": 10000, "tooltip": "The number of steps to stop the LanPaint early, useful for preventing the image from irregular patterns."}),
"LanPaint_Info": ("STRING", {"default": "LanPaint KSampler Adv. For more info, visit https://github.com/scraed/LanPaint. If you find it useful, please give a star ⭐️!", "multiline": True}),
"LanPaint_Info": ("STRING", {"default": "LanPaint KSampler Adv.", "tooltip": "For more info, visit https://github.com/scraed/LanPaint. If you find it useful, please give a star ⭐️!"}),
"Inpainting_mode": (["🖼️ Image Inpainting", "🎬 Video Inpainting"], {"default": "🖼️ Image Inpainting", "tooltip": "Choose Image mode for photos or Video mode for video frames with temporal consistency"}),
"LanPaint_InnerThreshold": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.0001, "round": 0.0001, "tooltip": "Early stop threshold for Langevin iterations based on semantic distance. 0.0 to disable. (Contributed by godnight10061)"}),
"LanPaint_InnerPatience": ("INT", {"default": 1, "min": 1, "max": 100, "tooltip": "Number of consecutive steps below threshold required to stop. (Contributed by godnight10061)"}),
},
}
@@ -376,7 +510,7 @@ class LanPaint_KSamplerAdvanced:
CATEGORY = "sampling"
def sample(self, model, add_noise, noise_seed, steps, cfg, sampler_name, scheduler, positive, negative, latent_image, start_at_step, end_at_step, return_with_leftover_noise, LanPaint_NumSteps=5, LanPaint_Lambda=16.0, LanPaint_StepSize=0.2, LanPaint_Beta=1.0, LanPaint_Friction=15.0, LanPaint_PromptMode="Image First", LanPaint_EarlyStop=1, LanPaint_Info="", Inpainting_mode="🖼️ Image Inpainting"):
def sample(self, model, add_noise, noise_seed, steps, cfg, sampler_name, scheduler, positive, negative, latent_image, start_at_step, end_at_step, return_with_leftover_noise, LanPaint_NumSteps=5, LanPaint_Lambda=5.0, LanPaint_StepSize=0.2, LanPaint_Beta=1.0, LanPaint_Friction=15.0, LanPaint_PromptMode="Image First", LanPaint_EarlyStop=1, LanPaint_Info="", Inpainting_mode="🖼️ Image Inpainting", LanPaint_InnerThreshold=0.0, LanPaint_InnerPatience=1):
force_full_denoise = True
if return_with_leftover_noise == "enable":
force_full_denoise = False
@@ -389,6 +523,8 @@ class LanPaint_KSamplerAdvanced:
model.LanPaint_NumSteps = LanPaint_NumSteps
model.LanPaint_Friction = LanPaint_Friction
model.LanPaint_EarlyStop = LanPaint_EarlyStop
model.LanPaint_InnerThreshold = LanPaint_InnerThreshold
model.LanPaint_InnerPatience = LanPaint_InnerPatience
if LanPaint_PromptMode == "Image First":
model.LanPaint_cfg_BIG = cfg
else:
@@ -455,15 +591,7 @@ class MaskBlend:
"""
Creates a 2D Gaussian kernel with the given size and standard deviation (sigma).
"""
sigma = (kernel_size - 1)/4
# Create a grid of (x, y) coordinates
x = torch.arange(kernel_size).float() - kernel_size // 2
y = torch.arange(kernel_size).float() - kernel_size // 2
x_grid, y_grid = torch.meshgrid(x, y, indexing='ij')
# Compute the Gaussian function
kernel = torch.exp(-(x_grid ** 2 + y_grid ** 2) / (2 * sigma ** 2))
kernel = kernel / kernel.sum() # Normalize the kernel
return gaussian_kernel_2d(kernel_size)
return kernel
@@ -494,7 +622,7 @@ class LanPaint_SamplerCustom:
"latent_image": ("LATENT",),
"LanPaint_NumSteps": ("INT", {"default": 5, "min": 0, "max": 100, "tooltip": "Number of steps for Langevin dynamics, representing turns of thinking per step."}),
"LanPaint_PromptMode": (["Image First", "Prompt First"], {"tooltip": "Image First: prioritizes image quality; Prompt First: prioritizes prompt adherence."}),
"LanPaint_Info": ("STRING", {"default": "LanPaint Custom Sampler. For more info, visit https://github.com/scraed/LanPaint. If you find it useful, please give a star ⭐️!", "multiline": True}),
"LanPaint_Info": ("STRING", {"default": "LanPaint Custom Sampler.", "tooltip": "For more info, visit https://github.com/scraed/LanPaint. If you find it useful, please give a star ⭐️!"}),
}
}
@@ -505,11 +633,13 @@ class LanPaint_SamplerCustom:
def sample(self, model, sampler, sigmas, add_noise, noise_seed, cfg, positive, negative, latent_image, LanPaint_NumSteps, LanPaint_PromptMode, LanPaint_Info=""):
model.LanPaint_StepSize = 0.2
model.LanPaint_Lambda = 16.0
model.LanPaint_Lambda = 5.0
model.LanPaint_Beta = 1.
model.LanPaint_NumSteps = LanPaint_NumSteps
model.LanPaint_Friction = 15.
model.LanPaint_EarlyStop = 1
model.LanPaint_InnerThreshold = 0.0
model.LanPaint_InnerPatience = 1
if LanPaint_PromptMode == "Image First":
model.LanPaint_cfg_BIG = cfg
else:
@@ -554,13 +684,15 @@ class LanPaint_SamplerCustomAdvanced:
"sigmas": ("SIGMAS", ),
"latent_image": ("LATENT", ),
"LanPaint_NumSteps": ("INT", {"default": 5, "min": 0, "max": 100, "tooltip": "Number of steps for Langevin dynamics, representing turns of thinking per step."}),
"LanPaint_Lambda": ("FLOAT", {"default": 16.0, "min": 0.1, "max": 50.0, "step": 0.1, "tooltip": "Bidirectional guidance scale. Higher values align with known regions but may cause instability."}),
"LanPaint_Lambda": ("FLOAT", {"default": 5.0, "min": 0.1, "max": 50.0, "step": 0.1, "tooltip": "Bidirectional guidance scale. Higher values align with known regions but may cause instability."}),
"LanPaint_StepSize": ("FLOAT", {"default": 0.2, "min": 0.0001, "max": 1.0, "step": 0.01, "tooltip": "Step size for Langevin dynamics. Higher values speed convergence but may be unstable."}),
"LanPaint_Beta": ("FLOAT", {"default": 1.0, "min": 0.0001, "max": 5.0, "step": 0.1, "tooltip": "Step size ratio between masked/unmasked regions. Lower values balance high Lambda."}),
"LanPaint_Friction": ("FLOAT", {"default": 15.0, "min": 0.0, "max": 50.0, "step": 0.1, "tooltip": "Friction parameter for fast Langevin. Lower values speed convergence but may be unstable."}),
"LanPaint_PromptMode": (["Image First", "Prompt First"], {"tooltip": "Image First: prioritizes image quality; Prompt First: prioritizes prompt adherence."}),
"LanPaint_EarlyStop": ("INT", {"default": 1, "min": 0, "max": 10000, "tooltip": "Steps to stop LanPaint early, preventing irregular patterns."}),
"LanPaint_Info": ("STRING", {"default": "LanPaint Custom Sampler Adv. For more info, visit https://github.com/scraed/LanPaint. If you find it useful, please give a star ⭐️!", "multiline": True}),
"LanPaint_Info": ("STRING", {"default": "LanPaint Custom Sampler Adv.", "tooltip": "For more info, visit https://github.com/scraed/LanPaint. If you find it useful, please give a star ⭐️!"}),
"LanPaint_InnerThreshold": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.0001, "round": 0.0001, "tooltip": "Early stop threshold for Langevin iterations based on semantic distance. 0.0 to disable. (Contributed by godnight10061)"}),
"LanPaint_InnerPatience": ("INT", {"default": 1, "min": 1, "max": 100, "tooltip": "Number of consecutive steps below threshold required to stop. (Contributed by godnight10061)"}),
}
}
@@ -571,7 +703,7 @@ class LanPaint_SamplerCustomAdvanced:
CATEGORY = "sampling/custom_sampling"
def sample(self, noise, guider, sampler, sigmas, latent_image, LanPaint_NumSteps, LanPaint_Lambda, LanPaint_StepSize, LanPaint_Beta, LanPaint_Friction, LanPaint_PromptMode, LanPaint_EarlyStop, LanPaint_Info=""):
def sample(self, noise, guider, sampler, sigmas, latent_image, LanPaint_NumSteps, LanPaint_Lambda, LanPaint_StepSize, LanPaint_Beta, LanPaint_Friction, LanPaint_PromptMode, LanPaint_EarlyStop, LanPaint_Info="", LanPaint_InnerThreshold=0.0, LanPaint_InnerPatience=1):
model = guider.model_patcher
model.LanPaint_StepSize = LanPaint_StepSize
model.LanPaint_Lambda = LanPaint_Lambda
@@ -579,6 +711,8 @@ class LanPaint_SamplerCustomAdvanced:
model.LanPaint_NumSteps = LanPaint_NumSteps
model.LanPaint_Friction = LanPaint_Friction
model.LanPaint_EarlyStop = LanPaint_EarlyStop
model.LanPaint_InnerThreshold = LanPaint_InnerThreshold
model.LanPaint_InnerPatience = LanPaint_InnerPatience
if LanPaint_PromptMode == "Image First":
model.LanPaint_cfg_BIG = guider.cfg
else:
@@ -611,6 +745,540 @@ class LanPaint_SamplerCustomAdvanced:
return (out, out_denoised)
class LanPaint_MiniMaxAudioEncode:
"""MiniMax H3 audio encode.
The sd.VAE wrapper expects channels-last audio ([B, L, C]) and converts
internally, so the waveform is transposed exactly like ComfyUI's generic
VAEEncodeAudio. This node adds mono->stereo upmixing on top. Audio
inpainting masks are no longer handled here: the video mask editor
produces the audio mask, which the workflow attaches to this latent with
SetLatentNoiseMask.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"audio": ("AUDIO", {"tooltip": "The audio track to encode (resampled to the VAE's rate if needed)."}),
"vae": ("VAE", {"tooltip": "The MiniMax H3 audio VAE."}),
},
}
RETURN_TYPES = ("LATENT",)
RETURN_NAMES = ("latent",)
FUNCTION = "encode"
CATEGORY = "audio"
DESCRIPTION = "Encode audio with the MiniMax H3 audio VAE (correct layout). Audio inpainting masks come from the video mask editor via SetLatentNoiseMask."
def encode(self, audio, vae):
waveform = audio["waveform"] # [B, C, L]
sample_rate = audio["sample_rate"]
vae_sr = getattr(vae, "audio_sample_rate", 32000)
if vae_sr != sample_rate:
if torchaudio is None:
raise RuntimeError("torchaudio is required to resample audio for the MiniMax H3 audio VAE")
waveform = torchaudio.functional.resample(waveform, sample_rate, vae_sr)
if waveform.shape[1] == 1:
waveform = waveform.expand(-1, 2, -1) # mono -> stereo
# the sd.VAE wrapper takes channels-last audio and converts internally
z = vae.encode(waveform.movedim(1, -1)) # [B, 32, 2, T]
return ({"samples": z},)
class LanPaint_MiniMaxAudioDecode:
"""MiniMax H3 audio decode (wrapper returns channels-last [B, L, C]; this
node converts back to the ComfyUI [B, C, L] audio convention)."""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"samples": ("LATENT", {"tooltip": "Audio latent, or a nested AV latent (the audio stream is decoded)."}),
"vae": ("VAE", {"tooltip": "The MiniMax H3 audio VAE."}),
},
}
RETURN_TYPES = ("AUDIO",)
RETURN_NAMES = ("audio",)
FUNCTION = "decode"
CATEGORY = "audio"
DESCRIPTION = "Decode a MiniMax H3 audio latent to a waveform."
def decode(self, samples, vae):
z = samples["samples"]
if getattr(z, "is_nested", False):
z = z.unbind()[-1] # nested AV latent: take the audio stream
waveform = vae.decode(z).movedim(-1, 1) # wrapper [B, L, C] -> ComfyUI [B, C, L]
sample_rate = getattr(vae, "audio_sample_rate_output", getattr(vae, "audio_sample_rate", 32000))
return ({"waveform": waveform, "sample_rate": sample_rate},)
try:
from comfy_api.latest._input_impl.video_types import VideoFromFile, VideoFromComponents
from comfy_api.latest._util.video_types import VideoComponents
except Exception: # comfy_api unavailable (stub/CI env): video output disabled
VideoFromFile = None
VideoFromComponents = None
VideoComponents = None
class LanPaint_VideoMaskEditor:
"""Paint per-frame video masks and audio intervals on a video.
Works like a simple LoadVideo node: it returns the selected video file as
a VIDEO reference (no decoding) plus two masks. The video mask is a
per-frame [F, H, W] tensor painted in the mask editor: the frontend
("Edit Video Mask" button) streams the same file and lets you paint masks
on keyframes; frames between keyframes get the SDF-morphed mask. Painted
keyframes are uploaded to the input folder as PNGs and their filenames are
recorded in the hidden ``keyframes`` widget as {"<frame_idx>": "<file>.png"}.
The audio mask is a [F] hard 0/1 tensor at video frame rate (1 = regenerate
that moment of the audio, 0 = keep), built from time intervals in seconds
recorded in the hidden ``audio_mask`` widget as [{"start": s, "end": e}].
The same editor displays the audio waveform and paints intervals; the
workflow attaches the mask to the audio latent (SetLatentNoiseMask) and the
sampler resamples it to the audio latent tokens.
Video mask convention: 1 = regenerate, 0 = keep. Both masks span the
video's full frame count (read from the file container, no pixel decoding).
"""
@classmethod
def INPUT_TYPES(s):
try:
import folder_paths
import os as _os
input_dir = folder_paths.get_input_directory()
files = sorted(
f
for f in _os.listdir(input_dir)
if _os.path.isfile(_os.path.join(input_dir, f))
and f.lower().endswith(
(".mp4", ".webm", ".mov", ".mkv", ".avi", ".m4v", ".gif")
)
)
except Exception: # folder_paths unavailable (stub env): no options yet
files = []
return {
"required": {
"video": (files, {"video_upload": True, "tooltip": "Source video file. Returned as the video output and used by the mask editor preview."}),
"keyframes": ("STRING", {"default": "{}", "multiline": True, "tooltip": "Hidden: keyframe mask files {\"frame\": \"file.png\"}. Written by the mask editor."}),
"audio_mask": ("STRING", {"default": "[]", "multiline": True, "tooltip": "Hidden: audio inpainting intervals [{\"start\": s, \"end\": e}] in seconds. Written by the mask editor."}),
},
}
RETURN_TYPES = ("VIDEO", "MASK", "MASK")
RETURN_NAMES = ("video", "mask", "audio_mask")
FUNCTION = "run"
CATEGORY = "video"
DESCRIPTION = "Loads a video (like LoadVideo) and also outputs a per-frame video inpainting mask and a per-frame audio mask painted in the mask editor (1 = regenerate, 0 = keep)."
def run(self, video=None, keyframes="{}", audio_mask="[]"):
import folder_paths
from .videomask import interpolate_masks, load_keyframe_png, parse_keyframes_widget, resize_masks
if not video:
raise ValueError("select a video file in the node first")
if VideoFromFile is None:
raise RuntimeError("the video output requires the ComfyUI runtime (comfy_api)")
path = folder_paths.get_annotated_filepath(video)
vf = VideoFromFile(path)
count = int(vf.get_frame_count())
w, h = vf.get_dimensions() # (width, height)
data = parse_keyframes_widget(keyframes)
loaded = {}
for idx, filename in data.items():
try:
path = folder_paths.get_annotated_filepath(filename)
if path and os.path.isfile(path):
loaded[idx] = load_keyframe_png(path)
except Exception:
continue
if loaded:
seq = interpolate_masks(loaded, count)
seq = resize_masks(seq, (w, h))
out = torch.from_numpy(seq).float()
else:
# no keyframes (or all files missing): the mask stays empty
out = torch.zeros(count, h, w, dtype=torch.float32)
# audio mask: [F] hard 0/1 at video frame rate from intervals in seconds
# (get_fps was renamed get_frame_rate, returning a Fraction, in newer ComfyUI)
get_fps = getattr(vf, "get_fps", None)
fps = float(get_fps() if get_fps else vf.get_frame_rate())
audio_out = torch.zeros(count, dtype=torch.float32)
try:
intervals = json.loads(audio_mask) if isinstance(audio_mask, str) else audio_mask
except Exception:
intervals = []
if isinstance(intervals, list):
for it in intervals:
try:
start = float(it.get("start", 0.0))
end = float(it.get("end", 0.0))
except Exception:
continue
if end > start:
f0 = max(0, int(math.floor(start * fps)))
f1 = min(count, int(math.ceil(end * fps)))
if f1 > f0:
audio_out[f0:f1] = 1.0
return (vf, out, audio_out)
class LanPaint_AVEncode:
"""Encode a video's frames and audio into a nested AV latent, attaching
the video and audio masks for inpainting.
Replaces the workflow chain GetVideoComponents -> VAEEncode ->
SetLatentNoiseMask + MiniMaxAudioEncode -> SetLatentNoiseMask ->
Concat AV Latent with a single node. The video file's frames and audio
track are decoded here (via the VIDEO reference), encoded with the two
VAEs, and the masks ride per-stream inside the returned nested latent,
so the sampler can inpaint video and audio from one mask source.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"video": ("VIDEO", {"tooltip": "The video (from the mask editor or LoadVideo). Frames and audio track are decoded and encoded."}),
"vae": ("VAE", {"tooltip": "The video VAE."}),
"audio_vae": ("VAE", {"tooltip": "The audio VAE (e.g. the MiniMax H3 audio VAE)."}),
"mask": ("MASK", {"tooltip": "Per-frame video mask [F, H, W] (1 = regenerate, 0 = keep)."}),
"audio_mask": ("MASK", {"tooltip": "Audio mask [F] or [F, 1] at video frame rate (1 = regenerate that moment of the audio, 0 = keep)."}),
},
}
RETURN_TYPES = ("LATENT",)
RETURN_NAMES = ("latent",)
FUNCTION = "encode"
CATEGORY = "video"
DESCRIPTION = "Encode a video's frames and audio into a nested AV latent with per-stream video and audio masks (1 = regenerate, 0 = keep)."
def encode(self, video, vae, audio_vae, mask, audio_mask):
components = video.get_components()
frames = components.images # [B, H, W, C] in 0..1
z_video = vae.encode(frames[:, :, :, :3])
audio = components.audio
if audio is None:
raise ValueError("the video has no audio track to encode")
z_audio = LanPaint_MiniMaxAudioEncode().encode(audio, audio_vae)[0]["samples"]
# audio mask: accept [F] or [F, 1]; the per-stream prep resamples it
# to the audio latent tokens
if audio_mask.ndim == 2 and audio_mask.shape[1] == 1:
audio_mask = audio_mask[:, 0]
return ({
"samples": comfy.nested_tensor.NestedTensor((z_video, z_audio)),
"noise_mask": comfy.nested_tensor.NestedTensor((mask, audio_mask)),
},)
def gaussian_kernel_2d(kernel_size):
"""2D Gaussian kernel, sigma = (size - 1) / 4 (same as MaskBlend)."""
if kernel_size <= 1:
return torch.ones(1, 1) # no blending: identity kernel
sigma = (kernel_size - 1) / 4
x = torch.arange(kernel_size).float() - kernel_size // 2
x_grid, y_grid = torch.meshgrid(x, x, indexing="ij")
kernel = torch.exp(-(x_grid**2 + y_grid**2) / (2 * sigma**2))
return kernel / kernel.sum()
def merge_video_with_mask(orig, inpainted, mask, blend_overlap):
"""Blend the inpainted frames into the original with a MaskBlend-style
boundary: dilate the mask (max_pool), smooth it (Gaussian), then
``orig * (1 - m) + inpainted * m``."""
m = mask.float()
if m.ndim == 4: # [F, 1, H, W] from SetLatentNoiseMask
m = m[:, 0]
elif m.ndim == 2: # single image mask [H, W]
m = m.unsqueeze(0)
count = min(orig.shape[0], inpainted.shape[0])
orig = orig[:count]
inpainted = inpainted[:count]
if m.shape[0] == 1: # one mask frame covers every frame (image case)
m = m[:1].expand(count, -1, -1)
elif m.shape[0] < count:
raise ValueError("the mask has fewer frames than the image")
else:
m = m[:count]
if tuple(m.shape[1:]) != tuple(orig.shape[1:3]):
m = torch.nn.functional.interpolate(
m.unsqueeze(1), size=orig.shape[1:3], mode="nearest-exact"
)[:, 0]
m = m.unsqueeze(1)
m = torch.nn.functional.max_pool2d(
m, kernel_size=blend_overlap, stride=1, padding=blend_overlap // 2
)
kernel = gaussian_kernel_2d(blend_overlap).to(orig.device)[None, None]
m = torch.nn.functional.conv2d(m, kernel, padding=blend_overlap // 2)[:, 0]
return orig * (1 - m[..., None]) + inpainted * m[..., None]
def merge_audio_with_mask(orig, inpainted, mask, crossfade, orig_sr, result_sr):
"""Blend the inpainted audio into the original inside the mask intervals.
``orig``/``inpainted`` are [B, C, L] waveforms (resampled to the original
rate first); the per-frame mask is resampled to samples and a linear ramp
of ``crossfade`` seconds is applied at each interval edge (0 = hard cut)."""
if result_sr != orig_sr:
if torchaudio is None:
raise RuntimeError("torchaudio is required to resample the inpainted audio")
inpainted = torchaudio.functional.resample(inpainted, result_sr, orig_sr)
if inpainted.shape[-1] != orig.shape[-1]:
n = min(inpainted.shape[-1], orig.shape[-1])
orig = orig[..., :n]
inpainted = inpainted[..., :n]
am = mask.float()
if am.ndim == 4: # [1, 1, F, 1] from SetLatentNoiseMask
am = am[0, 0]
elif am.ndim == 2 and am.shape[1] == 1:
am = am[:, 0]
n = orig.shape[-1]
if am.shape[0] == n:
w = am
else:
w = torch.nn.functional.interpolate(
am.float().unsqueeze(0).unsqueeze(0), size=(n,), mode="nearest-exact"
)[0, 0]
if crossfade > 0 and orig_sr > 0:
cf = max(1, int(round(crossfade * orig_sr)))
kernel = torch.ones(1, 1, cf, device=orig.device) / cf
# replicate padding: the box ramp applies only at true 0<->1
# transitions, not at the mask's data ends (zero padding would bleed
# a partial blend into the fully-masked tail). cf-1 total padding keeps
# the output length exactly n (even kernels included).
w_pad = torch.nn.functional.pad(
w[None, None], (cf // 2, cf - 1 - cf // 2), mode="replicate"
)
w = torch.nn.functional.conv1d(w_pad, kernel)[0, 0][..., :n]
if orig.shape[1] != inpainted.shape[1]: # match channel counts before blending
if orig.shape[1] == 1:
orig = orig.expand(-1, inpainted.shape[1], -1) # mono source: upmix
elif inpainted.shape[1] == 1:
inpainted = inpainted.expand(-1, orig.shape[1], -1)
else:
# surround original vs stereo inpainted: keep the original's first
# channels (a downmix rather than a crash)
orig = orig[:, : inpainted.shape[1]]
return orig * (1 - w[None, None]) + inpainted * w[None, None]
class LanPaint_AVDecode:
"""Decode a nested AV latent and merge the inpainted video and audio with
the original video, keeping the source's fps and bit depth.
The result frames replace the original only inside the video mask
(boundary blended like LanPaint_MaskBlend, ``blend_overlap`` pixels);
the result audio replaces the original only inside the audio mask
intervals (with a short crossfade at the edges). The decoded frames are
resized to match the original dimensions exactly (VAE decodes can round
the size). The output video carries the original file's frame rate and
bit depth. A source without an audio track falls back to the inpainted
audio.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"samples": ("LATENT", {"tooltip": "The nested AV latent to decode (from the sampler)."}),
"video": ("VIDEO", {"tooltip": "The original video (from the mask editor): source of the kept content, fps and bit depth."}),
"vae": ("VAE", {"tooltip": "The video VAE."}),
"audio_vae": ("VAE", {"tooltip": "The audio VAE (e.g. the MiniMax H3 audio VAE)."}),
"mask": ("MASK", {"tooltip": "Per-frame video mask [F, H, W] (1 = regenerate, 0 = keep)."}),
"audio_mask": ("MASK", {"tooltip": "Audio mask [F] or [F, 1] at video frame rate (1 = regenerate that moment of the audio, 0 = keep)."}),
"blend_overlap": ("INT", {"default": 11, "min": 1, "max": 51, "step": 2, "tooltip": "Boundary blend width in pixels between the inpainted and original video (MaskBlend-style)."}),
"audio_crossfade": ("FLOAT", {"default": 0.02, "min": 0.0, "max": 1.0, "step": 0.005, "tooltip": "Crossfade in seconds at audio interval edges (0 = hard cut)."}),
},
}
RETURN_TYPES = ("VIDEO", "AUDIO")
RETURN_NAMES = ("video", "audio")
FUNCTION = "decode"
CATEGORY = "video"
DESCRIPTION = "Decode the nested AV latent, merge the inpainted video and audio with the original (mask-blended), and write the result with the original fps and bit depth."
def decode(self, samples, video, vae, audio_vae, mask, audio_mask, blend_overlap, audio_crossfade):
if VideoFromComponents is None or VideoComponents is None:
raise RuntimeError("the video output requires the ComfyUI runtime (comfy_api)")
components = video.get_components()
orig_frames = components.images # [F, H, W, C] in 0..1
orig_audio = components.audio # {"waveform": [B, C, L], "sample_rate"} or None
z_video, z_audio = samples["samples"].unbind()
result_frames = vae.decode(z_video) # [F, H, W, C] in 0..1
if len(result_frames.shape) == 5: # [1, F, H, W, C]: combine batches
result_frames = result_frames.reshape(
-1, result_frames.shape[-3], result_frames.shape[-2], result_frames.shape[-1]
)
result_audio = LanPaint_MiniMaxAudioDecode().decode(
{"samples": z_audio}, audio_vae
)[0] # {"waveform": [B, C, L], "sample_rate": vae rate}
# VAE decodes can round the size: match the original dimensions exactly
target_h, target_w = orig_frames.shape[1], orig_frames.shape[2]
if tuple(result_frames.shape[1:3]) != (target_h, target_w):
result_frames = torch.nn.functional.interpolate(
result_frames.movedim(-1, 1),
size=(target_h, target_w),
mode="bilinear",
align_corners=False,
).movedim(1, -1)
merged_frames = merge_video_with_mask(orig_frames, result_frames, mask, blend_overlap)
merged_audio = result_audio
if orig_audio is not None:
merged_audio = merge_audio_with_mask(
orig_audio["waveform"].float(),
result_audio["waveform"].float(),
audio_mask,
audio_crossfade,
orig_audio["sample_rate"],
result_audio["sample_rate"],
)
merged_audio = {"waveform": merged_audio, "sample_rate": orig_audio["sample_rate"]}
return (
VideoFromComponents(
VideoComponents(
images=merged_frames,
audio=merged_audio,
frame_rate=video.get_frame_rate(),
),
bit_depth=video.get_bit_depth(),
),
merged_audio,
)
class LanPaint_ImageEncode:
"""Encode an image and attach the inpainting mask to the latent.
Replaces the chain VAEEncode -> SetLatentNoiseMask (plus the manual
ImageScale of the mask) with one node. The mask is snapped to the latent's
actual spatial size (nearest-exact, the LanPaint mask convention), so
whatever the VAE's padding does, the mask always matches. Works with
image VAEs (4D latent) and video VAEs encoding a single image (5D latent
[B, C, T, H, W], e.g. the Hunyuan video VAE): the mask is attached as
[1, 1, T, H, W] and the sampler's prep handles it. Without a mask this is
a plain encode.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"image": ("IMAGE", {"tooltip": "The image to encode (1 = regenerate region comes from the mask)."}),
"vae": ("VAE", {"tooltip": "The VAE."}),
},
"optional": {
"mask": ("MASK", {"tooltip": "Inpainting mask [H, W] (1 = regenerate, 0 = keep). Snapped to the latent size automatically."}),
},
}
RETURN_TYPES = ("LATENT",)
RETURN_NAMES = ("latent",)
FUNCTION = "encode"
CATEGORY = "image"
DESCRIPTION = "Encode an image and attach an inpainting mask to the latent (replaces VAEEncode + SetLatentNoiseMask)."
def encode(self, image, vae, mask=None):
z = vae.encode(image)
ndim = len(z.shape)
if ndim not in (4, 5):
raise ValueError(
f"LanPaint_ImageEncode expects a 4D or 5D latent, got {ndim}D"
)
latent = {"samples": z}
if mask is not None:
m = mask.float()
if m.ndim == 4: # [1, 1, H, W] from SetLatentNoiseMask
m = m[0, 0]
elif m.ndim == 3:
m = m[0]
if ndim == 4:
target = z.shape[-2:]
if tuple(m.shape) != target:
m = torch.nn.functional.interpolate(
m.unsqueeze(0).unsqueeze(0), size=target, mode="nearest-exact"
)[0, 0]
latent["noise_mask"] = m.unsqueeze(0).unsqueeze(0)
else: # 5D video-style VAE: snap to (T, H, W), one mask frame per token
t, h, w = z.shape[-3:]
if tuple(m.shape) != (h, w):
m = torch.nn.functional.interpolate(
m.unsqueeze(0).unsqueeze(0), size=(h, w), mode="nearest-exact"
)[0, 0]
latent["noise_mask"] = m.unsqueeze(0).unsqueeze(0).unsqueeze(2).expand(1, 1, t, h, w)
return (latent,)
class LanPaint_ImageDecode:
"""Decode an inpainted latent and merge it with the original image.
Replaces the chain VAEDecode -> LanPaint_MaskBlend (plus the manual
ImageScale alignment) with one node. The decoded image is resized to the
original's exact dimensions (VAE decodes can round the size), then the
inpainted pixels replace the original only inside the mask, with a
MaskBlend-style boundary (``blend_overlap`` pixels). Without an image this
is a plain decode.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"samples": ("LATENT", {"tooltip": "The inpainted latent to decode."}),
"vae": ("VAE", {"tooltip": "The VAE."}),
},
"optional": {
"image": ("IMAGE", {"tooltip": "The original image. When given, the decoded output is resized to its exact dimensions."}),
"mask": ("MASK", {"tooltip": "The inpainting mask (1 = take the inpainted pixels, 0 = keep the original)."}),
"blend_overlap": ("INT", {"default": 9, "min": 1, "max": 51, "step": 2, "tooltip": "Boundary blend width in pixels between the inpainted and original image (MaskBlend-style)."}),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "decode"
CATEGORY = "image"
DESCRIPTION = "Decode an inpainted latent, resize to the original image's exact dimensions, and merge with the original inside the mask (replaces VAEDecode + MaskBlend)."
def decode(self, samples, vae, image=None, mask=None, blend_overlap=9):
z = samples["samples"]
img = vae.decode(z)
if len(img.shape) == 5: # [1, F, H, W, C]: combine batches (video-style VAE)
img = img.reshape(
-1, img.shape[-3], img.shape[-2], img.shape[-1]
)
if image is None:
return (img,)
target_h, target_w = image.shape[1], image.shape[2]
if tuple(img.shape[1:3]) != (target_h, target_w):
img = torch.nn.functional.interpolate(
img.movedim(-1, 1),
size=(target_h, target_w),
mode="bilinear",
align_corners=False,
).movedim(1, -1)
if mask is None:
return (img,)
return (merge_video_with_mask(image, img, mask, blend_overlap),)
# A dictionary that contains all nodes you want to export with their names
# NOTE: names should be globally unique
NODE_CLASS_MAPPINGS = {
@@ -619,6 +1287,13 @@ NODE_CLASS_MAPPINGS = {
"LanPaint_SamplerCustom" : LanPaint_SamplerCustom,
"LanPaint_SamplerCustomAdvanced" : LanPaint_SamplerCustomAdvanced,
"LanPaint_MaskBlend": MaskBlend,
"LanPaint_MiniMaxAudioEncode": LanPaint_MiniMaxAudioEncode,
"LanPaint_MiniMaxAudioDecode": LanPaint_MiniMaxAudioDecode,
"LanPaint_VideoMaskEditor": LanPaint_VideoMaskEditor,
"LanPaint_AVEncode": LanPaint_AVEncode,
"LanPaint_AVDecode": LanPaint_AVDecode,
"LanPaint_ImageEncode": LanPaint_ImageEncode,
"LanPaint_ImageDecode": LanPaint_ImageDecode,
# "LanPaint_UpSale_LatentNoiseMask": LanPaint_UpSale_LatentNoiseMask,
}
@@ -629,5 +1304,12 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"LanPaint_SamplerCustom" : "LanPaint Sampler Custom",
"LanPaint_SamplerCustomAdvanced" : "LanPaint Sampler Custom (Advanced)",
"LanPaint_MaskBlend": "LanPaint Mask Blend",
"LanPaint_MiniMaxAudioEncode": "LanPaint MiniMax Audio Encode",
"LanPaint_MiniMaxAudioDecode": "LanPaint MiniMax Audio Decode",
"LanPaint_VideoMaskEditor": "LanPaint Video Mask Editor",
"LanPaint_AVEncode": "LanPaint AV Encode",
"LanPaint_AVDecode": "LanPaint AV Decode",
"LanPaint_ImageEncode": "LanPaint Image Encode",
"LanPaint_ImageDecode": "LanPaint Image Decode",
# "LanPaint_UpSale_LatentNoiseMask": "LanPaint UpSale Latent Noise Mask"
}
+10
View File
@@ -0,0 +1,10 @@
from typing import NamedTuple, Optional
import torch
class LangevinState(NamedTuple):
v: Optional[torch.Tensor]
C: Optional[torch.Tensor]
x0: Optional[torch.Tensor]
+267
View File
@@ -0,0 +1,267 @@
"""Per-frame video mask interpolation for the LanPaint video mask editor.
Keyframes are grayscale PNGs painted in the frontend at a capped resolution.
This module loads them, morphs between them via signed-distance-field (SDF)
level-set interpolation (at keyframe resolution), and upscales once to the
frame size.
The interpolation contract is shared with the frontend preview
(web/lanpaint_video_mask_editor.js): what the user sees is exactly what
``interpolate_masks`` produces, pixel for pixel.
Each keyframe mask is binarized at 0.5 and turned into a signed distance
field (positive inside, negative outside, 0 on the boundary). For a frame t
between keyframes k_i and k_{i+1} with w = (t - k_i) / (k_{i+1} - k_i):
d(p) = (1 - w) * sdf_i(p) + w * sdf_{i+1}(p)
mask(p) = sigmoid(d(p) / softness) softness = 1.0 pixel
The zero level set of the blended field slides linearly between the two
shapes (translation, growth, shrink, merge/split), keeping edges sharp
instead of cross-fading them. Frames that are exact keyframes return the
original painted mask unchanged. Frames outside the keyframe window (before
the first or after the last keyframe) have NO mask (all zeros): the mask
exists only at keyframes and between them. Mask convention: 1 = regenerate,
0 = keep.
"""
import json
from typing import Dict, Tuple
import numpy as np
from PIL import Image
def load_keyframe_png(path: str, size: Tuple[int, int] | None = None) -> np.ndarray:
"""Load a mask PNG as float32 [H, W] in [0, 1].
PNGs saved by the mask editor carry the mask in the alpha channel; a plain
grayscale PNG (e.g. produced by other tools) is read via luminance.
``size`` is (width, height); the mask is resized with bilinear filtering
(soft mask edges stay soft).
"""
with Image.open(path) as im:
if "A" in im.getbands():
im = im.getchannel("A")
elif im.mode != "L":
im = im.convert("L")
if size is not None and im.size != tuple(size):
im = im.resize(tuple(size), Image.BILINEAR)
arr = np.asarray(im, dtype=np.float32) / 255.0
return arr
def resize_masks(masks: np.ndarray, size: Tuple[int, int]) -> np.ndarray:
"""Resize a [T, H, W] mask sequence to ``size`` = (width, height)."""
if tuple(masks.shape[1:][::-1]) == tuple(size):
return masks
frames = [Image.fromarray((m * 255).astype(np.uint8)) for m in masks]
frames = [f.resize(tuple(size), Image.BILINEAR) for f in frames]
out = np.stack([np.asarray(f, dtype=np.float32) / 255.0 for f in frames])
return out
#: sentinel for the EDT: larger than any possible squared distance
_EDT_LARGE = None # set per-call from the mask shape
#: edge softness of the morph sigmoid, in pixels
_MORPH_SOFTNESS = 1.0
try: # exact EDT at C speed; the pure-Python fallback is kept for CI/stubs
from scipy.ndimage import distance_transform_edt as _scipy_edt
except ImportError:
_scipy_edt = None
def _edt_1d_sq(f: np.ndarray) -> np.ndarray:
"""Exact 1D Euclidean distance transform (squared distances).
Felzenszwalb-Huttenlocher lower-envelope of parabolas; O(n). ``f`` holds
0.0 at foreground positions and a large sentinel elsewhere; the result is
the squared distance to the nearest foreground element.
"""
n = f.shape[0]
v = np.zeros(n, dtype=np.int64)
z = np.zeros(n + 1, dtype=np.float64)
z[0] = -np.inf
z[1] = np.inf
k = 0
for q in range(1, n):
q2 = float(q * q)
while True:
vk = v[k]
s = ((f[q] + q2) - (f[vk] + float(vk * vk))) / (2.0 * (q - vk))
if s > z[k]:
break
k -= 1
k += 1
v[k] = q
z[k] = s
z[k + 1] = np.inf
k = 0
d = np.empty(n, dtype=np.float64)
for q in range(n):
while z[k + 1] < q:
k += 1
vk = v[k]
d[q] = f[vk] + float(q - vk) * float(q - vk)
return d
def _edt_2d(mask_binary: np.ndarray) -> np.ndarray:
"""Exact 2D Euclidean distance transform of a bool mask (True = foreground).
Returns float64 [H, W] distances to the nearest foreground pixel. Uses
scipy's C implementation when available (same exact distances, ~100x
faster than the pure-Python Felzenszwalb-Huttenlocher fallback, which is
kept for environments without scipy).
"""
if _scipy_edt is not None:
# scipy measures distance to the nearest ZERO of the input, so the
# mask is inverted to get the distance to the nearest foreground
return _scipy_edt(~mask_binary)
h, w = mask_binary.shape
large = float(h * h + w * w) + 1.0 # > any possible squared distance
f = np.where(mask_binary, 0.0, large).astype(np.float64)
for y in range(h):
f[y, :] = _edt_1d_sq(f[y, :])
for x in range(w):
f[:, x] = np.sqrt(np.maximum(_edt_1d_sq(f[:, x]), 0.0))
return f
def _signed_distance(mask_binary: np.ndarray) -> np.ndarray:
"""Signed distance field of a bool mask; positive inside, negative outside.
Empty masks get a uniform -max(H, W)/2 field (so a shape morphs *in* from
nothing); full masks get +max(H, W)/2 (so a shape morphs *out* to fill
the frame).
"""
h, w = mask_binary.shape
inside = mask_binary.all()
outside = not mask_binary.any()
if outside:
return np.full((h, w), -max(h, w) / 2.0, dtype=np.float64)
if inside:
return np.full((h, w), max(h, w) / 2.0, dtype=np.float64)
d_fg = _edt_2d(mask_binary) # distance to foreground (0 inside, >0 outside)
d_bg = _edt_2d(~mask_binary) # distance to background (0 outside, >0 inside)
return d_bg - d_fg
def _sigmoid_stable(x: np.ndarray, softness: float) -> np.ndarray:
"""Sigmoid with clipped input so exp cannot overflow."""
return 1.0 / (1.0 + np.exp(-np.clip(x / softness, -50.0, 50.0)))
def _shift(field: np.ndarray, dy: int, dx: int) -> np.ndarray:
"""Shift a field by whole pixels; vacated pixels become 0."""
h, w = field.shape
out = np.zeros_like(field)
src_y0, src_y1 = max(0, -dy), min(h, h - dy)
dst_y0, dst_y1 = src_y0 + dy, src_y1 + dy
src_x0, src_x1 = max(0, -dx), min(w, w - dx)
dst_x0, dst_x1 = src_x0 + dx, src_x1 + dx
if src_y1 > src_y0 and src_x1 > src_x0:
out[dst_y0:dst_y1, dst_x0:dst_x1] = field[src_y0:src_y1, src_x0:src_x1]
return out
def _centroid(mask_binary: np.ndarray):
"""Centroid (y, x) of a bool mask, or None when it is empty."""
ys, xs = np.where(mask_binary)
if len(xs) == 0:
return None
return (float(ys.mean()), float(xs.mean()))
def interpolate_masks(
keyframes: Dict[int, np.ndarray], count: int
) -> np.ndarray:
"""Morph a {frame_idx: [H, W] mask} dict to ``count`` frames (SDF level-set
interpolation).
Returns float32 [count, H, W] in [0, 1]. All keyframe masks must share
one shape. ``count`` <= 0 raises ValueError. Exact keyframe frames return
the original painted mask; frames outside the keyframe window (before the
first / after the last keyframe) are all zeros (no mask).
"""
if count <= 0:
raise ValueError("count must be positive")
if not keyframes:
raise ValueError("at least one keyframe is required")
indices = sorted(keyframes)
keys = {
i: np.asarray(keyframes[i], dtype=np.float32) for i in indices
}
h, w = next(iter(keys.values())).shape
out = np.zeros((count, h, w), dtype=np.float32)
# keyframes at or beyond `count` are out of range; if none is in range,
# no frame is a keyframe or between keyframes -> the mask stays empty
in_range = [i for i in indices if i < count]
if not in_range:
return out
# exact keyframe frames keep the original painted masks
for i in in_range:
out[i] = keys[i]
if len(indices) == 1:
return out
sdfs = {i: _signed_distance(keys[i] >= 0.5) for i in indices}
centroids = {i: _centroid(keys[i] >= 0.5) for i in indices}
for lo, hi in zip(indices, indices[1:]):
n_inner = hi - lo - 1
if n_inner <= 0:
continue
# translation compensation: sample each field in the interpolated
# frame so a translating shape slides instead of collapsing (plain
# SDF blending vanishes when the translation exceeds the shape's
# inscribed radius). Skip when either shape is empty (growth/shrink
# morphs need no compensation).
c_lo, c_hi = centroids[lo], centroids[hi]
if c_lo is None or c_hi is None:
dx, dy = 0.0, 0.0
else:
dx, dy = c_hi[1] - c_lo[1], c_hi[0] - c_lo[0]
sdf_lo, sdf_hi = sdfs[lo], sdfs[hi]
h, w = sdf_lo.shape
out[lo + 1 : hi] = 0.0 # fill whole intermediate block per frame
for t in range(lo + 1, hi):
wf = (t - lo) / (hi - lo)
# d(p) = (1-w)*sdf_lo(p - w*D) + w*sdf_hi(p + (1-w)*D)
sx1 = int(np.floor(wf * dx + 0.5))
sy1 = int(np.floor(wf * dy + 0.5))
sx2 = int(np.floor((1.0 - wf) * dx + 0.5))
sy2 = int(np.floor((1.0 - wf) * dy + 0.5))
d = (1.0 - wf) * _shift(sdf_lo, sy1, sx1) + wf * _shift(sdf_hi, -sy2, -sx2)
out[t] = _sigmoid_stable(d, _MORPH_SOFTNESS).astype(np.float32)
return out
def parse_keyframes_widget(value: str) -> Dict[int, str]:
"""Parse the node's ``keyframes`` widget JSON into {frame_idx: filename}.
Malformed input yields an empty dict (treated as "no keyframes"). Keys are
coerced to ints.
"""
if not value:
return {}
try:
data = json.loads(value)
except (TypeError, ValueError):
return {}
if not isinstance(data, dict):
return {}
out = {}
for k, v in data.items():
try:
if isinstance(v, str):
out[int(k)] = v
except (TypeError, ValueError):
continue
return out
+291
View File
@@ -0,0 +1,291 @@
"""Read and write LanPaint mask metadata in MP4 files via PyAV.
The metadata tag ``lanpaint-mask`` holds a UTF-8 JSON payload:
{
"version": 1,
"video": "<source video filename>",
"fps": <number>,
"keyframes": {"<frame_idx>": "<base64 PNG (no data: prefix)>", ...},
"audio_intervals": [{"start": <float>, "end": <float>}, ...]
}
Keyframes are base64-encoded PNG masks (RGBA, alpha = mask).
"No mask" = tag absent (None); "empty mask" = tag present with ``{}`` keyframes.
PyAV is import-guarded: the module-level ``av`` is ``None`` when PyAV is
unavailable, and the read/write functions raise ``RuntimeError`` in that case.
"""
import json
import os
from typing import Any, Dict, Optional
try:
import av
_HAS_AV = True
except ImportError:
av = None # type: ignore[assignment]
_HAS_AV = False
# ---------------------------------------------------------------------------
# Payload helpers
# ---------------------------------------------------------------------------
_METADATA_KEY = "lanpaint-mask"
_PAYLOAD_VERSION = 1
def encode_payload(payload: dict) -> str:
"""Encode a payload dict as a compact JSON string (UTF-8)."""
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
def decode_payload(raw: Optional[str]) -> Optional[dict]:
"""Decode a metadata value to a payload dict, or None on any error.
``raw`` may be ``None`` (tag absent), a JSON string, or something
unexpected. Malformed / missing input returns ``None`` gracefully.
"""
if raw is None:
return None
if not isinstance(raw, str):
return None
try:
data = json.loads(raw)
except (TypeError, ValueError):
return None
if not isinstance(data, dict):
return None
return data
# ---------------------------------------------------------------------------
# Core read / write
# ---------------------------------------------------------------------------
def read_mask_metadata(path: str) -> Optional[dict]:
"""Read the ``lanpaint-mask`` metadata tag from an MP4 file.
Returns the decoded payload dict, or ``None`` when the tag is absent or
PyAV is unavailable.
"""
if not _HAS_AV:
raise RuntimeError(
"PyAV (av) is required to read mask metadata but is not installed"
)
with av.open(path, "r") as container:
raw = container.metadata.get(_METADATA_KEY, None)
return decode_payload(raw)
def write_mask_metadata(
input_path: str,
output_path: str,
payload: dict,
) -> None:
"""Remux *input_path* to *output_path*, attaching a ``lanpaint-mask``
metadata tag.
The video track is stream-copied (no re-encode) so the video content is
preserved byte-for-byte. Audio and other tracks are also preserved.
The source file at *input_path* is **never** modified.
Raises ``RuntimeError`` when PyAV is unavailable.
"""
if not _HAS_AV:
raise RuntimeError(
"PyAV (av) is required to write mask metadata but is not installed"
)
json_str = encode_payload(payload)
with av.open(input_path, "r") as in_container:
# ``movflags=use_metadata_tags`` is MANDATORY — without it ffmpeg
# silently drops custom metadata keys from the output.
with av.open(
output_path,
"w",
format="mp4",
options={"movflags": "use_metadata_tags"},
) as out_container:
# Copy any existing metadata (except our own key) from the source.
for key, value in in_container.metadata.items():
if key != _METADATA_KEY:
out_container.metadata[key] = value
# Write the LanPaint payload.
out_container.metadata[_METADATA_KEY] = json_str
# Stream-copy every stream from the source.
stream_map: Dict[int, Any] = {}
for in_stream in in_container.streams:
out_stream = out_container.add_stream_from_template(in_stream)
stream_map[in_stream.index] = out_stream
# Demux → mux every packet.
for packet in in_container.demux():
if packet.dts is None:
continue
out_stream = stream_map[packet.stream.index]
packet.stream = out_stream
out_container.mux(packet)
# The caller is responsible for the output file on disk.
# ---------------------------------------------------------------------------
# Helpers for server routes
# ---------------------------------------------------------------------------
def _unique_output_path(input_dir: str, base_name: str) -> str:
"""Return a non-clobbering output filename in *input_dir*.
Appends ``_masked``, then ``_masked_2``, ``_masked_3``, ... until a name
that does not already exist is found.
Returns the bare filename (not the full path).
"""
stem, ext = os.path.splitext(base_name)
candidate = f"{stem}_masked{ext}"
if not os.path.exists(os.path.join(input_dir, candidate)):
return candidate
n = 2
while True:
candidate = f"{stem}_masked_{n}{ext}"
if not os.path.exists(os.path.join(input_dir, candidate)):
return candidate
n += 1
def export_mask_video_from_request(
input_dir: str,
filename: str,
keyframes: dict,
audio_intervals: list,
fps: float,
) -> str:
"""Remux a source video with mask metadata, returning the new filename.
Parameters
----------
input_dir:
The ComfyUI input directory (``folder_paths.get_input_directory()``).
filename:
The source video filename (relative to *input_dir*).
keyframes:
Dict of ``{frame_idx: base64_png_string}``.
audio_intervals:
List of ``{"start": float, "end": float}`` dicts.
fps:
The video frame rate.
Returns
-------
The bare filename of the exported MP4 (in *input_dir*).
"""
payload: Dict[str, Any] = {
"version": _PAYLOAD_VERSION,
"video": filename,
"fps": fps,
"keyframes": keyframes,
"audio_intervals": audio_intervals,
}
src = os.path.join(input_dir, filename)
if not os.path.isfile(src):
raise FileNotFoundError(f"source video not found: {src}")
out_name = _unique_output_path(input_dir, filename)
out_path = os.path.join(input_dir, out_name)
write_mask_metadata(src, out_path, payload)
return out_name
def register_routes(server) -> None:
"""Register the LanPaint video-mask metadata routes on a ComfyUI
PromptServer instance.
Call this from ``__init__.py`` when running inside ComfyUI (the ``server``
module is only importable in that environment). Safe to call multiple
times — routes are registered once.
"""
import aiohttp
import folder_paths
@server.routes.get("/lanpaint/video_mask_meta")
async def video_mask_meta(request: aiohttp.web.Request) -> aiohttp.web.Response:
filename = request.query.get("filename", "")
if not filename:
return aiohttp.web.json_response({"found": False})
input_dir = folder_paths.get_input_directory()
path = os.path.join(input_dir, filename)
if not os.path.isfile(path):
return aiohttp.web.json_response({"found": False})
try:
payload = read_mask_metadata(path)
except Exception:
payload = None
if payload is None:
return aiohttp.web.json_response({"found": False})
return aiohttp.web.json_response({"found": True, "payload": payload})
@server.routes.post("/lanpaint/export_mask_video")
async def export_mask_video(request: aiohttp.web.Request) -> aiohttp.web.Response:
try:
body = await request.json()
except Exception:
return aiohttp.web.json_response(
{"error": "invalid JSON body"}, status=400
)
filename = body.get("filename", "")
if not filename:
return aiohttp.web.json_response(
{"error": "missing filename"}, status=400
)
keyframes = body.get("keyframes", {})
if not isinstance(keyframes, dict):
return aiohttp.web.json_response(
{"error": "keyframes must be a dict"}, status=400
)
audio_intervals = body.get("audio_intervals", [])
if not isinstance(audio_intervals, list):
return aiohttp.web.json_response(
{"error": "audio_intervals must be a list"}, status=400
)
fps = body.get("fps", 30.0)
try:
fps = float(fps)
except (TypeError, ValueError):
return aiohttp.web.json_response(
{"error": "fps must be a number"}, status=400
)
input_dir = folder_paths.get_input_directory()
try:
out_name = export_mask_video_from_request(
input_dir, filename, keyframes, audio_intervals, fps
)
except FileNotFoundError as e:
return aiohttp.web.json_response({"error": str(e)}, status=404)
except RuntimeError as e:
return aiohttp.web.json_response({"error": str(e)}, status=500)
except Exception as e:
return aiohttp.web.json_response(
{"error": f"export failed: {e}"}, status=500
)
return aiohttp.web.json_response(
{"filename": out_name, "path": os.path.join(input_dir, out_name)}
)
+41
View File
@@ -0,0 +1,41 @@
/**
* Runs the frontend mask-morph math (web/lanpaint_mask_math.js) on the same
* keyframes the Python backend uses and prints the per-frame masks, so a
* pytest can pin the two implementations together.
*
* Usage: node parity_mask_math.mjs <input.json>
* input: {w, h, count, keyframes: {"<idx>": [alpha 0-255, ...]}}
* output (stdout): {w, h, count, data: [float ...]} per-frame flat arrays
*/
import { readFileSync } from "node:fs";
const data = JSON.parse(readFileSync(process.argv[2], "utf8"));
// the web dir has no package.json, so the module is loaded as ESM via a data: URL
const src = readFileSync(new URL("../web/lanpaint_mask_math.js", import.meta.url), "utf8");
const math = await import("data:text/javascript;base64," + Buffer.from(src).toString("base64"));
const { computeSDFEntry, MaskMorph } = math;
const { w, h, count, keyframes } = data;
const morph = new MaskMorph(w, h);
const indices = Object.keys(keyframes).map(Number).sort((a, b) => a - b);
const out = new Float32Array(count * w * h);
const sdfs = new Map();
for (const idx of indices) {
const alpha = new Uint8ClampedArray(keyframes[idx]);
out.set(alpha.map((a) => a / 255), idx * w * h); // exact keyframes keep the paint
sdfs.set(idx, computeSDFEntry(alpha, w, h));
}
for (let p = 0; p < indices.length - 1; p++) {
const lo = indices[p];
const hi = indices[p + 1];
const sdfLo = sdfs.get(lo);
const sdfHi = sdfs.get(hi);
for (let t = lo + 1; t < hi; t++) {
const wf = (t - lo) / (hi - lo);
out.set(morph.frame(sdfLo, sdfHi, wf), t * w * h);
}
}
console.log(JSON.stringify({ w, h, count, data: Array.from(out) }));
+324
View File
@@ -0,0 +1,324 @@
import importlib
import sys
import types
import pytest
import torch
from src.LanPaint.lanpaint import LanPaint as LanPaintEngine
def _repeat_to_batch_size(tensor: torch.Tensor, batch_size: int) -> torch.Tensor:
if tensor.shape[0] == batch_size:
return tensor
if tensor.shape[0] == 1:
return tensor.repeat((batch_size,) + (1,) * (tensor.ndim - 1))
repeats = (batch_size + tensor.shape[0] - 1) // tensor.shape[0]
return tensor.repeat((repeats,) + (1,) * (tensor.ndim - 1))[:batch_size]
def _import_nodes(monkeypatch):
comfy_mod = types.ModuleType("comfy")
comfy_mod.__path__ = []
comfy_utils_mod = types.ModuleType("comfy.utils")
comfy_utils_mod.repeat_to_batch_size = _repeat_to_batch_size
comfy_samplers_mod = types.ModuleType("comfy.samplers")
comfy_samplers_mod.KSAMPLER = type("KSAMPLER", (), {})
comfy_model_base_mod = types.ModuleType("comfy.model_base")
comfy_model_base_mod.ModelType = types.SimpleNamespace(FLUX="FLUX", FLOW="FLOW")
comfy_model_base_mod.WAN22 = type("WAN22", (), {})
comfyui_version_mod = types.ModuleType("comfyui_version")
comfyui_version_mod.__version__ = "0.6.0"
comfy_mod.utils = comfy_utils_mod
comfy_mod.samplers = comfy_samplers_mod
comfy_mod.model_base = comfy_model_base_mod
monkeypatch.setitem(sys.modules, "comfy", comfy_mod)
monkeypatch.setitem(sys.modules, "comfy.utils", comfy_utils_mod)
monkeypatch.setitem(sys.modules, "comfy.samplers", comfy_samplers_mod)
monkeypatch.setitem(sys.modules, "comfy.model_base", comfy_model_base_mod)
monkeypatch.setitem(sys.modules, "nodes", types.ModuleType("nodes"))
monkeypatch.setitem(sys.modules, "latent_preview", types.ModuleType("latent_preview"))
monkeypatch.setitem(sys.modules, "comfyui_version", comfyui_version_mod)
sys.modules.pop("src.LanPaint.nodes", None)
return importlib.import_module("src.LanPaint.nodes")
class _FakeDiffusionModel:
sigma_shift_video = 12.0
sigma_shift_audio = 3.0
class _FakeModelPatcher:
def __init__(self, model):
self.model = model
def _two_stream_shapes():
return [(1, 24, 37, 30, 54), (1, 32, 2, 207)]
def _minimax_model():
model = type("FakeMiniMaxH3", (), {})()
model.diffusion_model = _FakeDiffusionModel()
return model
# --- detection: only MiniMax H3 AV packs get the audio schedule -----------------
def test_detect_returns_none_for_single_stream_latent(monkeypatch) -> None:
nodes = _import_nodes(monkeypatch)
patcher = _FakeModelPatcher(_minimax_model())
assert nodes._detect_minimax_h3_audio(patcher, {}, [(1, 24, 37, 30, 54)]) is None
assert nodes._detect_minimax_h3_audio(patcher, {}, None) is None
def test_detect_returns_none_for_model_without_audio_schedule(monkeypatch) -> None:
nodes = _import_nodes(monkeypatch)
patcher = _FakeModelPatcher(object()) # no diffusion_model / no shift attrs
assert nodes._detect_minimax_h3_audio(patcher, {}, _two_stream_shapes()) is None
def test_detect_returns_layout_for_minimax_av_pack(monkeypatch) -> None:
nodes = _import_nodes(monkeypatch)
patcher = _FakeModelPatcher(_minimax_model())
layout = nodes._detect_minimax_h3_audio(patcher, {}, _two_stream_shapes())
assert layout == (_two_stream_shapes(), 12.0, 3.0)
def test_detect_honors_sigma_shift_node_overrides(monkeypatch) -> None:
nodes = _import_nodes(monkeypatch)
patcher = _FakeModelPatcher(_minimax_model())
options = {"transformer_options": {"minimax_h3_sigma_shift_video": 10.0, "minimax_h3_sigma_shift_audio": 2.5}}
layout = nodes._detect_minimax_h3_audio(patcher, options, _two_stream_shapes())
assert layout == (_two_stream_shapes(), 10.0, 2.5)
def test_guarded_imports_fall_back_in_stub_env(monkeypatch) -> None:
nodes = _import_nodes(monkeypatch)
assert nodes.time_shift_sigma is None # comfy.ldm.minimax not importable here
# --- per-stream schedule blending in the paint loop -----------------------------
class _DummySampling:
"""Emulates the flow model_sampling: CONST.noise_scaling is
sigma * (s * noise) + (1 - sigma) * latent and reshape_sigma requires a
scalar sigma."""
noise_scale = 1.0
def noise_scaling(self, sigma, noise, latent_image, max_denoise=False): # type: ignore[no-untyped-def]
assert sigma.numel() == 1, "noise_scaling requires a scalar sigma"
return sigma * (self.noise_scale * noise) + (1.0 - sigma) * latent_image
class _DummyModel:
def __init__(self) -> None:
self.inner_model = self
self.model_sampling = _DummySampling()
self.last_input = None
def __call__(self, x, sigma, model_options=None, seed=None): # type: ignore[no-untyped-def]
self.last_input = x
return x, x
def _engine(n_steps: int = 1): # type: ignore[no-untyped-def]
return LanPaintEngine(
_DummyModel(),
NSteps=n_steps,
Friction=15.0,
Lambda=1.0,
Beta=1.0,
StepSize=0.2,
)
def _flat_pack_inputs(): # type: ignore[no-untyped-def]
x = torch.zeros(1, 1, 8) # flat pack: 5 video rows + 3 audio rows
latent_image = torch.zeros_like(x)
noise = torch.ones_like(x)
sigma = torch.tensor([0.5])
latent_mask = torch.zeros_like(x) # 1 = keep -> regenerate everywhere by default
current_times = (torch.tensor([1.0]), torch.tensor([0.5]), torch.tensor([0.5])) # video: VE, abt, flow
current_times_audio = (torch.tensor([0.25]), torch.tensor([0.9]), torch.tensor([0.2])) # audio: VE, abt, flow
audio_indicator = torch.zeros(1, 1, 8)
audio_indicator[..., 5:] = 1.0
return x, latent_image, noise, sigma, latent_mask, current_times, current_times_audio, audio_indicator
def test_audio_rows_get_audio_schedule_parameters() -> None:
engine = _engine()
captured = {}
def fake_prepare_step_size(current_times, step_size, sigma_x, sigma_y): # type: ignore[no-untyped-def]
captured["abt"] = current_times[1]
captured["step_size"] = step_size
abt = current_times[1]
ones = torch.ones_like(abt)
return (current_times[0], abt, ones, ones, ones, ones, torch.zeros_like(abt), torch.zeros_like(abt), torch.zeros_like(abt), torch.zeros_like(abt))
engine.prepare_step_size = fake_prepare_step_size # type: ignore[method-assign]
x, latent_image, noise, sigma, latent_mask, current_times, current_times_audio, audio_indicator = _flat_pack_inputs()
engine(x, latent_image, noise, sigma, latent_mask, current_times,
model_options=None, seed=0, n_steps=1,
current_times_audio=current_times_audio, audio_indicator=audio_indicator)
abt = captured["abt"].flatten()
assert abt[0] == 0.5 # video rows keep the video schedule
assert abt[-1] == 0.9 # audio rows run on the audio schedule
step = captured["step_size"].flatten()
assert step[0] == pytest.approx(0.2 * (1 - 0.5)) # video step size
assert step[-1] == pytest.approx(0.2 * (1 - 0.9)) # audio step size
def test_without_audio_context_schedule_stays_uniform() -> None:
engine = _engine()
captured = {}
def fake_prepare_step_size(current_times, step_size, sigma_x, sigma_y): # type: ignore[no-untyped-def]
captured["abt"] = current_times[1]
abt = current_times[1]
ones = torch.ones_like(abt)
return (current_times[0], abt, ones, ones, ones, ones, torch.zeros_like(abt), torch.zeros_like(abt), torch.zeros_like(abt), torch.zeros_like(abt))
engine.prepare_step_size = fake_prepare_step_size # type: ignore[method-assign]
x, latent_image, noise, sigma, latent_mask, current_times, _, _ = _flat_pack_inputs()
engine(x, latent_image, noise, sigma, latent_mask, current_times,
model_options=None, seed=0, n_steps=1)
abt = captured["abt"].flatten()
assert abt[0] == 0.5
assert abt[-1] == 0.5 # uniform: the video schedule everywhere
def test_replace_step_uses_audio_sigma_for_audio_rows() -> None:
# n_steps=0 with keep-everywhere mask: the only transform applied is the
# replace step, and the final model call sees its result (dummy returns x).
engine = _engine(n_steps=0)
x, latent_image, noise, sigma, _, current_times, current_times_audio, audio_indicator = _flat_pack_inputs()
latent_mask = torch.ones_like(x) # keep everywhere -> replace step applies
engine(x, latent_image, noise, sigma, latent_mask, current_times,
model_options=None, seed=0, n_steps=0,
current_times_audio=current_times_audio, audio_indicator=audio_indicator)
inp = engine.inner_model.last_input.flatten()
# replace step = sigma_eff * (s * noise) + (1 - sigma_eff) * latent, with
# noise = 1 and latent = 0 -> the effective flow sigma itself
assert inp[0] == pytest.approx(0.5) # video rows: sigma_v
assert inp[-1] == pytest.approx(0.2) # audio rows: sigma_audio
class _OffsetModel:
"""Returns x + offset for both heads, so the flat-grid target deviation is known."""
def __init__(self, offset): # type: ignore[no-untyped-def]
self.inner_model = self
self.model_sampling = _DummySampling()
self.offset = offset
def __call__(self, x, sigma, model_options=None, seed=None): # type: ignore[no-untyped-def]
return x + self.offset, x + self.offset
def _score_engine(offset: float = 2.0): # type: ignore[no-untyped-def]
engine = LanPaintEngine(
_OffsetModel(torch.tensor(offset)),
NSteps=1, Friction=15.0, Lambda=1.0, Beta=1.0, StepSize=0.2,
IS_FLOW=True,
)
engine.img_dim_size = 3
return engine
def test_score_model_corrects_audio_target_only() -> None:
engine = _score_engine()
ai = torch.zeros(1, 1, 8)
ai[..., 5:] = 1.0
engine.audio_indicator = ai
engine.audio_correction = (1.0 - ai) + 0.625 * ai # c = sigma_a/(sigma_v*slope_a) at sigma_v=0.5
x_t = torch.zeros(1, 1, 8)
y = torch.zeros(1, 1, 8)
mask = torch.zeros(1, 1, 8) # regenerate everywhere -> score_x branch
abt = torch.full((1, 1, 8), 0.5)
sigma = torch.ones(1, 1, 8)
tflow = engine.add_none_dims(torch.tensor([0.5]))
score = engine.score_model(x_t, y, mask, abt, sigma, tflow, model_options=None, seed=0)
s = score.flatten()
# x = 0, x0_flat = 2 -> corrected x0 = 2*corr -> score = 2*corr
assert s[0] == pytest.approx(2.0) # video rows: corr = 1 -> untouched
assert s[-1] == pytest.approx(1.25) # audio rows: 2 * 0.625
def test_score_model_without_audio_context_uses_flat_target() -> None:
engine = _score_engine()
x_t = torch.zeros(1, 1, 8)
y = torch.zeros(1, 1, 8)
mask = torch.zeros(1, 1, 8)
abt = torch.full((1, 1, 8), 0.5)
sigma = torch.ones(1, 1, 8)
tflow = engine.add_none_dims(torch.tensor([0.5]))
score = engine.score_model(x_t, y, mask, abt, sigma, tflow, model_options=None, seed=0)
s = score.flatten()
assert s[0] == pytest.approx(2.0)
assert s[-1] == pytest.approx(2.0) # uniform flat target: no correction
# --- add_none_dims equivalence ------------------------------------------------
def test_add_none_dims_equivalent_for_scalar_inputs() -> None:
engine = _engine()
for img_dim_size in (3, 4, 5):
engine.img_dim_size = img_dim_size
t = torch.zeros(1)
old = t[(slice(None),) + (None,) * (img_dim_size - 1)]
new = engine.add_none_dims(t.clone())
assert tuple(new.shape) == tuple(old.shape)
# the while-loop additionally handles 0-dim inputs, which the
# tuple-index form rejects in modern torch
assert tuple(engine.add_none_dims(torch.zeros(())).shape) == (1,) * img_dim_size
def test_add_none_dims_passes_per_row_tensors_through() -> None:
engine = _engine()
engine.img_dim_size = 3
t = torch.zeros(1, 1, 8)
assert tuple(engine.add_none_dims(t).shape) == (1, 1, 8)
# --- prepare_step_size with per-row parameters --------------------------------
def test_prepare_step_size_handles_per_row_parameters() -> None:
engine = _engine()
engine.img_dim_size = 3
sigma = torch.ones(1, 1, 8)
abt = torch.full((1, 1, 8), 0.5)
abt[..., 5:] = 0.9
step_size = torch.full((1, 1, 8), 0.1)
step_size[..., 5:] = 0.02
sigma_x = torch.ones(1, 1, 8)
sigma_y = torch.ones(1, 1, 8)
out = engine.prepare_step_size((sigma, abt, sigma), step_size, sigma_x, sigma_y)
for t in out:
assert t.ndim == 3 # everything stays per-row broadcastable
_, abt_out, dtx, dty, Gamma_x, Gamma_y, A_x, A_y, D_x, D_y = out
# the A * dt cancellation: A_x = 1/(1-abt), dt = dtx/2 = step_size * sigma_x
# -> A_x * dt = step_size / (1-abt) * (1-abt) * ... bounded per row
adt = (A_x * dtx).flatten()
assert adt[0] == pytest.approx(0.2) # 1/(1-0.5) * 0.1
assert adt[-1] == pytest.approx(0.2) # 1/(1-0.9) * 0.02 -- bounded invariant
+104
View File
@@ -0,0 +1,104 @@
import torch
from src.LanPaint.lanpaint import LanPaint as LanPaintEngine
class _DummySampling:
def noise_scaling(self, sigma, noise, latent_image): # type: ignore[no-untyped-def]
return latent_image + noise * sigma
class _DummyModel:
def __init__(self) -> None:
self.inner_model = self
self.model_sampling = _DummySampling()
def __call__(self, x, sigma, model_options=None, seed=None): # type: ignore[no-untyped-def]
return x, x
def _inputs(): # type: ignore[no-untyped-def]
x = torch.zeros((1, 4, 8, 8))
latent_image = torch.zeros_like(x)
noise = torch.ones_like(x)
sigma = torch.tensor([1.0])
latent_mask = torch.zeros_like(x)
current_times = (sigma, torch.tensor([0.5]), torch.tensor([0.0]))
return x, latent_image, noise, sigma, latent_mask, current_times
def test_default_semantic_stop_triggers_at_patience_without_custom_distance_fn() -> None:
engine = LanPaintEngine(
_DummyModel(),
NSteps=10,
Friction=15.0,
Lambda=1.0,
Beta=1.0,
StepSize=0.2,
)
calls = {"langevin": 0, "with_score": 0, "without_score": 0}
def fake_langevin(x_t, score, mask, step_size, current_times, sigma_x=1, sigma_y=0, args=None): # type: ignore[no-untyped-def]
calls["langevin"] += 1
if score is None:
calls["without_score"] += 1
else:
calls["with_score"] += 1
return x_t, args
engine.langevin_dynamics = fake_langevin # type: ignore[method-assign]
model_options = {
"lanpaint_semantic_stop": {
"threshold": 1e-6,
"patience": 2,
}
}
x, latent_image, noise, sigma, latent_mask, current_times = _inputs()
engine(x, latent_image, noise, sigma, latent_mask, current_times, model_options=model_options, seed=0, n_steps=10)
assert calls["langevin"] == 3
assert calls["with_score"] == 3
assert calls["without_score"] == 0
def test_semantic_stop_is_disabled_when_no_inpaint_region() -> None:
engine = LanPaintEngine(
_DummyModel(),
NSteps=10,
Friction=15.0,
Lambda=1.0,
Beta=1.0,
StepSize=0.2,
)
calls = {"langevin": 0, "with_score": 0, "without_score": 0}
def fake_langevin(x_t, score, mask, step_size, current_times, sigma_x=1, sigma_y=0, args=None): # type: ignore[no-untyped-def]
calls["langevin"] += 1
if score is None:
calls["without_score"] += 1
else:
calls["with_score"] += 1
return x_t, args
engine.langevin_dynamics = fake_langevin # type: ignore[method-assign]
model_options = {
"lanpaint_semantic_stop": {
"threshold": 1e-6,
"patience": 1,
}
}
x, latent_image, noise, sigma, latent_mask, _ = _inputs()
current_times = (sigma, torch.tensor([0.5]), torch.tensor([0.0]))
no_inpaint_mask = torch.ones_like(latent_mask)
engine(x, latent_image, noise, sigma, no_inpaint_mask, current_times, model_options=model_options, seed=0, n_steps=10)
assert calls["langevin"] == 10
assert calls["with_score"] == 10
assert calls["without_score"] == 0
+118
View File
@@ -0,0 +1,118 @@
import importlib
import sys
import types
import pytest
import torch
def _repeat_to_batch_size(tensor: torch.Tensor, batch_size: int) -> torch.Tensor:
if tensor.shape[0] == batch_size:
return tensor
if tensor.shape[0] == 1:
return tensor.repeat((batch_size,) + (1,) * (tensor.ndim - 1))
repeats = (batch_size + tensor.shape[0] - 1) // tensor.shape[0]
return tensor.repeat((repeats,) + (1,) * (tensor.ndim - 1))[:batch_size]
def _import_nodes(monkeypatch):
comfy_mod = types.ModuleType("comfy")
comfy_mod.__path__ = []
comfy_utils_mod = types.ModuleType("comfy.utils")
comfy_utils_mod.repeat_to_batch_size = _repeat_to_batch_size
comfy_samplers_mod = types.ModuleType("comfy.samplers")
comfy_samplers_mod.KSAMPLER = type("KSAMPLER", (), {})
comfy_model_base_mod = types.ModuleType("comfy.model_base")
comfy_model_base_mod.ModelType = types.SimpleNamespace(FLUX="FLUX", FLOW="FLOW")
comfy_model_base_mod.WAN22 = type("WAN22", (), {})
comfyui_version_mod = types.ModuleType("comfyui_version")
comfyui_version_mod.__version__ = "0.6.0"
comfy_mod.utils = comfy_utils_mod
comfy_mod.samplers = comfy_samplers_mod
comfy_mod.model_base = comfy_model_base_mod
monkeypatch.setitem(sys.modules, "comfy", comfy_mod)
monkeypatch.setitem(sys.modules, "comfy.utils", comfy_utils_mod)
monkeypatch.setitem(sys.modules, "comfy.samplers", comfy_samplers_mod)
monkeypatch.setitem(sys.modules, "comfy.model_base", comfy_model_base_mod)
monkeypatch.setitem(sys.modules, "nodes", types.ModuleType("nodes"))
monkeypatch.setitem(sys.modules, "latent_preview", types.ModuleType("latent_preview"))
monkeypatch.setitem(sys.modules, "comfyui_version", comfyui_version_mod)
sys.modules.pop("src.LanPaint.nodes", None)
return importlib.import_module("src.LanPaint.nodes")
class StubAudioVAE:
"""Emulates the sd.VAE wrapper: channels-last audio in/out, converts to
[B, C, L] for the raw VAE internally (as sd.py:1040/1216 do)."""
audio_sample_rate = 32000
def __init__(self):
self.last_input = None
def encode(self, waveform):
self.last_input = waveform
w = waveform.movedim(-1, 1) # [B, L, C] -> [B, C, L]
t = max(1, w.shape[-1] // 800)
return torch.zeros(w.shape[0], 32, 2, t)
def decode(self, z):
return torch.zeros(z.shape[0], z.shape[-1] * 800, 2) # [B, L, C]
def _audio(samples: int, channels: int = 2, sample_rate: int = 32000):
return {"waveform": torch.zeros(1, channels, samples), "sample_rate": sample_rate}
def test_encode_produces_correct_latent_and_no_mask(monkeypatch) -> None:
nodes = _import_nodes(monkeypatch)
vae = StubAudioVAE()
out = nodes.LanPaint_MiniMaxAudioEncode().encode(_audio(320000), vae)
latent = out[0]
assert latent["samples"].shape == (1, 32, 2, 400)
# pure encoder: masks come from the video mask editor via SetLatentNoiseMask
assert "noise_mask" not in latent
def test_encode_resamples_and_upmixes_mono(monkeypatch) -> None:
nodes = _import_nodes(monkeypatch)
if nodes.torchaudio is None:
pytest.skip("torchaudio not available")
vae = StubAudioVAE()
mono_44k = _audio(320000, channels=1, sample_rate=44100)
nodes.LanPaint_MiniMaxAudioEncode().encode(mono_44k, vae)
# wrapper receives channels-last stereo at its own sample rate
assert vae.last_input.shape[-1] == 2 # mono upmixed to stereo (channels last)
assert 200000 < vae.last_input.shape[1] < 320000 # resampled 44.1k -> 32k shortens
def test_decode_returns_waveform_and_sample_rate(monkeypatch) -> None:
nodes = _import_nodes(monkeypatch)
vae = StubAudioVAE()
audio = nodes.LanPaint_MiniMaxAudioDecode().decode({"samples": torch.zeros(1, 32, 2, 400)}, vae)
assert audio[0]["waveform"].shape == (1, 2, 320000)
assert audio[0]["sample_rate"] == 32000
def test_decode_handles_nested_av_latent(monkeypatch) -> None:
nodes = _import_nodes(monkeypatch)
vae = StubAudioVAE()
class FakeNested:
is_nested = True
def __init__(self, tensors):
self.tensors = tensors
def unbind(self):
return self.tensors
audio = nodes.LanPaint_MiniMaxAudioDecode().decode({"samples": FakeNested([torch.zeros(1, 24, 4, 8, 8), torch.zeros(1, 32, 2, 400)])}, vae)
assert audio[0]["waveform"].shape == (1, 2, 320000)
+17 -35
View File
@@ -1,8 +1,12 @@
import torch
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
from src.LanPaint.lanpaint import LanPaint
def test_langevin_dynamics_fallback_on_nan() -> None:
"""Test that langevin_dynamics falls back to overdamped dynamics if damped dynamics produces NaNs."""
def test_langevin_dynamics_uses_first_order_scheme() -> None:
"""The second-order damped-oscillator scheme is disabled; langevin_dynamics
always runs the first-order (overdamped) scheme, whose state carries no
velocity and whose output stays finite."""
torch.manual_seed(0)
# Setup minimal LanPaint instance
lp = LanPaint(Model=MagicMock(), NSteps=10, Friction=1.0, Lambda=1.0, Beta=1.0, StepSize=0.1)
@@ -17,35 +21,13 @@ def test_langevin_dynamics_fallback_on_nan() -> None:
step_size = torch.tensor([0.1])
# (sigma, abt, flow_t)
current_times = (torch.tensor([0.5]), torch.tensor([0.5]), torch.tensor([0.5]))
# Mock StochasticHarmonicOscillator to return NaNs
# We patch it where it is used (imported) in lanpaint.py
with patch("src.LanPaint.lanpaint.StochasticHarmonicOscillator") as MockSHO:
mock_instance = MockSHO.return_value
# Configure dynamics to return NaNs
nan_tensor = torch.full_like(x_t, float('nan'))
mock_instance.dynamics.return_value = (nan_tensor, nan_tensor)
# Execute langevin_dynamics
# This should try run_damped -> get NaNs -> raise ValueError -> catch -> run_overdamped
x_out, (v_out, C_out) = lp.langevin_dynamics(x_t, score, mask, step_size, current_times, sigma_y=1.0)
# Verify that SHO was initialized and dynamics called
MockSHO.assert_called()
mock_instance.dynamics.assert_called()
# Verify result is finite (indicating fallback to overdamped logic was successful)
assert torch.isfinite(x_out).all(), "Output contains NaNs, fallback failed"
assert v_out is None or torch.isfinite(v_out).all()
def test_langevin_dynamics_fallback_on_exception() -> None:
"""Test that langevin_dynamics falls back to overdamped dynamics if damped dynamics raises Exception."""
torch.manual_seed(0)
lp = LanPaint(Model=MagicMock(), NSteps=10, Friction=1.0, Lambda=1.0, Beta=1.0, StepSize=0.1)
x_t = torch.randn(1, 4, 8, 8)
lp.img_dim_size = 4
mask = torch.zeros_like(x_t)
score = lambda x: torch.zeros_like(x)
step_size = torch.tensor([0.1])
current_times = (torch.tensor([0.5]), torch.tensor([0.5]), torch.tensor([0.5]))
with patch("src.LanPaint.lanpaint.StochasticHarmonicOscillator") as MockSHO:
mock_instance = MockSHO.return_value
# Configure dynamics to raise Exception
mock_instance.dynamics.side_effect = RuntimeError("Simulation exploded")
x_out, (v_out, C_out) = lp.langevin_dynamics(x_t, score, mask, step_size, current_times, sigma_y=1.0)
assert torch.isfinite(x_out).all()
# Execute langevin_dynamics (first-order overdamped scheme directly)
x_out, args_out = lp.langevin_dynamics(x_t, score, mask, step_size, current_times, sigma_y=1.0)
assert hasattr(args_out, "v")
assert hasattr(args_out, "C")
assert hasattr(args_out, "x0")
assert args_out.v is None # first-order scheme: no velocity state
assert args_out[1] is args_out.C
assert args_out[2] is args_out.x0
# Verify result is finite (the overdamped update is numerically robust)
assert torch.isfinite(x_out).all(), "Output contains NaNs"
File diff suppressed because it is too large Load Diff
+349
View File
@@ -0,0 +1,349 @@
"""Tests for the LanPaint video mask metadata read/write (videometa module).
Requires PyAV. All tests are skipped gracefully when PyAV is unavailable,
which is the case in CI environments (e.g., the system Python at C:\\Python314
does not have av installed, while the ComfyUI venv at E:\\ComfyUI\\.venv does).
"""
import os
import sys
import tempfile
import pytest
# Ensure the project root is on sys.path (tests may be run from any CWD).
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
try:
import av
import numpy as np
HAVE_AV = True
except ImportError:
av = None # type: ignore[assignment]
np = None # type: ignore[assignment]
HAVE_AV = False
pytestmark = pytest.mark.skipif(not HAVE_AV, reason="PyAV (av) not available")
if HAVE_AV:
from src.LanPaint.videometa import (
decode_payload,
encode_payload,
export_mask_video_from_request,
read_mask_metadata,
write_mask_metadata,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
if HAVE_AV:
def _make_test_mp4(path: str, duration_frames: int = 10, fps: int = 10) -> None:
"""Create a tiny colour-bar MP4 via PyAV (no external tool required).
The video is 32x24 pixels, RGB frames encoded with libx264.
"""
container = av.open(path, "w", format="mp4")
stream = container.add_stream("libx264", rate=fps)
stream.width = 32
stream.height = 24
stream.pix_fmt = "yuv420p"
for i in range(duration_frames):
# simple colour bar: red channel varies per frame
arr = np.zeros((24, 32, 3), dtype=np.uint8)
r = (i * 25) % 256
for py in range(24):
for px in range(32):
arr[py, px] = [r, (px * 8) % 256, (py * 10) % 256]
frame = av.VideoFrame.from_ndarray(arr, format="rgb24")
for packet in stream.encode(frame):
container.mux(packet)
for packet in stream.encode():
container.mux(packet)
container.close()
# ---------------------------------------------------------------------------
# Payload encode / decode
# ---------------------------------------------------------------------------
class TestPayloadCodec:
def test_roundtrip_simple(self) -> None:
payload = {
"version": 1,
"video": "test.mp4",
"fps": 30.0,
"keyframes": {"0": "iVBORw0KGgoAAAA="},
"audio_intervals": [{"start": 1.0, "end": 2.5}],
}
encoded = encode_payload(payload)
assert isinstance(encoded, str)
decoded = decode_payload(encoded)
assert decoded == payload
def test_roundtrip_unicode(self) -> None:
payload = {
"version": 1,
"video": "テスト動画.mp4",
"fps": 24.0,
"keyframes": {
"0": "iVBORw0KGgoAAAA=",
"12": "iVBORw0KGgoAAAANSUhEUg==",
},
"audio_intervals": [
{"start": 0.5, "end": 1.25},
{"start": 3.0, "end": 4.75},
],
}
encoded = encode_payload(payload)
decoded = decode_payload(encoded)
assert decoded == payload
def test_empty_keyframes(self) -> None:
payload = {
"version": 1,
"video": "no_mask.mp4",
"fps": 30.0,
"keyframes": {},
"audio_intervals": [],
}
encoded = encode_payload(payload)
decoded = decode_payload(encoded)
assert decoded == payload
def test_decode_none(self) -> None:
assert decode_payload(None) is None
def test_decode_garbage(self) -> None:
assert decode_payload("not json") is None
assert decode_payload(42) is None # type: ignore[arg-type]
assert decode_payload("[]") is None # valid JSON but not a dict
def test_decode_missing_keyframes_ok(self) -> None:
# payloads missing optional fields still decode
assert decode_payload('{"version":1,"video":"v.mp4"}') == {
"version": 1,
"video": "v.mp4",
}
# ---------------------------------------------------------------------------
# Read / write round-trip
# ---------------------------------------------------------------------------
class TestWriteRead:
def test_roundtrip_with_keyframes(self) -> None:
payload = {
"version": 1,
"video": "src.mp4",
"fps": 30.0,
"keyframes": {
"0": "iVBORw0KGgoAAAA=",
"5": "iVBORw0KGgoAAAANSUhEUg==",
},
"audio_intervals": [{"start": 1.0, "end": 3.0}],
}
with tempfile.TemporaryDirectory() as td:
src = os.path.join(td, "src.mp4")
dst = os.path.join(td, "out.mp4")
_make_test_mp4(src, duration_frames=10, fps=30)
write_mask_metadata(src, dst, payload)
assert os.path.isfile(dst)
read_back = read_mask_metadata(dst)
assert read_back == payload
def test_tag_absent_on_plain_mp4(self) -> None:
with tempfile.TemporaryDirectory() as td:
src = os.path.join(td, "plain.mp4")
_make_test_mp4(src)
result = read_mask_metadata(src)
assert result is None
def test_source_is_never_modified(self) -> None:
payload = {
"version": 1,
"video": "src.mp4",
"fps": 25.0,
"keyframes": {},
"audio_intervals": [],
}
with tempfile.TemporaryDirectory() as td:
src = os.path.join(td, "src.mp4")
dst = os.path.join(td, "out.mp4")
_make_test_mp4(src, duration_frames=5, fps=25)
# hash the source bytes before writing
src_bytes_before = open(src, "rb").read()
write_mask_metadata(src, dst, payload)
src_bytes_after = open(src, "rb").read()
assert src_bytes_before == src_bytes_after, (
"write_mask_metadata modified the source file"
)
def test_unicode_in_keyframe_data(self) -> None:
# base64 strings can contain unicode context; metadata values are
# UTF-8 ― ensure round-trip with non-ASCII frame indices works.
payload = {
"version": 1,
"video": "видео.mp4",
"fps": 24.0,
"keyframes": {"0": "iVBORw0KGgoAAAA="},
"audio_intervals": [],
}
with tempfile.TemporaryDirectory() as td:
src = os.path.join(td, "src.mp4")
dst = os.path.join(td, "out.mp4")
_make_test_mp4(src, duration_frames=6, fps=24)
write_mask_metadata(src, dst, payload)
result = read_mask_metadata(dst)
assert result is not None
assert result["video"] == "видео.mp4"
# ---------------------------------------------------------------------------
# Filename suffixing (non-clobber)
# ---------------------------------------------------------------------------
class TestExportMaskVideo:
def test_basic_export(self) -> None:
with tempfile.TemporaryDirectory() as td:
src_name = "video.mp4"
src = os.path.join(td, src_name)
_make_test_mp4(src, duration_frames=10, fps=30)
out_name = export_mask_video_from_request(
input_dir=td,
filename=src_name,
keyframes={"0": "abc123"},
audio_intervals=[{"start": 0.0, "end": 2.0}],
fps=30.0,
)
assert out_name == "video_masked.mp4"
assert os.path.isfile(os.path.join(td, out_name))
def test_suffix_when_target_exists(self) -> None:
with tempfile.TemporaryDirectory() as td:
src_name = "video.mp4"
src = os.path.join(td, src_name)
_make_test_mp4(src, duration_frames=5, fps=25)
# Create a fake collision file
with open(os.path.join(td, "video_masked.mp4"), "wb") as f:
f.write(b"not an mp4")
out_name = export_mask_video_from_request(
input_dir=td,
filename=src_name,
keyframes={},
audio_intervals=[],
fps=25.0,
)
assert out_name == "video_masked_2.mp4"
assert os.path.isfile(os.path.join(td, out_name))
def test_multiple_suffixes(self) -> None:
with tempfile.TemporaryDirectory() as td:
src_name = "video.mp4"
src = os.path.join(td, src_name)
_make_test_mp4(src, duration_frames=5, fps=25)
# create several collisions
for name in ("video_masked.mp4", "video_masked_2.mp4", "video_masked_3.mp4"):
with open(os.path.join(td, name), "wb") as f:
f.write(b"not an mp4")
out_name = export_mask_video_from_request(
input_dir=td,
filename=src_name,
keyframes={},
audio_intervals=[],
fps=25.0,
)
assert out_name == "video_masked_4.mp4"
def test_source_not_found(self) -> None:
with tempfile.TemporaryDirectory() as td:
with pytest.raises(FileNotFoundError):
export_mask_video_from_request(
input_dir=td,
filename="nonexistent.mp4",
keyframes={},
audio_intervals=[],
fps=30.0,
)
def test_payload_fidelity(self) -> None:
"""The exported file must carry the exact payload that was given."""
payload_kf = {"0": "iVBORw0KGgoAAAA=", "7": "/9j/4AAQSkZJRgABAQ=="}
payload_ai = [
{"start": 0.5, "end": 1.0},
{"start": 3.25, "end": 5.75},
]
with tempfile.TemporaryDirectory() as td:
src_name = "vid.mp4"
_make_test_mp4(os.path.join(td, src_name), duration_frames=10, fps=30)
out_name = export_mask_video_from_request(
input_dir=td,
filename=src_name,
keyframes=payload_kf,
audio_intervals=payload_ai,
fps=30.0,
)
payload = read_mask_metadata(os.path.join(td, out_name))
assert payload is not None
assert payload["version"] == 1
assert payload["video"] == src_name
assert payload["fps"] == 30.0
assert payload["keyframes"] == payload_kf
assert payload["audio_intervals"] == payload_ai
# ---------------------------------------------------------------------------
# Metadata coexistence
# ---------------------------------------------------------------------------
class TestMetadataCoexistence:
def test_preexisting_metadata_is_preserved(self) -> None:
"""When the source MP4 already has metadata (e.g., title), it survives."""
with tempfile.TemporaryDirectory() as td:
src = os.path.join(td, "labelled.mp4")
# Create with a metadata tag
container = av.open(src, "w", format="mp4")
container.metadata["title"] = "Original Title"
container.metadata["comment"] = "Should survive"
stream = container.add_stream("libx264", rate=10)
stream.width = 32
stream.height = 24
stream.pix_fmt = "yuv420p"
frame = av.VideoFrame.from_ndarray(
np.zeros((24, 32, 3), dtype=np.uint8), format="rgb24"
)
for pkt in stream.encode(frame):
container.mux(pkt)
for pkt in stream.encode():
container.mux(pkt)
container.close()
dst = os.path.join(td, "labelled_masked.mp4")
payload = {
"version": 1,
"video": "labelled.mp4",
"fps": 10.0,
"keyframes": {},
"audio_intervals": [],
}
write_mask_metadata(src, dst, payload)
result = read_mask_metadata(dst)
assert result == payload
# Verify original metadata survived
container2 = av.open(dst, "r")
assert container2.metadata.get("title") == "Original Title"
assert container2.metadata.get("comment") == "Should survive"
container2.close()
+39
View File
@@ -0,0 +1,39 @@
import { app } from "../../scripts/app.js";
const LANPAINT_GITHUB_URL = "https://github.com/scraed/LanPaint";
const TARGET_NODES = new Set([
"LanPaint_KSampler",
"LanPaint_KSamplerAdvanced",
"LanPaint_SamplerCustom",
"LanPaint_SamplerCustomAdvanced",
]);
app.registerExtension({
name: "LanPaint.InfoLink",
async nodeCreated(node) {
if (!node?.comfyClass || !TARGET_NODES.has(node.comfyClass)) {
return;
}
const alreadyAdded = node.widgets?.some(
(widget) => widget.name === "lanpaint_star_button"
);
if (alreadyAdded) {
return;
}
node.addWidget(
"button",
"More Info, Bug Report, Star on GitHub ⭐",
"lanpaint_star_button",
() => {
window.open(
LANPAINT_GITHUB_URL,
"_blank",
"noopener,noreferrer"
);
}
);
},
});
+171
View File
@@ -0,0 +1,171 @@
/**
* Pure mask-morph math shared by the mask editor preview and the parity test
* (tests/parity_mask_math.mjs). MUST mirror src/LanPaint/videomask.py —
* interpolate_masks/_edt_2d/_signed_distance/_sigmoid_stable — so the preview
* is pixel-equivalent to the backend output (the 0.5 level sets match
* exactly: Math.round(v*255) >= 128 iff v >= 0.5).
*/
/** Exact 1D EDT (squared distances), Felzenszwalb-Huttenlocher, O(n). */
export function edt1DSq(f) {
const n = f.length;
const v = new Int32Array(n);
const z = new Float64Array(n + 1);
z[0] = -Infinity;
z[1] = Infinity;
let k = 0;
for (let q = 1; q < n; q++) {
const q2 = q * q;
let s;
for (;;) {
const vk = v[k];
s = (f[q] + q2 - (f[vk] + vk * vk)) / (2 * (q - vk));
if (s > z[k]) break;
k--;
}
k++;
v[k] = q;
z[k] = s;
z[k + 1] = Infinity;
}
const d = new Float64Array(n);
k = 0;
for (let q = 0; q < n; q++) {
while (z[k + 1] < q) k++;
const vk = v[k];
d[q] = f[vk] + (q - vk) * (q - vk);
}
return d;
}
/** Exact 2D EDT in place; f holds 0 at foreground, sentinel elsewhere. */
export function edt2D(f, w, h) {
const row = new Float64Array(w);
const col = new Float64Array(h);
for (let y = 0; y < h; y++) {
row.set(f.subarray(y * w, (y + 1) * w));
f.set(edt1DSq(row), y * w);
}
for (let x = 0; x < w; x++) {
for (let y = 0; y < h; y++) col[y] = f[y * w + x];
const d = edt1DSq(col);
for (let y = 0; y < h; y++) f[y * w + x] = Math.sqrt(Math.max(d[y], 0));
}
return f;
}
/**
* Signed distance field of a mask's alpha channel (0-255, >= 128 = painted).
* `alpha` must be a PURE alpha array of exactly w*h bytes (not RGBA).
* Returns { field: Float64Array(w*h), cy, cx, hasShape } — positive inside.
*/
export function computeSDFEntry(alpha, w, h) {
const n = w * h;
if (alpha.length !== n) {
throw new Error("computeSDFEntry expects a pure alpha array of w*h bytes (RGBA was passed?)");
}
const large = h * h + w * w + 1;
const sentinel = Math.max(w, h) / 2;
let any = false;
let all = true;
let sumY = 0;
let sumX = 0;
let count = 0;
let f = new Float64Array(n);
for (let i = 0; i < n; i++) {
const on = alpha[i] >= 128;
if (on) {
any = true;
sumY += (i / w) | 0;
sumX += i % w;
count++;
} else {
all = false;
}
f[i] = on ? 0.0 : large;
}
if (!any) {
return { field: new Float64Array(n).fill(-sentinel), cy: 0, cx: 0, hasShape: false };
}
if (all) {
// Python's np.where mean for an all-true mask is the frame center
return {
field: new Float64Array(n).fill(sentinel),
cy: (h - 1) / 2,
cx: (w - 1) / 2,
hasShape: true
};
}
const dFg = edt2D(f, w, h);
f = new Float64Array(n);
for (let i = 0; i < n; i++) {
const on = alpha[i] >= 128;
f[i] = on ? large : 0.0;
}
const dBg = edt2D(f, w, h);
const field = new Float64Array(n);
for (let i = 0; i < n; i++) field[i] = dBg[i] - dFg[i];
return { field, cy: sumY / count, cx: sumX / count, hasShape: true };
}
/** Shift a field by whole pixels; vacated pixels become 0. */
export function shiftField(field, w, h, dy, dx) {
const out = new Float64Array(w * h);
const srcY0 = Math.max(0, -dy);
const srcY1 = Math.min(h, h - dy);
const srcX0 = Math.max(0, -dx);
const srcX1 = Math.min(w, w - dx);
for (let y = srcY0; y < srcY1; y++) {
const srcRow = y * w;
const dstRow = (y + dy) * w;
for (let x = srcX0; x < srcX1; x++) {
out[dstRow + x + dx] = field[srcRow + x];
}
}
return out;
}
/** Edge softness of the morph sigmoid, in pixels (mirror _MORPH_SOFTNESS). */
export const MORPH_SOFTNESS = 1.0;
/** Exact mirror of videomask._sigmoid_stable: 1/(1+exp(-clip(d/soft, +-50))). */
export function sigmoidStable(d) {
return 1 / (1 + Math.exp(-Math.max(-50, Math.min(50, d / MORPH_SOFTNESS))));
}
/**
* Per-frame SDF morph, mirroring interpolate_masks' inner loop exactly:
* d(p) = (1-w)*sdf_lo(p - w*D) + w*sdf_hi(p + (1-w)*D)
* mask = sigmoid(d)
* Returns a Float32Array(w*h) in 0..1.
*/
export class MaskMorph {
constructor(w, h) {
this.w = w;
this.h = h;
}
frame(loEntry, hiEntry, wf) {
const w = this.w;
const h = this.h;
let sx1 = 0;
let sy1 = 0;
let sx2 = 0;
let sy2 = 0;
if (loEntry.hasShape && hiEntry.hasShape) {
const dx = hiEntry.cx - loEntry.cx;
const dy = hiEntry.cy - loEntry.cy;
sx1 = Math.floor(wf * dx + 0.5);
sy1 = Math.floor(wf * dy + 0.5);
sx2 = Math.floor((1 - wf) * dx + 0.5);
sy2 = Math.floor((1 - wf) * dy + 0.5);
}
const f1 = shiftField(loEntry.field, w, h, sy1, sx1);
const f2 = shiftField(hiEntry.field, w, h, -sy2, -sx2);
const out = new Float32Array(w * h);
for (let i = 0; i < w * h; i++) {
out[i] = sigmoidStable((1 - wf) * f1[i] + wf * f2[i]);
}
return out;
}
}
File diff suppressed because it is too large Load Diff