The second source tab was Server file, a box for a typed path. It is now
Outputs: the videos in ComfyUI's output folder, newest first, in the same
list with a preview that the Uploads tab uses. The list asks the server
each time it opens (/ausboss/transform/video/outputs, no parameters), so a
video rendered a moment ago is in it.
The picked video is saved the way ComfyUI names a file in one of its
folders ("clip.mp4 [output]"). resolve_server_file reads that form and
nothing else: a whole path, a drive, "~", ".." and a network path are
refused from the text alone, and a link that leads out is refused once the
path is resolved. A workflow saved with a typed path shows nothing picked
and a line that asks for the pick.
Video Crop + Rotate + Pad (Frame and Clip): Server file mode read a whole
path that had to lie inside ComfyUI's input, output or temp folder. It now
takes the folder and the name (output/clip.mp4) and joins them below that
folder. A whole path, a drive, "~", ".." and a network path are refused
from the text alone, and a link that leads out is refused once the path is
resolved. The hint under the field says what to type when a whole path is
in it.
LoRA Loader: the preview route sends a small JPEG made from the picture
beside the LoRA instead of the file itself.
resolve_input_path refuses when ComfyUI's folders are missing instead of
opening the name unchecked. A route's error text that names a place on the
server's disk is replaced whole (nodes/_route_errors.py), on the LoRA
routes too.
Image Crop + Rotate + Pad and both Video Crop + Rotate + Pad nodes rounded
the canvas up to Divisible by and put the pixels left over on the right and
bottom, whatever was padded. A 1916 x 821 picture with 3 px of padding on
the left and Divisible by 24 came to 1920 wide, with 1 px of fill on the
right edge, which had no padding. With Resize on, that strip was smeared
into the picture's last column, and the stitched result kept it as a light
line.
The canvas now rounds one axis at a time by Load Image + Pad's rule
(_pad_helpers._axis_layout): the leftover joins a side that was padded, the
right or bottom one when both are, and an axis nobody padded has its crop
trimmed evenly to the multiple below. A picture smaller than one multiple
still grows on the far side. With no padding on an axis the size therefore
rounds down, not up: 1000 x 750 at 64 gives 960 x 704, was 1024 x 768.
Inputs and outputs are unchanged.
The editor uses the same rule (axisLayout, resolvePadding and keptCrop in
js/shared/transform_geometry.mjs), so the picture on the node, the size
line and the editor match the run. The size line names fill added on any
side and pixels trimmed, and the centre buttons count fill that sits on the
left or top. The Divisible by tooltip and help pages describe the rule, and
the Krea 2 Outpaint Model Patch warning no longer says Divisible by rounds
the other axis up.
tests/test_transform_engine.py and tests/transform_geometry.test.mjs share
one fixture table, so the editor and the run are held to the same numbers.
Resolved:
- node_image_crop_rotate_pad.py: optional inputs are the four resize
inputs, then stitch_blend, then stitch_grow; node_api.json regenerated
with test_node_api.py --update (no breaking change against main).
- test_transform_outputs.py: the optional-inputs test expects the stitch
inputs after the resize ones.
- transform_editor.mjs: the Inpaint & Stitch section and its status line
serve the image node too (feather branch) beside the size-chain status
(this branch); the Cancel code is #73's, line for line.
- LTX example note and the Clip help page carry both branches' wording.
- CHANGELOG keeps every bullet.
fix/transform-mask-only-feather stops the transform fading the picture,
so the canvas the stitcher holds is already the unfaded one. Remove
transform_pil_layers, keep_unfaded and resize_transform_to_megapixels,
their three tests and the CHANGELOG bullet; the image node keeps its
optional resize inputs and the integer aspect maths stays.
- Rotating keeps the crop's size over the same part of the picture from
the knob, slider, number box and Reset rotation (cropForRotation).
- A readout under the picture names every step that sets the output size
(crop -> pad -> align -> resize) and warns when the resize Step
stretches the picture or Align pads fill ahead of a resize; ticking
Resize shows Megapixels and Step on the face.
- Locked-aspect crops use integer maths (_ratio_box / ratioBox), so the
size shown equals the run.
- Image Crop + Rotate + Pad's four resize inputs are optional, so API
prompts saved before them validate again.
- Video Crop + Rotate + Pad -> Clip: one Length switch replaces the Fixed
frames and Limit boxes; OUT sits on the last output frame; a connected
fixed_frames / frame_load_cap takes over the length.
- The stitcher pastes the unfaded canvas when feather > 0
(transform_pil_layers / keep_unfaded). Superseded by mask-only feather
and dropped in the next commit.
Carries a copy of the editor Cancel / discard-prompt change from
feat/editor-cancel-discard (#73), which this work was built on.
The image and video transform nodes faded the picture into the fill
colour across the feather ramp. Outpaint models read that ramp as
content (the Qwen 2.1 outpaint LoRA painted a darker band along a tilted
edge, LTX's IC-LoRA left feathered bars unpainted), and the stitcher
kept the faded pixels, so Stitch's colour match measured its drift
against them and pulled the fill toward grey (d(ab) 18.5 at feather 24 on
a straight pad, 2.6 now). Feather now shapes only the mask and the stitch
blend built from it; the picture keeps a hard edge against the fill, so a
feathered canvas is the feather-0 canvas pixel for pixel.
Image Crop + Rotate + Pad gains the clip node's optional stitch_blend
(32) and stitch_grow (0) inputs, and its editor shows the same Inpaint &
Stitch section with Show blend. The editor's live fade overlay goes with
the fade.
The video transform nodes' local_path widget was read by every queued run,
so any client that can reach the unauthenticated /prompt route could make
the server open any video on the disk. Local path mode now reaches only
ComfyUI's input, output and temp folders unless ComfyUI is started with
AUSBOSS_TRANSFORM_LOCAL_PREVIEW=1, for queued runs and editor previews
alike. Tooltips, help pages, README and CHANGELOG say so; the clip node's
test fixture opts in explicitly.
Video Crop + Rotate + Pad -> Clip / -> Frame:
- The rail under the preview is a timeline: playhead scrubbing on the node
face, frame-accurate IN/OUT handles that carry the playhead, Set IN/OUT
and I/O keys in the editor; js/shared/timeline_math.mjs mirrors the
backend's inclusion rule and emits hundredth-grid seconds.
- Scrubbing no longer flickers: storyboard tiles only stand in when nearer
than the frame on stage and are decoded at their target times; the scrub
session returns the frame it already decoded instead of the next one;
served previews are cached.
- Aspect lock on the format chips (pad, lock, clear) with lockPadding in
transform_geometry; Lock aspect in the editor.
- A new source keeps fill, feather, resize, Snap and Limit and re-pads to
the lit chip; a canvas row (fill, feather, resize) sits on the node face.
- The clip node declares a black fill and feather 0 as its defaults and the
editor's Reset all follows the node's own declaration, so a fresh node or
a reset never lands on the grey, feathered canvas the LTX IC-LoRA cannot
paint; image nodes keep grey/24.
Examples:
- LTX 2.3 Video Outpaint: Select Frame -> core Generate Text writes the
positive prompt; note rewritten with the tested rules (53 renders over
six clips: only fill #000000 + feather 0 decide painting).
- Core model loaders coloured and Workflow Notes grown to fit across the
other examples.
- Optional resize of the transformed output to a pixel budget, with core
Scale Image to Total Pixels semantics: megapixels x 1024^2, aspect
preserved, each dimension rounded to resolution_steps. The image uses
the chosen filter (lanczos/area/bicubic/bilinear/nearest-exact); the
mask always resizes bilinear so feathered edges cannot ring. The math
is mirrored py+js (scale_to_megapixels / scaleToMegapixels) and
fixture-tested on both sides; the new widgets append after the stable
V1 set so saved workflows keep loading, and IS_CHANGED fingerprints
them.
- A quick row under the node's canvas: Reset (rotation, crop, padding),
a Feather toggle that remembers the amount it turns off, and the
Resize toggle with its megapixel scrub box.
- The editor gains a Resize output section (megapixels and steps as
scrub controls, method select) and its status names the exact resized
size.
- The output size readout moved off the pixels: centered below the image,
flipping above when clipped, and showing the resize target.
- CHANGELOG entries for this round and the two commits before it.
Feathering had two defects: the mask used max(original, blur), and a
blurred step edge sits at ~50% on the boundary, so the mask jumped
255->127 in one pixel - the visible seam line - and the output image was
never blended at all, ending in a hard cut against the fill. The mask
ramp is now pinned to full strength exactly at the edge (double the blur
and clip, then max with the original for thin slivers), and the visible
output fades into the fill color across the same ramp, so outpaints
blend smoothly in both the image and the mask.
The rotation knob now anchors to the outermost top-right corner of
source-union-output, so it stays visually attached when padding grows
the canvas instead of floating where the source corner used to be.
Save & close is now filled with the AusBoss accent; Reset all is styled
as a destructive red button.
22 Python + 9 JS tests pass, including new asserts that the mask ramp
holds >=200 on the first kept column (was ~127) and that the image
visibly blends into the fill. Live-browser verified: knob tracks the
outermost corner under 140/90px padding, buttons computed styles match.
Real-world scrubbing was still sluggish on large files for three reasons
the small test clip hid: decodes ran synchronously inside async route
handlers (stalling ComfyUI's entire web server, websocket included),
every request re-opened and re-probed the container, and mid-GOP targets
re-decoded from the previous keyframe each time.
- Route handlers now run decode/encode in worker threads.
- A persistent per-file decoder session keeps the container and codec
state alive: forward steps decode only the frames in between, backward
or distant jumps do one keyframe seek without reopening. Sessions are
LRU-capped and an idle reaper closes them after 60s so the file lock
is released (Windows users can delete/overwrite a clip they just
scrubbed) — a bug the test suite caught via WinError 32.
- A keyframe storyboard (up to 96 tiles) builds in a background thread
when a video is selected. Dragging the timeline draws the nearest tile
synchronously — measured 2.4ms input-to-pixels in a live browser —
and the exact decoded frame replaces it when it lands.
- The rotation handle emoji is replaced with a vector arc-and-arrowhead
glyph, crisp at any zoom and identical on every platform.
21 Python + 9 JS tests pass, including new coverage for session
forward/backward parity against fresh decodes and storyboard payloads.
Scrubbing previously used a 70ms trailing debounce, so nothing rendered
while the slider was moving and stale responses could rewrite frame_index
mid-drag (rubber-banding). Replaced with a latest-wins pump: the first
input fetches immediately, exactly one request stays in flight, and each
response re-reads the widgets so the next fetch targets the newest
position. Scrub frames download at 640px with widget sync off; drag
release, key release, and playback stop land one full-size frame that
snaps the widgets to the decoded position. Playback and held arrow keys
share the light path. Server side, per-file metadata is cached by
(path, mtime, size) so a scrub request opens the container once, and the
JPEG optimize pass is dropped for ephemeral previews.
Browser-verified on live ComfyUI: 41 burst inputs -> 23 fetches rendering
continuously during the drag, final widget lands on the last position,
and the displayed pixels match the exact final frame. Random-access
frame latency measured at ~18ms median on the test clip.
The rotation control moves from a stalk above top-center (stacked on the
pad_top diamond) to a rotate-glyph knob off the source's top-right
corner, with the help text updated.
Review fixes verified against live ComfyUI 0.28.0:
- rotatedSize now reproduces Pillow's expand=True output exactly (transpose
fast path at axis angles, corner extents elsewhere). The previous formula
was 1px short on 34 of 78 sampled size/angle combos, so editor-predicted
output dimensions disagreed with queued results at most free angles.
Verified 0 mismatches across 6776 combinations and end-to-end on the
server (predicted 848x496 == queued image and mask at 77.7 degrees).
- decode_video_frame seeks to the nearest keyframe and decodes forward
instead of scanning from frame 0, with the sequential scan kept as the
fallback for files without usable timestamps. Scrubbing long videos no
longer decodes the entire prefix per preview request.
- Preview routes no longer serve arbitrary local paths to any HTTP client:
local-path previews outside ComfyUI's input/output/temp folders now
require AUSBOSS_TRANSFORM_LOCAL_PREVIEW=1, checked before touching the
filesystem so denied requests cannot probe path existence. Queued
workflows are unaffected.
- Editor: metadata is cached per source instead of refetched every scrub
tick, timeline seeks fetch only the frame with a 70ms trailing debounce,
AbortError from superseded requests no longer paints the error state,
and switching source mode before picking a file no longer resets the
transform.
- Tests: 13 -> 18 Python (seek vs sequential ground truth, time-mode seek,
out-of-range clamp, preview gate), 8 -> 9 JS (Pillow-verified rotated-size
fixtures including the odd/even transpose edge).