A node sized for the old face could grow into the node below once the new
notes showed. Now:
- The size line names the lit ratio where it acted ("pad to 16:9
1821x1024") instead of adding a line, and "already 9:16: pick another
ratio or turn it" sits on the same line when it fits.
- The Even out, To start and Full clip buttons fit their lines.
- "= the whole clip. Shorten it to move IN" fits beside the Length on the
example workflow's node.
- A new picture only ever shrinks the picture area to its shape; it never
grows the node.
- A node dragged taller while its preview was off keeps that height when
the preview comes back.
- A new picture with no ratio lit starts with the padlock off, as the
untouched picture has no shape to hold.
The padlock:
- Reset and tapping the lit ratio turn it off.
- It does nothing on the untouched picture (the row says Source), so a
trim there only trims.
- The row says Held while it is on, and a locked drag splits the new
bands evenly (each move is solved from where the drag started).
- Reset crop also removes the bands the lock added.
- The lit ratio and the lock share the pack's solid teal "on" look;
hover is a light outline.
Shapes and sizes:
- A picture within about 1% of a ratio counts as that ratio: tapping 9:16
on a nearly 9:16 photo adds no 1 px band.
- While a ratio is lit, the line under the picture says what it did,
and "Already 9:16: nothing to add. Pick another ratio or turn it." when
there was nothing to do (a saved ratio re-applies to every new picture).
- The resize warning reads "1.5% taller: each side rounds to 32 px"; its
tooltip names a Step that avoids it, and in pad mode Even out adds a few
pixels of padding so nothing stretches.
- Fit dims and says "pick a ratio first" until a ratio is lit.
- Over the picture, the cursor and small arrows show only the ways it can
move; with nowhere to go it is a plain pointer.
Drawing:
- Added space is drawn in the real fill colour with a faint hatch, and
only the picture the crop cuts away is darkened, so black bands on the
black stage show.
- The rotate knob keeps clear of the padding diamonds and crop squares.
- On a big node the controls stay at most 460 px wide, centred, and only
the picture grows. A new picture fits the picture area to its shape,
never taller than the height you gave it.
Words: Align is "Round canvas to", Local path is "Server file", Resize is
an off | on switch, and the canvas row labels read like the others.
The full editor is built from the node's own controls: the ratio row with
its turn button and padlock, the Fit switch, and number boxes for the
rotation (Shift for 0.1 degree) in place of the coarse slider. Ratios
from ausboss_presets.json that no button shows sit in a More list. The
size box keeps clear of the handles, and the editor's button style no
longer reaches the number boxes.
Prototype of the ratio row, for review. A chip used to stay lit after a
drag changed the shape (16:9 lit on a 1889x1280 canvas), a second tap on
it locked and a third cleared every pad, the portrait/landscape button
threw away hand edits, a locked diamond dragged inward did not move, and
Crop | Pad looked like two more ratio chips.
- A chip is lit only while the canvas (crop plus padding, before Align)
has its ratio; the pick (properties.ausboss_fit_aspect) is dropped when
a gesture leaves it, so a new clip is only fitted to a shape the canvas
still has. Without a picture a tapped ratio waits for the first one.
- Tap = reach the ratio (pad, or crop with Fit on crop), tap the lit one =
back to the whole picture. No other chip state.
- Holding the shape is a padlock button (ausboss_aspect_lock): drags keep
the lit ratio, or the custom one, from the moment you grab a handle.
- The orientation button turns the shape: pad mode pads the current crop
to the turned ratio, crop mode turns the crop box about its centre.
Nothing to turn (no picture, untouched, square): only the labels turn.
- Custom / Source caption, and the real ratio on the size line.
- Fit is a segmented pill; switching it re-reaches a lit ratio.
- Rotation keeps a lit ratio (and a held custom one).
- Dragging the picture where the crop spans the source slides it inside
its padding (slidePadding).
- The editor's Ratio list applies at once; Hold shape is the padlock.
Saved properties and widget values keep their names and meaning; an old
locked chip opens with the padlock on. Pure rules in transform_geometry
(canvasSize, aspectMatches, turnAspect, turnedCrop, padAround,
slidePadding, ratioLabel, isUntouched) with node:test coverage.
The stage could be squeezed to about 130 px on all three nodes: the
declared floors counted the rows around it short, and the Clip node's rows
alone take 450 px. A padded portrait clip ended up a sliver in a 16:9
canvas.
- The stage's floor follows its width (stageHeightForWidth: 0.6 x width,
200 to 340 px). The panel's minimum is the measured rows around the
stage plus that floor, so a corner drag stops there; before the panel
is on screen a per-kind estimate stands in. A new node opens at it.
- keepStageRoom grows the node to the same floor when a row appears later
(Resize, a wrapping readout), and sets it as the stage's CSS minimum so
Nodes 2.0, which sizes nodes by content, holds it too.
- exactMinWidth and holdVueNodeMinWidth make 330 the real minimum width in
both renderers (the classic corner drag jumped to 434; Nodes 2.0 went
down to 225 and crushed the chip row).
- The source card's "Choose an uploaded ..." line hides once a file is
chosen, which is the room older layouts need for the new floor.
- The LTX 2.3 example moves the two nodes under the Clip node down for its
new height; the contract and a live load find no overlaps.
Every workflow tab in a page shares one server connection, and ComfyUI
files a node's results by node id. A run from one tab that finished while
another tab was open landed on the open tab's node with the same id. On
Video Crop + Rotate + Pad -> Clip that was a second video under the
buttons that grew the node over the one below; Show Text and Seed also
saved the other workflow's text and seed into this one, and Save Video and
Compare replaced their own result.
- The three Crop + Rotate + Pad nodes stand down ComfyUI's image and video
previews (they send no picture of their own), in both renderers.
- suppressCoreVideoPreview sets hideOutputImages, so Nodes 2.0 no longer
draws Save Video's clip a second time under its player.
- shared/prompt_scope.mjs notes which graph queued each run (the queue
call is wrapped for the prompt id). For a result from another tab, the
AusBoss node here gets this tab's own stored result back, every pack
onExecuted handler returns early, and the result is handed to the owner
tab's AusBoss nodes when that tab is open again. Core nodes are left
alone.
Tests: prompt_scope (scope rules plus a policy check that every
onExecuted handler asks isForeignRun first) and core_preview (every
transform kind suppresses both previews; video suppression sets
hideOutputImages) fail on main and pass here.
A transform node is created on the first file in the input folder. When
a workflow saved with an empty loader configured it blank, the node kept
remembering that first file, because a blank source never replaced the
remembered one. Picking the first file then looked like no change: the
workflow's saved rotation, crop and padding stayed and a lit ratio was
not refit. Any other file reset as it should.
The node's first sync and a workflow load now remember what the node
holds, blank included (rememberedSource in transform_geometry.mjs). Only
a blank the user makes, such as Local path before a path is typed, keeps
the last source, so going back to the same file is still no change.
Covers the image node and both video nodes.
Every card and DOM panel was added with options.serialize = false, which
only keeps a widget out of the queued prompt. The frontend saves each
widget whose own serialize is not false and hands saved values back by
position, so each card and panel saved an empty value of its own after the
node's real values, and an input appended to the node later opened holding
it (Stitch Inpaint's seam, Crop For Inpaint's keep_inside). Every DOM widget
now also sets widget.serialize = false, as the frontend's own previews do,
so new saves carry only real values.
Older saves still end in those empty values, and loading them now skips
the cards and panels. On every node that saves by position they come after
all real values (Save Image's card sits mid-list, but Save Image saves by
name), so nothing moves. On frontend 1.53.6 the examples (the 21 from
before the trim and the 7 now) and 14 older saved workflows queue
byte-identical prompts before and after, keep every value, and save and
reopen cleanly.
An input appended later still needs a fallback for those older saves.
tests/saved_widget_values.test.mjs checks that every DOM widget sets the
flag, that the examples keep their values with the panels skipped, and that
each input appended to a node whose older saves end in an empty value is
declared and, as a widget input, has a card resetUnknown fallback that
fires. tests/test_card_fallbacks.py (ComfyUI's Python) checks each fallback
is the input's own default. AGENTS.md states the rule.
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.
- 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 Crop + Rotate + Pad editor gains Cancel beside Save & close; Escape
now cancels. Changed edits ask first (shared js/shared/discard_prompt.mjs)
and Discard restores the node to its state when the editor opened. The
Workflow Note editor asks before Cancel, Escape or a backdrop click drops
edits, and a JSON edit carried back to the form no longer outlives Cancel.
A native select draws its option list itself, so hovering a name gave
nothing to go on: you picked by filename alone. Load Image + Pad, Load
Video and the crop / rotate / pad editors now open a list that shows the
hovered picture, or plays the hovered clip muted with its size and
length, and filters as you type; arrow keys, Enter and Escape work.
The picker (js/shared/media_picker.mjs) only reads the combo widget's
options and writes the chosen value back, so saved workflows, undo and
the API are unchanged. Pure parts live in media_list.mjs with tests.
The image node had a feather on/off checkbox and no fill colour on its
face while the video nodes showed a fill swatch and a feather amount,
and only the image node had a full Reset. All three now show the same
canvas row (fill, feather in px, and the resize budget where the node
has one) and the same Open editor | Reset crop | Reset buttons.
Four extensions each carried their own copy of the toast-or-console
fallback, transform upload failures used a blocking alert(), and a
failed video drop onto Load Video only logged to the console. They all
go through showToast now, so every message looks and behaves the same.
The quick Reset under Image Crop + Rotate + Pad promised rotation, crop
and padding but ran the editor's full reset, which also restored fill,
feather and Align. It now resets only the shape, the same keys a source
swap resets. The editor's Reset transform tooltip names Align, which it
does reset.
The frontend hides a DOM widget below a zoom threshold unless it opts
out. The video transform nodes mounted their panel through the one
addDOMWidget call without hideOnZoom: false, so zooming out left an
empty box. A guard test now checks every DOM widget in the pack.
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.
Video Crop + Rotate + Pad drew its preview stretched after a resize. The
canvas is CSS-stretched to whatever box the panel is given, but
prepareCanvas() sizes the backing store from the element at DRAW time, so
a box that changes shape without a redraw shows the last frame at the
wrong aspect ratio.
The panel's ResizeObserver was created inside a `kind === "image"` branch,
grouped with the interactive drag handlers because the comment there
reasons about hit-testing. Hit-testing is only half of what it does. The
image node redrew on resize and looked right; the video node - a passive
preview with no other reason to redraw - did not.
Latent until panels started taking the node's leftover height: before
that the box could not change shape without a width change, and a width
change forced a relayout that redrew anyway.
The observer now runs for both kinds; only the drag handlers stay
image-only. Two tests hold the line - one fails if any canvas-painting
panel ships without a resize observer, one fails if this observer is
gated on node kind again. Verified by reintroducing the bug: the second
test fails and names the offending condition.
Dragging a node taller only ever widened its stage. The frontend
arranges widgets in one pass:
if (w.computeSize) -> fixed height, kept OUT of the split
else if (w.computeLayoutSize) -> joins distributeSpace(freeSpace, ...)
else -> one standard widget row
It is an else-if, so a widget declaring both is pinned by computeSize
and its computeLayoutSize is never called. Every stage, player and
filmstrip here derived that fixed height from the node's WIDTH, so the
extra height became dead space underneath. Compare was fixed by hand;
the other six had the same defect.
Adds js/shared/panel_layout.mjs with one fillNodeHeight() that deletes
computeSize and declares a floor with no ceiling - distributeSpace reads
a missing maxSize as Infinity, so the panel takes whatever is left.
Applied to Compare, Save Video, Load Video, Frame Chooser, Load Image +
Pad, Image Crop+Rotate+Pad, and the shared transform panel that Video
Crop+Rotate+Pad mounts. Removing computeSize also removed where those
nodes got their opening height, so Save Video, Load Video and Load Image
+ Pad now state a default; a saved size still wins through onConfigure.
Measured in the browser: all seven grow 1:1 with the node and clamp at
their own floor.
The panel audit now enforces it - a DOM panel is either a stage that
calls fillNodeHeight and never assigns computeSize, or is listed as
fixed by design - so a new panel cannot quietly reintroduce this.
The audit compared every node against ComfyUI core and the major packs;
this lands its outcomes in one branch.
On-node visual editing (the old Codex-pack interaction, rebuilt):
- New Load Image + Pad: image loader with whole-edge pad dragging on the
node canvas, align-to-multiple rounding, and a megapixel target that
rescales the source before padding so the mask seam stays binary.
- Pad Image gains the same handle canvas, fed by an execution-time
snapshot of its input; wireframe with diagnostic empty states before
the first run. Widgets, outputs, and saved workflows unchanged.
- Image Crop + Rotate + Pad's compact panel is now a live stage - crop,
pad, and rotate handles on the node, one shared implementation with
the full-screen editor so the two surfaces cannot drift.
Feature gaps closed against the ecosystem:
- Crop For Inpaint: target_megapixels, selectable rescale_algorithm
(recorded in the stitcher for the paste-back), and extend_* inputs
that grow the frame itself for outpainting.
- Save Video: format choice - mp4 h264, mp4 h265, webm vp9 with Opus
(resampled to 48 kHz); CRF drives all three.
- Load Video: every_nth thinning (fps output divides to match) and a
max_frames cap that ends the decode early.
- LoRA Loader: saved stack templates behind a bar button, and a console
warning when a LoRA's base-model family disagrees with the checkpoint.
- LM Studio Chat: history in/out for multi-turn chains and a json_schema
widget for structured replies.
- Drop Shadow: multiply blend and a shadow_mask output. Align Image:
offset outputs for un-aligning after sampling. Color Match:
first_frame reference mode as a one-node video flicker fix.
Removed (backed up in git history): Select Frame Range and the Video
Bundle family - core ImageFromBatch and the core VIDEO wire now cover
both jobs. Dead NODE_ID module variables cleaned out of every node file.
455 Python tests, 216 JS tests, validate_nodes.py green on 19 node files.
The rotation knob previously anchored to axis-aligned bounding boxes, so
under rotation or padding it floated in space, visually attached to
nothing. It now rides the actual top-right corner of the rotated image
quad (short stem, offset outward along the corner's radial direction)
and orbits with the image as the angle changes - the grab-the-object's-
corner model design tools use. Verified at 0/30/-60/135 degrees: the
drawn knob sits exactly at the computed rotated-corner position.
Feather quality of life: default is now 24 (a no-op until padding or
rotation creates generated area), and the slider is paired with a number
box with single-step arrows for granular control; both stay in sync and
values above the slider range remain typeable up to 4096.
The sidebar Preview now renders the feather fade live: it builds the
generated-area mask (padding, rotation corners, source alpha), blurs it,
draws it twice to match the engine's edge-pinned ramp, tints it with the
fill color, and overlays - so tweaking feather shows the real blend as
you type. Verified: preview pixels near the crop edge move measurably
toward the fill color as feather increases.
Implements the mocked-up sidebar refinements: each section header now
carries a small marker matching its on-canvas handle (cyan square for
crop, green rotate knob, orange diamond for padding), teaching the
editor's color language without explanatory text, and a Preview section
at the bottom of the sidebar renders the actual output composite - fill
background, rotated source clipped to the crop, padded canvas, no
overlays - live while dragging any handle.
Browser-verified: all three markers render, sections appear in order
(Crop, Rotate, Padding & mask, View & reset, Preview), and the preview
canvas resizes and repaints when rotation and padding change, with fill
color in the padding corners and image content in the center.
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.
Each playback tick awaits a frame fetch, and a Pause press usually lands
during that await. The tick only checked for cancellation at its start,
so after the await it re-scheduled itself anyway - a zombie loop the
button could no longer control, since the toggle keyed off the constantly
replaced timer handle. Repro was play, pause, play: from then on pause
presses landed mid-await and were overwritten.
Playback state is now an explicit `playing` flag (the toggle's single
source of truth) plus a session counter; ticks re-check both after the
await and die if the session changed, covering both pause and rapid
pause-then-play. Verified in a live browser with a deterministic harness
that clicks Pause at the exact moment a frame fetch starts: both race
cycles stop cleanly, no frame advancement while paused across 3s of
sampling, and normal play/pause still works.
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).