The flat fallback of the old dual-mode import pattern bound the shared
top-level 'modules' name, so on a sys.path where another root ships a
modules/ package, first-importer-wins decided which vendored tree - and
with it which ARCH_REGISTRY object - the pack binds.
New src/keep_vendored.py (stdlib-only, pip-_vendor semantics): one
sys.modules root '_keep_vendor' anchored to this pack's modules/deps via
spec_from_file_location, single instantiation by construction, a loud
ImportError on a foreign occupant of the private name, sys.modules
cleanup on a failed exec, never touches sys.path. The four vendored
import sites in src/ repoint to vendor(); the lazy loader sites stay
lazy. tools/scan_compat.py models vendor("...") string-literal calls
(and static _keep_vendor.* imports) so the static import closure keeps
covering the vendored tree. Guard pins: the static rot scan (no absolute
self-imports, no dynamic-import machinery in the vendored tree), the
namespace ban over src/nodes/tools, the flat-decoy probe (a foreign
modules/ tree on sys.path binds nothing shared) and the registry-identity
probe (one instantiated tree per process across every import mode).
modules/deps/__init__.py carries the docstring-only contract update - the
only modules/deps file touched.
origin/master had advanced past the fork point (fcb4f2d 'bump ver 1.1',
2025-07-03) with six v1.x commits (ver 1.1.1, multiface tracking, README/
pyproject updates up to the v1.2.0 tag, the wm_basicsr/wm_facelib rename)
that the local v2.0.0 rewrite supersedes. The 'ours' strategy keeps the
v2.0.0 tree byte-identical and records the v1.x tail as ancestry, so the
push to the registry branch is a plain fast-forward and no remote commit
is destroyed.
Owner-made edits (2026-11-08): publish.yml trimmed (actions/checkout v7,
Comfy-Org/publish-node-action@main, the repository_owner guard and the
issues permission dropped), .github/workflows/tests.yml removed (GitHub
runners cannot handle the test suite), pyproject.toml package
description shortened to "Video Face Super-Resolution" (version stays
2.0.0).
tests/test_ci_workflow.py is removed with it: every pin in it asserted
the shape of the deleted tests.yml (its module fixture hard-fails on the
missing file), so keeping it would turn the gate red on every run. The
stale pyproject comment that named tests.yml is updated. tools/
ci_env_sim.py stays (harmless, still runnable locally).
The owner's live profile on the 30 s clip (17 -> 32 -> ~52 GB) is now
attributed by measurement (a per-stage RAM sampler outside the tree:
psutil RSS/commit + StageBar marks, 128-frame CPU fixture, x6.07):
- the paste stage's gradual per-tick growth is the round-18 output
buffer's lazy page commit (16 x 19.44 MB per 16-frame group) - the
contract floor being paid, not a leak;
- the first stage's +15 GB is the caller-owned input IMAGE materializing
into the working set as the detection loop reads it (dRSS +2.3 GB,
dCommit +0 B on the raw-input run) - not pack-owned;
- the static audit found no whole-clip float32 copy of the input in the
pack (the only advanced-indexing gather of the input is the
max_clip_length-bounded :1132).
Two pack-owned O(N) items remained and are removed:
- FIX 1: the mixed-case faces np.stack gather (~611 MB owner-scale) is
replaced by LazyFaceSource - the caller's face stack served per group
(a view when contiguous, a group-sized copy when scattered) through a
faces_provider seam mirroring frame_provider; the all-eligible
pass-through gate keeps handing the raw stack.
- FIX 2: the fallback tail converts per gray group (_flush_fallback_group,
the indexed write) - one group's stack + transient (<= 20 x 4.86 MB),
independent of the fallback count, instead of a whole-fallback stack
plus its ~10x conversion transient; the device-canvas and host tails
keep their whole-stack form.
Gate: 6 failed, 580 passed, 16 skipped, 7 deselected - the 6 are the
owner's own asset/example edits, unchanged; 11 new pins (byte identity,
no-copy censuses, the cross-group boundary, the one-whole-clip-buffer
count).
The paste core owned a float32 buffer and the processor then allocated a
second whole-clip one and copied the eligible rows into it while the
first was still alive. On the owner's clip (777 x 1500 x 1080 x 3,
float32 = 15.10 GB per buffer) the mixed case - any face-less frame -
peaked at input + two such buffers (~45.3 GB); the all-eligible case
aliased and was already at the ~30.2 GB contract floor.
- paste_faces_batch_chunked gains keyword-only out= / out_rows=: the
processor's final buffer and the destination row of each canvas row.
The group convert-out writes by INDEX (in-place index_put_, not the
read-side advanced-indexing copy) - a scattered destination stays
scattered. Both None keep today's behaviour exactly.
- comfy_convert_out gains out_rows= with the same indexed write plus the
loud-failure validation (pair, length, range, uniqueness, dtype,
canvas, capacity).
- both processor paste twins allocate the final whole-clip buffer BEFORE
the paste and hand it to the core; the scatter is deleted. The
all-eligible case keeps its alias - no copy is reintroduced.
- draw_box now lands on the final rows of the final buffer.
Measured (a torch.empty census over both paths, base vs this tree): the
mixed case goes from 2 canvas-shaped float32 buffers to 1 in both the
single-face and the multiface path; the all-eligible case stays at 1; the
returned IMAGE is that buffer (weakref identity).
Gate: 569 passed, 16 skipped, 7 deselected, 6 failed - the 6 are the
owner's own asset/example edits, unchanged; the 7 new pins are the
indexed-write byte identity (both paths), the validation contract, the
mixed-case census (both paths) and the draw_box rows.
The round-17 byte-identity pin used a 5x7x9 tensor, which never splits
COMFY_BATCH_CHUNK_PIXELS - it compared a single-chunk whole-clip
conversion with a single-chunk group. Production splits: at 1500x1080 the
budget holds 2 frames per chunk, so the whole clip and a group are
converted on different chunk boundaries.
The new pin patches the budget down (the split is the point, the size is
not) and asserts that the chunked whole-clip conversion and every
per-group conversion - including the lazy view's - are byte-identical to
the single-chunk rows.
Gate: 562 passed, 16 skipped, 7 deselected, 6 failed - the same six
owner-tree failures, unchanged.
The final stage materialized the clip twice as uint8 BGR: the whole-clip
stack (~3.78 GB on the owner's 777-frame 1500x1080 clip) and the
eligible-gather copy of it (up to another ~3.78 GB). Both are gone:
- BgrFrameSource (src/keep_image_utils.py) wraps the caller's float32
IMAGE tensor and converts rows on demand; the conversion is elementwise,
so a row is byte-identical to the same row of the whole-clip conversion.
- paste_faces_batch_chunked (src/keep_gpu_paste.py) asks the background
per group through one provider protocol; the numpy-array path is served
by NumpyFrameProvider and keeps the old zero-copy slicing.
- both paste twins (src/keep_processor.py) take the source; the eligible
rows are a lazy view, so the gather copy is gone and the rows that are
not pasted are never converted.
- the fallback rows arrive in groups of their own, and their gray flags
are computed with their group (the whole-clip gray pass is gone too);
the eligible rows' flags were never read.
The float32 output buffer stays (it is the node's returned IMAGE) and the
float32 input stays (the caller holds it). The full and host paste modes
still materialize the eligible stack - the disclosed limitation.
Gate: 561 passed, 16 skipped, 7 deselected, 6 failed - the 6 are the
owner's own asset/example edits, unchanged; the 9 new pins are the byte
identity, the laziness, the group-sized conversions and the gray flags.
_call_spandrel routes all three model calls through the spandrel
descriptor (size_requirements padding), the FP16 autocast is gated on
supports_half, the re-entrant _spandrel_on_device manager owns the
stage-level GPU lifecycle, and the bg tiled_scale halves its tile under
OOM (512 -> 256 -> 128, sticky). The plan's literal torch.cuda.empty_cache()
is substituted with the guard-(g)-sanctioned _soft_empty_cache at both
sites (disclosed deviation, owner-answered 2026-11-03).
Implements .dev/docs/keep-fix5-implement1.txt against the 9e6c50f tree:
Phase 1 - the paste pipeline: the face upscaler joins the batched core
(the _batched_paste/_batched_paste_multiface aborts are deleted), mode
is forced chunked with a bg model, face_upscale_model threads through
the paste chunk bodies (parse masks stay on the 512 faces, the ROI
grid_sample warps the upscaled faces), upscale_faces_batch_gpu (B=1,
FP16 autocast on CUDA), and _tiled_scale_and_resize_batch replaces
_tiled_scale_batch + the round-12 bg grouping (B=1 FP16 loop, one
bicubic-antialias resize straight to the paste canvas; the no-model
branch keeps _resize_bg_frames). bg_chunk_size and its pins are removed
as dead machinery. parse_soft_masks and _run_upscaler run under
autocast(enabled=(device.type == 'cuda')).
Phase 2 - facelib: RetinaFace caches priors by (h, w, device) and
detect_faces filters by confidence and runs torchvision.ops.nms on the
device before the single CPU transfer (output format preserved);
read_image gains check_gray=True, only the three sequence call sites
pass False.
Phase 3 - archs: SDPA in CrossAttention._attention, AttnBlock and both
gmflow attention forms; get_flow pinned to float32 with autocast
disabled; pos_emb cast to query_emb.dtype; keep_net under autocast at
both sequence call sites.
Phase 4 - the vendored basicsr pruning: data/, losses/, models/,
train.py, test.py, 23 unused archs, 7 unused utils deleted (78 files);
DCNv2Pack removed from arch_util; the three __init__.py rewrites;
enumeration manifests regenerated. metrics/ and ops/ stay on disk
byte-identical, off the import path. 4.2 skipped (explicitly optional).
Autocast is CUDA-only everywhere, so the CPU gate keeps exercising the
FP32 paths; no model is loaded. Gate: 556 passed, 0 failed (embedded
python; 557 -> 556 = the dead bg_chunk_size pins out, new pins in).
GPU throughput and the FP16/SDPA numerics stay owner-measured.
The bg upscaler's whole-clip tiled_scale call materialised the x4
intermediate of the entire clip (~0.37 GiB per 720p frame - two float32
stacks of the scaled size plus the uint8 one) and OOMed a 16 GB card on
a 48-frame 720p clip before the first paste; the owner measured the
video path unusable with the bg upscaler alone (worse than face x4 or
both x4).
batched_bg_frames now runs the tiled_scale calls over bg_chunk_size
frame groups sized from the x4 intermediate's bytes (27 B per scaled
pixel, the staging budget), each group resized to its target before the
next group starts - the x4 intermediate dies with its group instead of
materialising for the whole clip. The model work is unchanged
(tiled_scale still processes every image on its own); the no-model and
CPU branches keep their exact resize routes through the extracted
_resize_bg_frames. New pins: grouped == whole-batch pixels across the
host, device-staging and canvas variants, and the bg group budget math.
Gate: 557 passed (555 + 2), 0 failed.
GPU throughput stays owner-measured; the CUDA Lanczos resize's batch
composition is the design's disclosed owner-gated residual.
Round-11 upscaler-pipeline audit (owner task: check whether the bg/face
upscaler inputs are applied correctly or erroneous, and how they compose
with final_upscale_factor):
- VERDICT: the two upscaler models are quality operators only - their
output scales are always absorbed by resizes (batched_bg_frames /
vendored paste / aligned path); final_upscale_factor alone fixes the
output geometry (the paste canvas x factor), exactly as the node
tooltip states. Slowdown with x4 models is the documented design
(a face upscaler disqualifies the batched paste; bg model runs per
fallback frame; the canvas is model-widened x factor).
- CONFIRMED ERROR (the one real bug found, reproduced CPU-only in three
configurations): the single-face no-face/affine-None short-circuit
appended the raw _run_upscaler output without the factor/canvas
resize, so a bg model with scale != factor or a sub-512 clip mixed row
sizes and crashed the convert-out (np.stack ValueError). The
multiface sibling already resized (round 10); this applies the same
paste_canvas_hw + LANCZOS4 resize to the single-face site.
- The twin size check (:1352) is dead code (all_restored_faces_cv2 is
preallocated to num_input_frames) - no fix required.
- One pin's paste stub corrected to the real 512x512 canvas with a
dated docstring; no fixture re-capture needed (only rows that crashed
the run change pixels).
Gate: 555 passed, 6 skipped, 7 deselected, 0 failed (baseline 555).
Implements the owner's .dev/docs/keep-fix4.txt precisely:
- Block A (keep_gpu_paste.py): _compute_roi_theta composes the paste's
inverse affine with the ROI window; _paste_chunk_roi and
_paste_chunk_faces warp each face straight into its canvas ROI with
one F.affine_grid / F.grid_sample pair per face and blend ROI-local
with the plain round_().clamp_(0, 255) tail - the fix-2.1 full-canvas
batched warp and the t3 sequential per-row accumulator are replaced
(overlapping ROIs no longer see an earlier face's blend; the later
face's scatter wins). warp_face_roi and _paste_frame_full_canvas_multi
lose their production call sites and stay live for their op-level pins;
_quantize_and_clip stays live for _paste_chunk and the full-canvas
fallbacks.
- Block B (keep_processor.py): _draw_boxes_on_canvas draws the 2-px
green box per face on the device canvas / chunk buffer; draw_box (and,
for the multiface, the gray skip) no longer disqualifies the batch;
stage_bar is threaded into every paste call of both batched bodies.
- Block C: the multiface paste stage rides the same cadence as the
single-face one - batched rows tick inside the paste core (the per-
group tick extended to the multi-face branches), fallback rows tick in
the loop, and a frame with no face (or every affine lost) short-circuits
to the background, resized to the paste canvas (adapted from the
snippet's raw append: a sub-512 clip would stack-mismatch - disclosed
in the dated comment).
- Disclosed limitations, as written in the spec: the boxes land on the
device-output canvases only (a CPU draw_box run keeps no boxes on the
batched frames), and the uint8 color casts to 255.0 on the float32
chunk buffer; the dead "Device output assembly disengaged" host log is
gone with the wholesale body replacement.
Both sequence goldens re-captured (owner-authorized pixels; only
output_frames changed); the paste equivalence / multiface / bar-cadence
pins reworked to measured values with dated docstrings.
Gate: 555 passed, 6 skipped, 7 deselected, 0 failed (baseline 555,
pins reworked 1:1).
Implements the owner's .dev/docs/keep-fix3.txt precisely:
- Fix 1.1: a frame with no detected face (affine None) short-circuits
the fallback paste to the upscaled background - no vendored
paste_faces_to_input_image on a face-less frame; paste_bar threaded
into _batched_paste; the canvas/chunk buffers alias when every frame
is batched (no copy); progress.update(1) added to the skipped rows.
- Fix 1.2: the grayscale eligibility filter is gone - the device blend
pastes a gray frame with the same tensor logic; _batched_paste gains
stage_bar and forwards it into all three paste modes.
- Fix 2.1: _paste_chunk_roi is one vectorized GPU tail - the faces and
the parse masks warp to the full canvas in one warp_affine_batch call
each (grid_sample), one-tensor alpha blend, the vendored quantizing
tail over the whole-canvas maximum; the per-face ROI machinery
(warp_face_roi, fixed-point terms, ROI patch transfers) is gone from
the single-face path. warp_face_roi stays live for the multi-face
sibling _paste_chunk_faces. Pixel change owner-authorized; the
retired transfer counters are disclosed in the dated docstring.
- Fix 2.2: device_output_mode - CUDA never returns "host": full while
the whole batch's canvas fits the budget, else always chunked (the
OOM handler is the net, the owner-authorized trade); non-CUDA keeps
"host".
- Fix 2.3: paste_faces_batch_chunked ticks the stage bar per completed
GPU group; paste_faces_batch does the same per chunk (the full/host
modes) - nobody ticks twice, the bar totals num_input_frames exactly
on every path; the multi-face groups get no tick (the multiface
stage owns its bar).
The single-face sequence golden is re-captured (owner-authorized
pixels); the paste equivalence and cadence pins are reworked to the
measured behavior with dated docstrings.
Gate: 555 passed, 6 skipped, 7 deselected, 0 failed (baseline 552).
The four sequence stages (detecting face landmarks, cropping and
aligning faces, restoring faces with KEEP, pasting faces and finalizing
frames) logged only their start line and went silent until the next
stage. StageBar (src/keep_stage_progress.py, stdlib-only) is a second,
console-only progress channel beside the V3 execution-API reporter:
- the line "{label} {done}/{total} ({pct}%) {rate} fps ETA {mm:ss}
elapsed {mm:ss}" - overall-average rate, floored percent (never 100
early), ETA from the average rate;
- carriage-return rendering on a real console (no ANSI escapes, the
[KEEP] pack tag, writes to sys.__stdout__ so the D4 console capture
is never corrupted); one complete logger line per render otherwise;
- the cadence is a 2 s time gate - O(1) per tick, no per-frame spam;
- one bar per stage per run: constructed before the chunk loop, ticks
accumulate across chunks, one finish() on the normal path (an OOM or
a cancel leaves without a final line - the exception is the report).
Hooked at nine sites across both sequence paths: detection ticks per
frame (face-less frames too), the single-face crop per affine, the
multi-face crop once per face batch (unit=faces - the per-face LMEDS
loop is opaque), restore per chunk, paste per frame iteration. The
early returns (all-NaN, zero faces) carry their stage's final line.
Gate: 552 passed, 6 skipped, 7 deselected, 0 failed (baseline 535; the
new pins live in tests/test_keep_stage_progress.py).
The paste-mode verdict (device_output_mode) reads the free VRAM through
ComfyUI's get_free_memory(..., torch_free_too=True), which counts what
the torch cache can give back. Round 6 (Opt 6) deleted the per-chunk
_soft_empty_cache() of the restore loop, so cached restore blocks shrank
the paste budget (the min of the 1 GiB cap and the free share) and the
verdict flipped from full/chunked to host: the clip took the host-CPU
assembly - the owner's "Pasting faces and finalizing frames" stall.
One release before the verdict in both sequence paths restores the
pre-Opt-6 budget once per run, without re-paying the per-chunk sync cost
Opt 6 removed. The cadence pin records the measured twice-per-run clean
cadence (pre-paste, then the finally's); the OOM cadence is unchanged
and pinned; a new pin proves the release precedes the paste stage.
Gate: 535 passed, 6 skipped, 7 deselected, 0 failed (baseline 534 plus
the new ordering pin).
Opt 1 - the fused crop loops warp on the device (crop_faces_batch per
source-shape group) instead of one cv2.warpAffine per row.
Opt 2 - warp_face_roi uploads its four terms once and composes
x_full/y_full on the device; the fixed-point math is unchanged (bitwise).
Opt 3 - bgr_u8_batch_to_normalized runs in float32 end to end and
_gaussian_smooth convolves in float32; the float64 transient is gone.
Opt 4 - the Lanczos axis matrices are cached per (in_size, out_size); the
cache holds device-free numpy, so one entry serves both devices.
Opt 5 - the aligned single-image path restores on the device
(_restore_aligned_torch): no detection, no vendored img2tensor tail, no
vendored paste. The io schema is untouched.
Opt 6 - the chunk loops no longer call _soft_empty_cache(); the release
stays in the finally blocks and in the OOM handler.
The two sequence golden fixtures are re-captured (the owner's documented
override of the capture tool's do-not-rerun warning) - Opt 1 and Opt 3
change pixels by design. The image fixtures are unchanged.
Tier-1 gate: 534 passed, 6 skipped, 7 deselected, 0 failed.
The round-4 golden fixtures are compressed archives: scanning them as
text trips on coincidental C:\ / C:/ byte pairs. The pin passed at
round 4 only because the fixtures were still untracked at gate time
(git ls-files did not list them); the decompressed members were
verified path-free during that round's QA.
The round-4 fused crop loops re-derived each frame's detection image with
one single-row converter call per frame - N host sync points where the
round-3 crop did one batch. The grouped form converts the chunk's gathered
rows in one elementwise pass (bitwise the per-row rows) and is bounded by
the caller's chunk; the paste-fallback row form stays. Pins guard the
conversion count. The paste stage itself is proven byte-equivalent to
32e6628 (dispatch, cores, tails); timed CPU stubs show no superlinear
term in either tree - the real-box burn needs the owner's log to attribute
(the device-mode verdict under real VRAM pressure is the only paste-stage
quantity this diff can move).
- R1: the face helper is constructed on the pack device (was the offload
device); load_device() explicitly places the detector and the parser with
a logged CPU fallback, offload() mirrors the restore, the YOLO .device
skew is closed (P-D1..P-D5).
- R2: detection streams per max_clip_length group (no full-clip convert-in
retention), crop is fused into a chunk-bounded restore loop with
preallocated staging, the paste tail keeps one contractual float32
buffer, the helper is released after every run; golden fixtures pin the
outputs bitwise (P-M1..P-M7).
- The diagnosis: the OOM was O(frames) whole-clip host buffers, not the
faces (the detector/landmark path was already GPU-placed);
max_clip_length never bounded convert-in or the paste tail.
- Gate: 523 passed, 6 skipped, 7 deselected, 0 failed (baseline 505).
Two owner-reported bugs, both verified as gaps that never closed (the old
code on master had them too).
Cancel: the node never polled ComfyUI's interrupt flag, so the Cancel
button had no effect. A new src/keep_interrupt.py wraps
comfy.model_management.throw_exception_if_processing_interrupted() (lazy
import, a no-op outside ComfyUI); it is called at every long-loop
boundary - detection, crop, restore chunks, paste frames, and the paste
module's group/chunk loops. InterruptProcessingException is a
BaseException, so it escapes the pipeline untouched.
Multi-face: the pipeline collapsed to one face per frame - the detector
call passed only_keep_largest=True (the vendored selector reduces to the
largest face) and the processor then took all_landmarks_5[0]. With
only_center_face=False the new branches keep every detected face:
detection returns all faces, the crops/restore/paste flatten to the
ragged per-frame face count, the device canvas composites several ROIs
per row, and the vendored fallback hands the helper all of a frame's
faces. The near-black tail follows the real vendored reference (one
convert per image after a forward face loop). only_center_face=True and
the aligned path keep their bodies byte-for-byte.
44 new pins: cancel (the exception escapes the sequence and the node, no
work after it, unchanged output without a cancel), multi-face image and
video (all faces restored and pasted, faces appearing/disappearing,
None-affine and black-face drops, per-row near-black tail against the
vendored composite), and the paste core (face-to-frame map, row
compositing, chunk boundaries that never split a frame's faces).
Verified on the CPU device only - no model loads, no GPU runs. Gate: 505
passed, 6 skipped, 7 deselected, 0 failed (461 baseline + 44 new pins).
The round-1 device canvas disengaged past its fixed 1 GiB budget, so at
final_upscale_factor=4 (~44 MB/frame at 720p) typical clips fell back to
the host assembly at 16x the pixels. The engagement is now three-way:
- device_canvas_budget: a share of the free VRAM (0.25) under the hard
cap, the cap when free memory is unavailable - the mirror of
paste_chunk_size.
- device_output_mode: full | chunked | host. The chunked mode assembles
frame groups on the device (the same group size the full mode derives
internally, so the batch compositions coincide) and downloads the
per-group convert-outs into one preallocated host IMAGE buffer - the
device footprint stays bounded for any clip length.
- comfy_convert_out: the BGR->RGB float convert-out runs on the device
in budget-sized sub-chunks (the out-of-place chain peaks at ~9x the
canvas bytes of transient), so the budget bounds what it gates.
- a "host" verdict on a CUDA device is logged - the silent fallback
that hid the regression must not be silent again.
Also: the paste loop's NaN-landmark short-circuit never recorded its
frame in fallback_indices - in a device-output mode the tail's indexed
assignment length-mismatched (pinned red before the fix; the branch is
defensive today, the production interpolation fills NaN landmarks).
8 new bit-exactness pins (budget, selector, convert-out sub-chunking,
factor-4-scale, chunked assembly incl. group-boundary independence,
sentinels, end-to-end modes); the instrument gains the factor-4-scale
output-mode scenario. Verified on the CPU device only - no GPU run (the
CUDA before/after stays owner-gated). Gate: 461 passed, 6 skipped,
7 deselected, 0 failed.
The instrument now times the same paste_faces_batch call in the host and
the device output mode, one session, one fixture, and asserts the two
are bit-identical. On the CPU fixture (8 frames, 360x640, stub nets):
the output assembly's host<->device traffic is gone (1.06 MB each way
becomes 0, the ROI-sized staging recorded as roi_staged_bytes), wall
-8.8%, process CPU -8.7%. The delta dilutes the CUDA effect - on the
owner's GPU the kernel work stays on the GPU and the removed portion is
pure host time - so the CUDA measurement of the same comparison stays
the owner-gated V2.
A self-review of the device-output canvas found the rare near-black
branch calling the numpy _background_max_outside with a device tensor -
it happened to work through the numpy/torch API overlap, but it made an
undocumented per-frame device read inside the branch and would break on
a torch-only API change. The branch is rare by construction, so this one
frame is downloaded explicitly, exactly like the full-canvas fallback
next to it.
Also adds the device-output pin of the near-black branch itself
(test_the_device_canvas_keeps_the_near_black_full_canvas_fallback): the
rare path now runs in the canvas mode in the gate instead of only in
production.
The paste-stage attribution (tools/prof_paste_host_cpu.py, the CPU-device
stub run) showed the batch always takes the device path - the vendored
per-frame fallback never runs in the default configuration - and that the
remaining host cost of the step is the output assembly: the per-frame ROI
download, the whole-canvas host copy, the whole-stack np.stack and the
float math of the convert-out, all of which exist only because the output
stack is assembled in host memory.
F1 keeps the stack on the device: batched_bg_frames(return_device=True),
paste_faces_batch(device_output=True) scatters the blended ROI patches
into one uint8 device canvas, and the paste step assembles the batch with
one indexed copy, one stacked upload of the vendored-fallback frames and
a single bgr_u8_batch_to_comfy on the device. Nothing is downloaded or
copied per frame. The mode is off on a CPU device, over the 1 GiB
PASTE_DEVICE_STACK_MAX_BYTES budget and when nothing is eligible, so the
host path - and every existing pin - runs byte-for-byte as before; the
bytes that would have travelled are kept as roi_staged_bytes.
The new pin test_the_device_output_stack_is_bit_identical_to_the_host_stack
pins the canvas against the host stack on a CPU device. Gate: 451 passed,
6 skipped, 7 deselected.
The owner's route decision (the tolerance-accepted GPU resample):
resize_lanczos_u8 implements PIL's three-lobe Lanczos with the
scale-aware support and the drop-and-renormalize edge rule as dense
axis matrices + matmul, chunked by LANCZOS_CHUNK_PIXELS pixels.
batched_bg_frames keeps the result of _tiled_scale_batch on the device,
resizes with the new op when CUDA and downloads once at the end; a CPU
device keeps the per-frame cv2 loop (pixel-identical there - the
equivalence pin still holds exactly).
Pinned in test_image_op_equivalence.py against PIL (the semantic
reference: mean <= 0.3, max <= 12) and against cv2 (the accepted drift:
upscale mean <= 1.0 / max <= 8, downscale mean <= 8.5 / max <= 54),
plus chunk transparency and a GPU-vs-CPU pin; the profiler wraps the
op as p6_bg_resize.
The paste stage's largest remaining host-side item was the gray flag: one numpy variance pass per frame (41 ms of process CPU per frame, 4.7 cores). On a CUDA device the three channel-difference variances now run on the device in float64 (the vendored int16 differences, chunked to bound the transient), and only the frames whose result lands on the threshold fall back to the host computation - so the flags are the host path's flags, not a tolerance.
A CPU device and a caller without a device keep the numpy computation unchanged. Pinned in tests/test_is_gray_device.py: random, flat and saturated frames, a constructed borderline frame that actually hits the threshold, the CPU device, the empty batch, a bad shape, and both processor call sites.
The probe now measures the paste stage's sub-stages (the wall time, the process CPU time and the core count of every step) and counts the host-device copies of the paste, so a change of the paste can be judged on the CPU load it leaves, not on the wall time alone.
The paste stage spent its host time on three full-canvas passes per frame. The face warp now runs on the device with cv2's own fixed-point convention (the matrix inversion of warpAffine, the 1/1024 pre-scale, the 1/32 tap grid, the (+512)>>10 rounding), the mask warp reuses the one batched grid sliced to the face ROI, and the blend and the quantizing tail run inside that ROI. The frame-global maximum branch is kept: a near-black frame falls back to the full-canvas path, which stays bit-exact by construction.
Measured over 48 frames at 720p on CUDA: the paste call's process CPU time falls 20% (38.74 -> 30.92 ms/frame), the host-device transfers shrink 6.66x (the blended canvas download by 85%), and the result is bit-identical to the full-canvas path on the smooth and the noise corpus, at interior and edge-touching ROIs.
Times every stage of process_image_sequence on synthetic frames with a same-session control clone of the base implementation, plus micro benches for the parse chunk sweep and the KEEP-stage allocator question. The evidence behind the redesign ranking and the before/after tables of the internal design review.
process_image_sequence converts the batch once, detects per frame while capturing the frames the detector saw (the read_image products, including the min(h,w)<512 upscale), estimates each affine once with the vendored call, crops with cv2.warpAffine (decision D2: the uint8-bound warp stays bit-exact; the batched grid_sample helper stays parked and pinned), normalizes the whole crop batch on the device, keeps the KEEP chunk contract byte-identical, converts the restored faces with one batched tensor2img, pastes through the batched paste with the vendored fallback, and stacks the output in one transfer. The paste canvas is the detection frame, so small frames keep the base's upscaled canvas (the round-1 QA defect F1, fixed and pinned). A failed affine degrades to the neutral crop with a warning instead of crashing like the base. Progress still reports num_frames * 4 in the four families. Pins: the read-once/affine-once/chunk-contract AST guards, the progress contract, and the new end-to-end base-vs-shipped suite (tests/test_gpu_sequence_e2e.py) with the real vendored helper - measured drift 0/255 on the vendored branch, 1/255 on the batched branch.
New src/keep_gpu_paste.py: the chunked ParseNet forward (chunk cap 8, the measured VRAM sweet spot), the batched parse-mask postprocess, the batched blend with the vendored quantizing tail, one tiled_scale call for the whole background group, is_gray without the PIL round trip, and VRAM-aware chunk sizes from model_management.get_free_memory. The frames the batched paste cannot handle (draw_box, face upsampler, grayscale, no parse net, no face) keep the vendored paste_faces_to_input_image. Equivalence against the real vendored paste: <=1/255 on the parse mask, blend and tail bit-exact.
New src/keep_gpu_ops.py: affine capture with the vendored cv2 calls, batched crop/inverse warps (grid_sample, in-bounds compositing of the gray border, uint8 requantization), a batched form of the vendored tensor2img chain, the ParseNet colormap/blur/postprocess, and the batched ComfyUI converters in keep_image_utils.py (one host transfer per batch; the /255 reproduces img2tensor's double rounding bit for bit). Every op carries a measured-tolerance pin in tests/test_gpu_batch_equivalence.py; the uint8-bound warps measure up to 8/255 against cv2's fixed-point sampling on adversarial content - the numbers that drive the tolerance decision D2.
The host's requirements.txt provides torch, torchvision, numpy, einops, pyyaml, Pillow, scipy, tqdm and requests; the pack now declares only opencv-python-headless - the vendored architectures and the four LANCZOS4 resizes need cv2, and no GUI call exists anywhere in the pack or the vendored tree, so the headless wheel suffices (no libGL/Qt in a server install). requests was a phantom: only the vendored basicsr download helper imports it. The dependency guards move to the host-guarantee policy (closure coverage = declared + host-provided, pinned to the host's requirement lines, exhaustive in both directions).
download_weight's progress flag becomes real: one guarded comfy.utils.ProgressBar step per completed download (a failed one stays at 0), lazy import so the engines load without a server. The torch.hub transport stays bar-free - its own bar is untagged stderr noise.
The channel conversions, the INTER_LINEAR resizes, the GRAY2BGR lift and the temporal landmark smoothing (the former gaussian_filter1d) become torch-native helpers; tensors keep their device and the host copy happens once, as uint8. The four LANCZOS4 resizes keep cv2 (torch has no lanczos) - that is why OpenCV stays declared. Equivalence is pinned in tests/test_image_op_equivalence.py: conversions exact, bilinear resize within 1/255 of cv2 over a 20-geometry matrix, smoothing within 1e-10 (float64 measured ~4e-14, float32 bit-equal).
The loader of ComfyUI logs one line per yaml entry. The host already printed that block at its startup, so the pack raises the level of the root logger while it asks the host to read the file again - the block is not printed a second time.
ComfyUI reads the default extra_model_paths.yaml and every file of --extra-model-paths-config (main.py:142-144). The pack now follows the same list, so a user who supplies the extra roots only on the command line gets the layout convention as well and the checkpoint is found where it is instead of being downloaded again.
The hash mismatch path deleted the file it found - and since the extra model roots are resolved now, that file can live in the original directory of the user. The download is atomic now (the transport writes into a temporary file that is only put in place after it was verified), a file outside the primary root of the installation is never deleted or overwritten, and the co-location of the face weights keeps a file of the user as a backup instead of overwriting it.
Per-defect regression guards (the extra model roots, the logger of ComfyUI, the KEEP tag, the routed console output, the selective download) plus the host modules app and utils in the lazy-import guard.