diff --git a/.claude/skills/ausboss-node-brand/SKILL.md b/.claude/skills/ausboss-node-brand/SKILL.md index 620f4a8..23b59d5 100644 --- a/.claude/skills/ausboss-node-brand/SKILL.md +++ b/.claude/skills/ausboss-node-brand/SKILL.md @@ -63,6 +63,71 @@ the `AUSBOSS_NODES_` prefix, so they stay uncolored on purpose. - Node faces stay compact. Big interactions get a fullscreen editor or an inline mode — never a node with a huge fixed minimum size. +## Node faces: the widget card + +The classic LiteGraph widget — a full-width rounded row with an arrow at +each end — is not the pack's look. Every public node whose face is made of +plain widgets gets an entry in `js/widget_cards/index.js`; `mountWidgetCard` +(`js/shared/widget_card.mjs`, pure decisions in `widget_card_math.mjs`) +hides the standard widgets and mirrors them in one compact DOM card. The +widgets underneath stay the single source of truth, so save/load, undo, the +API format and links never change. + +The grammar, as ausboss signed it off in September 2026: + +- **Layout.** One card per node, a label column (30 %, muted) and a control + column. Rows are 26 px; captions (`section`) and disclosures (`group`, + state in `node.properties.ausboss_show_`) fold the rarely touched + settings away — Crop For Inpaint opens on six rows, its targets and + extends behind "Target size, extend". Rows that only mean something in + one mode (`when`) show in that mode. No captions on plain number rows. +- **Numbers** are `makeScrubInput` boxes: centred value, chevrons on the + right, the unit (`px`, `MP`, `×`) inside the box ahead of the chevrons in + a fixed 22 px slot that every single-field row reserves whether it has a + unit or not — that is what keeps the numbers on one centre line; a unit + outside the box, or only in some boxes, was rejected because it nudged + the numbers apart. +- **Choices**: ≤ 4 short labels → a segmented pill (`off | guided | matting`), + otherwise a select with readable labels (`Width × height`). +- **Booleans** are a two-segment pill as wide as the other controls, + `off` on the left, the on-state on the right, with short state names when + they help (`off | embed workflow`, `every step | once per run`) and the + explanation on the tooltip. A small 30 px switch inside a card was + rejected as out of scale; the only small switch in the pack is the + preview bar's, which is meant to be discreet. +- **Strings** are text fields; a multiline prompt is a `kind: "textarea"` + row (`grow: true` takes the node's spare height) so a prompt node is one + card, not a card under two loose text boxes (Krea 2 Encode). +- **Hex colors** get a swatch plus the text. +- **One linkable widget per row.** The frontend's Widget Input Socket model + (RFC #9, frontend ≥ 1.10.4; "convert to input" is gone) draws a widget's + socket at the widget's own row and only while a link is dragged, hovered + or connected. The card lends each hidden widget its row's position, so a + link dropped on a row lands on that row's widget — but two widgets on one + row would put two sockets on one pixel, which nobody can aim at (this + shipped once and was caught on a screenshot). So a `pair` row is only + allowed with `top: true`, which lifts the pair's sockets into the node's + slot column under the real inputs and greys the field when linked (Image + Resize width/height, Frame Interpolate's rates, Save Video fps/crf). + Everything else is one widget per row, and a value that exists to be + wired is a backend `forceInput` socket (Math Expression `a`/`b`/`c`, + typed `"FLOAT,INT"` so either kind of number wires in). +- **Preview nodes** (Select Frame, Mask Refine, LaMa Inpaint) put a thin bar + between the card and the picture: the node's tools (AUTO) on the left, a + small `PREVIEW` switch on the right; off, the picture's box is gone and the + node is shorter, and the backend writes no temp file. A toggle inside the + picture box was rejected — the point is to remove the box. +- **A node that loaded shorter than its card grows on load** + (`ensureNodeMinHeight`, `js/shared/panel_layout.mjs`) so old workflows + never clip a panel. +- Save Image has its own card (`js/save_image/`) built from the same + grammar: folder + browse, filename with a linked tag, live path preview, + tag chips, format pill, embed switch. + +What ausboss asked to remove, so it does not come back: offset outputs on +Align Image, captions under Crop For Inpaint's rows, the small switch, units +outside the box, and typed boxes for values that are only ever wired. + ## Settings conventions - Ids are `AusBoss..`; register declaratively via the @@ -106,7 +171,14 @@ the `AUSBOSS_NODES_` prefix, so they stay uncolored on purpose. the keyboard, and Shift always means the fine step. Build them with `makeScrubInput` from `js/shared/scrub_input.mjs` (the LoRA loader's strength box is the reference feel); a bare `` in a - panel is a bug. + panel is a bug, and so is a classic number widget left on a finished + node face. Card scrubs derive their steps from the widget: the fine step + is the widget's own increment, the coarse step is 0.05 for a 0..1 float, + ten increments for a wide float, 8 for an integer with a range past 2048. +- Links must be aimable. Before calling a face done, drag a real link onto + every row and socket and look at the screenshot (`docs/live_testing.md`); + a check that only reads which input got the link proves nothing about + whether a person could have hit it. - A click on empty panel space must fall through (no `preventDefault`) so the node stays draggable from its body. - Wheel and middle-click belong to the graph (zoom/pan) unless the widget is @@ -140,3 +212,7 @@ node --test tests/*.test.mjs Then the full checklist in [docs/adding_a_node.md](../../../docs/adding_a_node.md): restart ComfyUI, watch the banner, check `/object_info`, queue an API graph, load the example workflow, and hard-refresh the browser after JS changes. +For anything visual, the headless-Chrome harness in `scripts/dev/` and the +recipe in [docs/live_testing.md](../../../docs/live_testing.md) produce the +screenshots ausboss reviews; save them under `_scratch/node_screenshots/` +with clear names (`__.png`). diff --git a/.comfyignore b/.comfyignore new file mode 100644 index 0000000..0ef6c8b --- /dev/null +++ b/.comfyignore @@ -0,0 +1,33 @@ +# Files comfy-cli leaves OUT of the Registry archive (gitignore syntax; the +# packer zips git-tracked files minus these). Runtime code, node help, +# example workflows, README, LICENSE and pyproject stay in. Everything below +# is development-only: it added nothing for a user and gave the Registry's +# security scan test fixtures and tooling to flag. +# +# scripts/release_preflight.py checks this file keeps covering these paths +# and never swallows a runtime one (js/docs/ is node help and must ship). + +# tests and offline tooling +tests/ +scripts/ + +# CI and repository automation +.github/ + +# agent instructions and developer docs (docs/ at the root only) +AGENTS.md +CLAUDE.md +.claude/ +.agents/ +/docs/ + +# editor and working files +.gitignore +.comfyignore +todo.md +todo +workflow_ideas.md +*.swp +.*.swp +*.kate-swp +.*.kate-swp diff --git a/.github/workflows/registry_status.yml b/.github/workflows/registry_status.yml new file mode 100644 index 0000000..cbd8b70 --- /dev/null +++ b/.github/workflows/registry_status.yml @@ -0,0 +1,37 @@ +name: Check Registry approval + +on: + workflow_run: + workflows: ["Publish to Comfy registry"] + types: [completed] + workflow_dispatch: + inputs: + version: + description: "Version to check; blank uses pyproject.toml" + required: false + type: string + +permissions: + contents: read + +jobs: + registry-status: + if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.workflow_run.head_sha || github.sha }} + persist-credentials: false + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + - name: Verify exact published version is Active + env: + AUSBOSS_TARGET_VERSION: ${{ inputs.version }} + run: | + if [ -n "$AUSBOSS_TARGET_VERSION" ]; then + python scripts/registry_status.py --version "$AUSBOSS_TARGET_VERSION" + else + python scripts/registry_status.py + fi diff --git a/.github/workflows/validate_action.yml b/.github/workflows/validate_action.yml new file mode 100644 index 0000000..a994440 --- /dev/null +++ b/.github/workflows/validate_action.yml @@ -0,0 +1,29 @@ +name: Validate release candidate + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + offline-checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + - uses: actions/setup-node@v7 + with: + node-version: "24" + - run: python scripts/validate_nodes.py + - run: python scripts/release_preflight.py + - run: python tests/test_registry_status.py + - run: node --test --test-isolation=none tests/*.test.mjs diff --git a/.gitignore b/.gitignore index 74511e1..83006e3 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,12 @@ ausboss_presets.json # Local working notes ausboss_test_checklist.md ausboss_idea_backlog.md +/todo +/todo.md +/workflow_ideas.md +/docs/push_readiness.md +/CLAUDE_KREA_WORKFLOW_HANDOFF.md .claude/skills/comfyui-node-api/SKILL.md + +# Local duplicate of the versioned .claude brand skill +/.agents/skills/ausboss-node-brand/ diff --git a/AGENTS.md b/AGENTS.md index 3d08c84..399a876 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,10 +112,44 @@ example_workflows/ # example workflows (regular workflow JSON, not API JSON) use `[project.optional-dependencies]` and fail soft at runtime. - Frontend JS never assigns prototype callbacks directly — use `chainCallback` from `js/shared/index.mjs`. -- Numeric fields in pack panels are scrub controls, Adobe-style: drag the - value to scrub, click to type, chevron arrows step, Shift is always the - fine step. Use `makeScrubInput` from `js/shared/scrub_input.mjs` — never - a bare ``. +- Every INT and FLOAT a public node exposes reaches the user as a scrub + control, Adobe-style: drag the value to scrub, click to type, chevron + arrows step, Shift is always the fine step. Use `makeScrubInput` from + `js/shared/scrub_input.mjs` — never a bare ``, and + never a classic canvas number widget on a finished node face. Units + (`px`, `MP`, `×`) ride inside the box in a fixed-width slot that every + single-field row reserves, so the numbers down a card share one centre + line. Known, deliberate exceptions: the Seed card's seed (typed) and Load + Video's trim timecodes (typed). +- **Node faces are widget cards.** A node whose face would be classic + LiteGraph widgets (full-width rows with an arrow at each end) gets an + entry in `js/widget_cards/index.js` instead: `mountWidgetCard` from + `js/shared/widget_card.mjs` hides the standard widgets and mirrors them + in one compact DOM card — numbers → scrub, ≤ 4 short choices → segmented + pill (else a select), booleans → an off | on pill as wide as the other + controls (never a small switch inside a card), strings → text field, + multiline → `kind: "textarea"` (`grow: true` takes the node's spare + height), hex colors → swatch. Rows can depend on other values (`when`) + and fold behind a disclosure (`group`). The widgets underneath stay the + single source of truth (save/load, undo, API, links). Design detail and + the reasons behind it: `.claude/skills/ausboss-node-brand/SKILL.md`. +- **One linkable widget per row.** The frontend (≥ 1.10.4, "Widget Input + Socket" RFC #9) gives every widget an input socket drawn at the widget's + own row, only while a link is dragged, hovered or connected; "convert to + input" no longer exists. The card lends each hidden widget its row's + position, so two widgets on one row would stack two sockets on one pixel + and nobody could aim. A `pair` row is therefore only allowed with + `top: true`, which lifts its sockets into the node's slot column under + the real inputs (Image Resize width/height); everything else is one + widget per row. A value that is meant to be wired rather than typed is a + backend `forceInput: True` socket (Math Expression `a`/`b`/`c`), and a + multi-type string such as `"FLOAT,INT"` accepts either kind of link. +- Preview-carrying nodes (Select Frame, Mask Refine, LaMa Inpaint) share + `js/input_preview/`: a thin bar (node tools left, a small `preview` + switch right) above the picture, the picture gone and the node shorter + when the switch is off, backed by an optional `preview` BOOLEAN input + that also skips the temp file. A new node with a result picture reuses + it rather than growing its own. - A DOM panel that shows a stage/preview claims the node's free height via `fillNodeHeight` from `js/shared/panel_layout.mjs` — never a hand-rolled `computeSize`, which pins the panel and leaves dead space when the node is @@ -138,12 +172,25 @@ in `.claude/skills/ausboss-node-brand/SKILL.md`. ```bash python scripts/validate_nodes.py +node --test tests/*.test.mjs ``` -Then restart ComfyUI fully, watch the AusBoss banner for failed modules, -confirm the node appears in `GET http://127.0.0.1:8188/object_info`, queue a -tiny API graph, and load its example workflow. After JS changes, hard-refresh -the browser tab (Ctrl+Shift+R). +Then restart ComfyUI fully (a changed `INPUT_TYPES` is only served after a +restart), watch the AusBoss banner for failed modules, confirm the node +appears in `GET http://127.0.0.1:8188/object_info`, queue a tiny API graph, +and load its example workflow. After JS changes, hard-refresh the browser +tab (Ctrl+Shift+R) — the frontend caches `.mjs` modules aggressively. + +Frontend work is proven on a real canvas, not by reading code: the +headless-Chrome harness in `scripts/dev/` (recipe, gotchas and the +screenshot convention in `docs/live_testing.md`) creates nodes, drives real +mouse drags, and clips screenshots into `_scratch/node_screenshots/` for +review. A link-drop test that only checks which input got the link is not +enough — look at the picture and ask whether a person could have aimed +there. + +After changing any `js/shared/*.mjs`, `nodes/_*_helpers.py` or their tests, +refresh the lab's vendored copies (see the top of this file). ## Releasing @@ -168,28 +215,33 @@ A release, when explicitly asked for: `keywords`: the registry shows the description verbatim and ComfyUI-Manager search matches against it, so it must name the actual nodes — never a generic blurb. -4. Merge to main and watch the publish run in the Actions tab. The - push-triggered run has failed before (the 1.2.0 merge's run died and - the version only published because someone noticed): if it fails, - re-run it by hand — Actions → "Publish to Comfy registry" → Run - workflow — and treat a red run as an unpublished release until proven - otherwise. +4. Merge to main only when the release is ready, then watch the publish + run in Actions. If it fails, inspect both the job and the Registry + before retrying: an upload may have succeeded before a later step + failed, and published version contents cannot be overwritten. Re-run + "Publish to Comfy registry" only after confirming that the upload did + not create the version. 5. Verify the version AND ITS STATUS on the registry: ```bash - curl -s https://api.comfy.org/nodes/ausboss-nodes/versions | python3 -c "import json,sys; [print(v['version'], v['status']) for v in json.load(sys.stdin)]" + python scripts/registry_status.py ``` - `NodeVersionStatusActive` is the only status users can see. A fresh - version usually lands as `NodeVersionStatusFlagged` — the registry's - automated security scan holding it for human review — and a flagged - version is INVISIBLE in ComfyUI-Manager's "Select Version" picker and - sets no "last update" on the listing, so the release is not actually - out until the Comfy team clears it. Every version this pack has - published (1.1.0, 1.1.1, 1.2.0) sat flagged; ask for review through - the Comfy Registry / Comfy-Org channels (their Discord, or the - registry's support contact) rather than re-publishing, and re-check - the status afterwards. + Require `NodeVersionStatusActive` for the exact new version before + calling the release available in Manager. The script reads + `include_status_reason=true`; exit 0 means Active, 2 means not approved, + and 1 means the status could not be established. The separate "Check + Registry approval" Action runs after successful publication and can + be dispatched manually to recheck without publishing again. + + As of 2026-09-07, versions 1.1.0, 1.1.1, 1.2.0, and 1.3.0 are Banned, + not merely Flagged. The older decisions identify LM Studio's unrestricted + endpoint; 1.2.0/1.3.0 carry a broader code-execution verdict. Review is + tracked in https://github.com/Comfy-Org/registry-backend/issues/216. + Resolve the recorded finding and request review of a corrected candidate; + a fresh version number alone does not resolve a ban. Confirm the current + issue history before posting, and obtain explicit authorization to send + any external follow-up unless it was already authorized in the session. ## Phase 2: porting an existing node diff --git a/CHANGELOG.md b/CHANGELOG.md index e33d5a8..0c95618 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,292 @@ All notable changes to ComfyUI-AusBoss are documented here. +## 2.0.0 - 2026-09-07 + +- **Breaking: LM Studio Chat is removed.** The Registry identified an + unrestricted server-side request through its workflow-controlled endpoint. + The node, HTTP helper, frontend, help page, and tests are removed. Existing + workflows using `AUSBOSS_NODES_LmStudioChat` must replace that node; a Text + node can supply a fixed prompt where live chat is unnecessary. Other public + mapping keys remain unchanged. +- Added Resolution Master, Seed, Workflow Note, Run Timer, and Video Crop + + Rotate + Pad → Clip, alongside 15 organized example workflows. +- The README now includes eight visuals using actual editor screenshots and + tested renders, including an animated video outpaint comparison. +- Release PRs now run offline validation and frontend tests in Actions. A + separate Registry approval check reports the exact version's status and + review reason after publishing, with manual rechecks that do not republish. + +- Klein 9B Outpaint now samples the encoded padded image through a noise mask and uses a 64 px feather. Controlled comparisons showed a cleaner seam than the previous empty latent and 24 px feather; Stitch Inpaint still preserves the protected source exactly. +- Completed the public-pack readiness review: grouped README sections cover every public node; all example graphs have setup notes, stage groups, and matching thumbnails. Added the model-free Image and Video Transform example for both editors and Align Image. Corrected installed model selections, literal line breaks in the LoRA example note, card-driven layout overlaps, and the tour's sample and timing instructions. +- Load Image + Pad now uses a widget card for source/upload, fill, feather, alignment, and budget, with individually linkable exact padding controls. Text uses a growing multiline card, and Krea 2 Outpaint Model Patch exposes both reference placement and KV caching in its card. +- The watermark example keeps SAM3 detection while removing rgthree presentation helpers and the Easy Use frame-count display. Its setup card names ComfyUI-RMBG, and SAM3 unloads between branches to leave GPU memory for LaMa. +- Release preflight now checks saved links in both directions, stage containment, title-bar overlaps, and setup-note formatting. Backend tests have a process-isolated runner so ComfyUI stubs cannot leak between test files. + +- **LoRA Loader 🆎** now stops on a missing LoRA by default: an enabled row whose file cannot be found fails validation before sampling with an error that names the file and says where the switch is, instead of warning and rendering without it — what scripted and agent-driven runs need. Gear menu → **Stop on missing LoRA** (API input `on_missing`, `"error"` by default or `"skip"`) restores the old warn-and-skip behaviour per node; graphs saved before the input existed get the default. + +- Added rendered MiniMax H3 Text to Video and First + Last Frame examples, plus a model-free Resolution Master example. Corrected H3 duration rounding to never undershoot the requested seconds. Tested and corrected the Klein PixaOutpaint prompt and conditioning; the protected source interior remains pixel-exact. + +- **Resolution Master 🆎** joins the public pack: an explicit orientation toggle, ratio chips, canvas handles, sub-1 MP budgets and an empty latent output. Corrected zoomed drags, bounded extreme ratios, linked-axis controls and restore behavior; removed the external font request. + +- **Five new showcase workflows.** `Klein 9B Inpaint` (a painted mask + through Mask Refine 🆎 and Crop For Inpaint 🆎, Klein 9B edits only that + crop, Stitch Inpaint 🆎 puts it back), `Klein 9B Edit` (Image Resize 🆎 + sets the 1 MP working size and Image Size 🆎 keeps the output the + source's shape), `Klein 9B Outpaint` (Load Image + Pad 🆎 on any side at + once, PixaOutpaint in the LoRA Loader 🆎, a padded reference and masked + Flux 2 starting latent), + `MiniMax H3 Image to Video` (Image Resize 🆎 to H3's 768 px canvas, + Float 🆎 + Math Expression 🆎 for the model's 17k+5 frame grid, the turbo + LoRA model-only, Save Video 🆎 with the model's own audio) and `Krea 2 + Prompt from Image` (core Text Generate on the Krea 2 text encoder, Show + Text 🆎, the prompt wired into Krea 2 Encode 🆎). Each opens with a + Workflow Note 🆎 and ships a thumbnail; a masked copy of the sample + picture joins `example_workflows/inputs/`. Every node in the pack now + appears in at least one example, including the node tour. + +- **Krea 2 outpaints on every side at once.** The `Krea 2 Outpaint` + example is rebuilt around yijunwang2's **AnyPaint** LoRA + (`krea2_anypaint_rank32`). The old one-axis rule was never a Krea 2 + limit: the patch's registered placement is the contract of the same + author's Registered Outpaint LoRA, which was trained on a source spanning + one whole canvas axis - and the shipped example did not load that LoRA at + all. AnyPaint is trained the other way round: the grey-padded canvas is + the reference, spread over the whole frame, and the sampler's noise mask + keeps the known pixels, so left, right, top and bottom can grow in one + pass. Krea 2 Outpaint Model Patch 🆎 gained an optional `placement` + input for it - `source rectangle` (the default, the old behaviour) or + `whole canvas` - and its help page explains which LoRA wants which. + Saved graphs load unchanged. + +- **Outpaint seams: no more dark band, no more corner diagonals.** A + four-side outpaint used to show a faded band along the source's edges + and 45° lines running out of its corners. Measured on the pier sample: + the model paints the new area a few percent off the source's tone on + every side (fill colour makes no difference), the feathered sampler mask + smears that step into the band inside the source, and the pad feather + merged its per-side ramps with a maximum, whose iso-lines crease at the + corners and print as diagonals. Stitch Inpaint 🆎's `color_match` now + reads the drift ACROSS each seam - the generated pixels just outside + against the original pixels just inside, line by line - instead of + inside the mixed band, and blends the sides by distance instead of + picking the nearest, so the correction turns the corner smoothly and the + band gets exactly the share the sampler gave it. Load Image + Pad 🆎's + feather (and every stitcher built from it) now unites its ramps instead + of taking their maximum: identical along a side, bilinear in the corner. + Both outpaint examples run with `color_match` 1 again; their seam steps + drop from 2-6 % to under 1 % of luminance. + + +- **Video Crop + Rotate + Pad → Clip 🆎.** The transform nodes' third + member: one rotate, crop and pad applied to every frame of a trimmed + video, with Load Video's `start_seconds` / `end_seconds`, `every_nth` + and `max_frames`, and the same outputs (frames, the window's audio, + frame count, fps, size, duration) plus the generated-area `mask` — so a + clip can lose a border or a tilt, or grow the black bands a video + outpaint paints into. It shares the frame picker's editor (the timeline + picks the preview frame; scrubbing never re-runs the clip) and gains the + image node's megapixel resize, which the editor now shows for any node + that carries those widgets. Frames go through the transform in chunks + with the queue's progress bar and cancel serviced between them. It also + emits a **stitcher** (a ninth output, after the existing eight) built from + the final resized frames, so Stitch Inpaint pastes the source back over a + generated clip without a Crop For Inpaint node; the editor's right sidebar + gained an **Inpaint & Stitch** section - blend ramp, a stage overlay of + where the paste lands, and grow behind a disclosure - kept separate from + the padding feather. The feathered paste mask is now one shared helper + for every stitcher producer. A **Snap** select on the trim strip + (`frame_snap`: free / 8n+1 / 4n+1) drops trailing frames to a count the + video model keeps, so `frame_count`, the audio window and the stitcher + match the clip that comes back; the showcase workflow wires the clip's + size and count into the empty latent and snaps at 8n+1. + +- **Transform nodes: drop a video, pad to a format in one tap, a sharp + preview at any zoom, trim without losing the playhead.** A video file + dropped onto Video Crop + Rotate + Pad (frame or clip) uploads and + becomes the source, as core's upload widgets do for their nodes. A row of + format chips right under every transform preview (16:9, 9:16, 1:1, 4:3 + ...) pads the whole source to that aspect with centered fill bands - the + editor's Pad to aspect without opening it; the lit chip clears it again. + The node preview's backing store now follows the graph zoom (up to 4x), + so zooming in on the node no longer shows a blurry, pixelated stage. + Dragging an IN/OUT handle now peeks at the frame under the handle and + returns to the playhead on release, instead of moving the scrub bar and + the saved preview position along with the trim. + +- **Stitch Inpaint takes a shorter generated batch.** A stitcher built + from more frames than came back is trimmed to the leading ones with a + console note instead of failing after the run - video models keep 8n+1 + or 4n+1 frames and drop the tail. More inpainted frames than source + frames is still an error. + +- **Save Image redesigned as one card.** Folder with a Browse into the + output folder, Filename, a live path preview that turns into the first + file written after a run, the name tags as chips - Counter (on, the next + free number for that stem, never a collision), Date, Time, Size and + Batch # - PNG / lossless WebP (new) / lossless JXL pills, and an Embed + workflow switch. Two link-only inputs: `filename` takes the exact name + from upstream (its image extension swapped for the chosen format's, a + blank value stops the run rather than inventing a name) and + `caption_text` writes the paired .txt. The old widgets stay underneath in + their saved order, so existing workflows load as they were and a legacy + `exact_name` shows in the field tagged *exact*. Local names lose core's + trailing underscore (`shot_00001.png`, not `shot_00001_.png`); numbering + restarts at 00001 beside the old files without touching them. + +- **Align Image drops its offset outputs.** `offset_x` / `offset_y` were + never wired in practice; the node stops at image, width, height. A saved + workflow that linked them loses those two links. + +- **Select Frame, Mask Refine and LaMa Inpaint previews can be switched + off.** A thin bar sits between the settings and the picture - the node's + tools (Mask Refine's AUTO) on the left, a small `preview` switch on the + right. Off, the picture's box is gone and the node is that much shorter; + the switch stays where it was, and the node writes no file to the temp + folder (a new optional `preview` input carries the choice). + +- **Units live inside the scrub box** (`px`, `MP`, `×`), ahead of the + chevrons, and every single-field row in a card reserves the same slot + for one whether it has a unit or not, so the numbers down a card share + one centre line - a `px` no longer nudges its number left of the row + above. + +- **Widget cards: the classic canvas widgets are gone from every node + face.** Image Resize, Color Match, Stitch Inpaint, Crop For Inpaint, + Mask Refine, Save Image, Save Video, Load Video, Align Image, Frame + Interpolate, Math Expression, Select Every Nth, Split Batch, Select + Frame, Merge Batches, Integer, Float, LaMa Inpaint, Krea 2 Encode and + the Krea 2 Outpaint Model Patch now carry one compact card in place of + the full-width rows with an arrow at each end: numbers are the pack's + scrub control, short choices a segmented pill, long ones a select, + booleans an off | on pill as wide as the other controls, hex colors a + swatch. Rows that only mean something in + one mode (a resize target's size, a pad color) show in that mode; the + rarely-touched settings fold behind one disclosure (Crop For Inpaint's + targets and extend, Save Image's exact-name and folder, Mask Refine's + More). The widgets underneath are untouched - saved workflows, the API + format and widget-to-input links work as before, and a row driven by a + link dims out. Shared in `js/shared/widget_card.mjs`. + +- **Links still land on card rows.** The frontend only draws a widget's + input socket while a link is being dragged or once it is connected, at + the widget's own place - which a hidden widget no longer had. Each row + now lends its position to the widget it stands for: drag a link over a + card and a socket appears beside every row that can take it, drop it on + the row (the socket, the label or the field itself) and that widget is + driven by the link, exactly as with the classic widget. One socket per + row, so no row holds two linkable fields: Image Resize's width and + height, Frame Interpolate's two rates and Save Video's fps and crf keep + their side-by-side layout but their sockets sit with the node's inputs + (under `image` / `mask`), where a link greys the field out like the old + converted input; Crop For Inpaint's target and extend values, Mask + Refine's levels and Load Video's size and every-nth / limit are single + rows now, one socket each. + +- **Math Expression: `a`, `b`, `c` are sockets.** The three values arrive + on the node's left edge and take a `FLOAT` or an `INT` (an image width + wires straight in); the card is the expression alone. An unwired value + reads as 0. A workflow that had typed a constant into `a`, `b` or `c` + should carry it in the expression instead - those boxes are gone. + +- **Krea 2 Encode is one card.** The prompt, the negative prompt and the + VLM reference switch sit in a single panel; the prompt box takes the + node's spare height when it is dragged taller. The node is unchanged + underneath: a plain two-prompt encoder without a VAE and reference, and + an edit/outpaint encoder with them (checked both ways on a Krea 2 turbo + render). + +- **A node that loaded shorter than its new card grows on load.** A + workflow saved before a card or the preview bar existed kept the old + node height, which squeezed the panel under its floor and clipped it + flat (LaMa Inpaint's preview box); the node now takes at least the + height its widgets ask for. + +- **LoRA Loader: the bar scale follows the enabled rows.** Switching a + row off hands the shared strength scale to the strongest row still in + play; the dimmed row's own bar clamps at the edge meanwhile. + +- **Workflow Note 🆎: the card for sharing a workflow.** A big title, a + Markdown how-to, the models the workflow needs with Download buttons + grouped under the `📂 ComfyUI/models/` they belong in, the node + packs it depends on, and the author's links — the note every shared + workflow carries, without a second pack. Every model file and node pack + on the card is checked against the install that opened it (teal dot and + an *installed* pill when found, subfolders included; red dot and the + Download button when not), so the person who downloaded the workflow + sees what is left to do before the first run. A pencil opens the + editor (form or raw JSON; *Detect from this workflow* fills the pack + list from the open graph); the Banner layout turns the same node into + a title label. Stored as JSON in one STRING widget; rendered as text, + never HTML; only http(s) URLs become links. + +- **Seed 🆎: the seed that remembers what ran.** One INT wire, one + card: the number (click to type, ⧉ copies), a Random / Fixed / Step + segment that drives the sampler's own after-generate control, and the + buttons a run needs — New seed rolls and pins one, Use last run puts + back the seed the last run actually used (reported by the backend, so + it is right even after Random has already advanced the box) and pins + it, ▾ lists the last eight. The history saves with the workflow. + +- **Run Timer 🆎: a stopwatch for the whole queue.** One black readout + with no title bar, no pack badge, no wires and no padding — the node + is the display, painted by the node itself on the classic canvas, so + it drags from anywhere and resizes by the corner with the digits + scaling along (a DOM panel stands in under the Nodes 2.0 renderer). + Starts on `execution_start`, ticks, holds the total at the end (amber + while running, teal done, red failed); the last time saves with the + workflow and the right-click menu lists the few before it. + +- **Stitch Inpaint: `color_match`.** An outpaint often comes back a step + lighter or warmer than the picture it extends, and the seam shows even + when the content continues perfectly. The new 0..1 control measures + that drift in the feathered band — where the sampler's pixels and the + true pixels overlap, so it reads the model's own shift rather than + comparing unrelated content — per padded side and per line along the + seam (sky and water drift differently), smoothed — and pulls the + pasted region back onto the original's tone before the blend. Source + pixels stay bit-identical. Off by default; the outpaint showcase ships + with it at 1. + +- **Panels hide their storage widgets under Nodes 2.0 too.** The Vue + renderer ignores the classic canvas collapse and filters widgets on + its own `options.hidden` flag, so the Seed card, the Workflow Note, the + LoRA loader and Load Image + Pad all showed their raw storage widget + above the panel there. The shared hide helper now throws both switches. + Run Timer's Nodes 2.0 fallback also drops the renderer's 225px minimum + width and its footer badge, so the readout stays the whole node. + +- **The Registry archive stops shipping the workshop.** A documented + `.comfyignore` keeps tests, offline scripts, CI, agent instructions and + the root docs out of the published zip (comfy-cli's packer honours it), + which also removes the test fixtures the Registry scanner kept flagging. + `release_preflight.py` now checks the file covers those paths and never + swallows a runtime one, parses every example workflow, and pairs each + thumbnail with its workflow; the stale thumbnail generator is gone. + +- **Showcase workflows** in `example_workflows/`, four graphs on + ComfyUI core plus this pack and nothing else. **Krea 2 Studio**: draft, + then refine - the plain Krea 2 Turbo draft goes through a learned 4x + upscaler, Image Resize lands it on a 2.3 MP budget, and a second + KSampler repaints it at denoise 0.15 with the same prompt and seed + (measured against plain resize, learned-only, pixel-pass and + latent-upscale routes on a portrait, a tarot illustration and a street + scene: 0.12-0.2 is clean, 0.3 speckles skin, latent upscale is worst); + Color Match keeps the draft's tone, a gentle core sharpen finishes, + Compare A/B shows final against draft, and every stage bypasses on its + own (each combination verified to execute); about 15 s on a 5090. An + **LTX 2.3 video outpaint** that turns a 9:16 clip into 16:9 - the clip + node pads the black bars an outpaint IC-LoRA paints new scene into, + core LTX nodes only (`LTXV Add Guide` + `Get IC-LoRA Parameters`, no + Lightricks pack), then Stitch Inpaint puts the original frames back + from the clip's own stitcher with `color_match` and Save Video keeps + the clip's audio; about a minute for 97 frames at 1280×704 on an RTX + 5090, with a 9:16 sample clip in `example_workflows/inputs/`. Plus the + Krea 2 one-axis outpaint and a Krea 2 text-to-image with the LoRA + Loader, each with a matching preview JPG and `properties.models` + download metadata on the loaders so ComfyUI's own missing-model check + offers the files too. + ## 1.3.0 - 2026-08-31 - **LoRA Loader: strength bars you can grab.** Every named row paints a diff --git a/README.md b/README.md index bae7758..ef503d6 100644 --- a/README.md +++ b/README.md @@ -10,181 +10,315 @@

-ComfyUI-AusBoss is a curated collection of polished nodes designed to streamline the ComfyUI workflows I use most. Each node replaces repetitive setup with focused controls, compact graph footprints, and only the outputs needed downstream. Larger visual tasks open into dedicated full-screen editors, while experiments and one-off utilities stay out of the public pack. +ComfyUI-AusBoss provides compact image, video, inpaint, and workflow utility nodes. Numeric controls scrub by dragging, click to type, and use Shift for fine steps. Visual tools share the same crop, pad, and rotation controls. Every node has a **?** help card with its inputs, outputs, and usage notes. -## Nodes +![A narrow lakeside pier photo expanded into a wider mountain-and-lake scene with Krea 2 outpainting.](assets/readme/outpaint-showcase.webp) -### Image Crop + Rotate + Pad 🆎 +**Make room for more.** Load Image + Pad prepares the canvas; Krea 2 + AnyPaint generates the extension; Stitch Inpaint brings the original back. [Open the outpaint workflow →](example_workflows/Krea%202%20Outpaint%20%28AusBoss%29.json) -A Load Image-style node with a compact transformed preview and a full-screen editor for precise crop, rotation, padding, fill, mask feathering, and output-size alignment. The preview is itself a live stage — drag the crop handles, padding diamonds, and rotate knob directly on the node, and open the editor when you want zoom, pan, and the numeric sidebar. - -### Video Crop + Rotate + Pad → Frame 🆎 - -Picks **one frame** out of a video from an uploaded input file or local path, and applies the same transform controls as the image node. The output is a single image, not a clip — the full-screen editor's long timeline, playback and exact frame steps are there to *find* the frame, not to trim a range. - -### Select Frame 🆎 - -Returns one unchanged frame from an `IMAGE` batch using a clear one-based frame number. Out-of-range requests report the valid range instead of silently selecting the wrong image, and the panel shows the frame you picked so you can check it without a separate preview node. - -### LoRA Loader 🆎 - -A stacked multi-LoRA node. Each row has an on/off pill, a searchable picker (type to filter, arrow keys + Enter to pick; browse view groups by folder and hovering shows the preview image), and strengths you can **drag left/right to scrub** (Shift for fine steps), click to type, or nudge with the little step arrows. A **center-zero strength bar** rides behind each row's name — teal for positive, red for negative, scaled to the stack's largest magnitude — and the name itself is a scrub surface too. The stack-wide toggle (which remembers the mixed setup an accidental click destroys), count, templates, reconnect and settings ride the empty band between the node's input and output slots, and **+ LoRA** stays pinned to the stack's bottom edge however tall you drag the node. A gear-menu action **absorbs every LoRA loader wired into the model chain** — core, rgthree, Pixaroma — into the stack and bypasses the originals; **moved LoRA files resolve by name at run time** and a genuinely missing one warns and skips its row instead of failing the run. The per-row info card shows the LoRA's preview image, base model, trigger words from its file metadata, a one-click Civitai lookup saved beside the model as `.civitai.info`, or your own saved words — click words to toggle them into the deduplicated `triggers` output — plus an optional suggested strength range that tints out-of-range values. The bar's ▤ button saves and applies named templates of the whole stack, and a LoRA that patches nothing on the connected model — the usual sign it was built for a different base model — logs a console warning naming it. CLIP input is optional. - -### Align Image 🆎 - -Snaps width and height to a clean multiple of a number you pick — 16, 32, whatever the model wants. Qwen image models, VAEs, and many samplers behave best on cleanly divisible sizes. `resize` rescales to the nearest multiple, `crop` center-crops down, `pad` replicate-pads up, and the new size comes out as INTs for wiring into latent nodes — plus `offset_x`/`offset_y` for cropping back after sampling. - -### Image Size 🆎 - -Reads an image's dimensions as INTs — width, height, longest edge, shortest edge — so resizes and latents key off the actual image instead of retyped numbers that drift. - -### LM Studio Chat 🆎 - -Sends a prompt (and optionally an image) to a local LM Studio server — or any OpenAI-compatible endpoint — and returns the reply as text. Empty model name means "whatever the server has loaded", reasoning comes out on a separate output so the text stays clean for conditioning — both inline `` blocks and the `reasoning_content` field servers return for hybrid models — and the seed doubles as the re-roll knob. Give reasoning models a generous `max_tokens`: they spend tokens thinking *before* answering, and if the budget runs out mid-thought the node tells you so by name rather than handing back an empty string. Chain the `history` output into another chat node's `history` input for multi-turn conversations, or fill `json_schema` to force a structured JSON reply. Errors are actionable: a refused connection says how to start the server. - -### Color Match 🆎 - -Harmonizes an image against a reference by transferring per-channel LAB mean and standard deviation — the fix for an inpainted or upscaled region that comes back slightly brighter or cooler than the plate it sits in. Optional mask restricts the correction to just that region, and `strength` blends it back toward the original. For video, `reference_mode: first_frame` locks every frame to the batch's own first frame — the one-node flicker fix. - -### Load Image + Pad 🆎 - -A Load Image that opens straight into an outpaint canvas: drag any edge of the final rect drawn on the node to grow that side's padding — the whole edge is the handle, per-side pixel counts ride the bands, and the badge always shows the true output size. The mask covers exactly the padding with an optional **feather** ramped inward across the seam, the canvas rounds up to a clean multiple, and a **megapixel target** rescales the source *before* padding so the mask seam stays crisp at sampler-friendly sizes. Outputs the canvas, the mask, the final width/height as INTs, a `stitcher` for Stitch Inpaint 🆎 that restores the original pixels bit-identically after sampling, and a `reference` — the unpadded source fitted to a small multiple of 16 for reference conditioning. - -### Krea 2 Encode 🆎 - -Encodes a Krea 2 prompt pair and attaches reference latents in one node. Wire a VAE and a reference image and the reference is encoded onto the positive conditioning; leave them unwired and it is a plain two-prompt encoder. The negative comes out of the same node, so a turbo graph at CFG 1.0 is not carrying a second text encode that does nothing. References are fitted to a 384px long edge and a multiple of 16 — the VAE downsamples by 8 and the DiT patchifies by 2, so an odd edge lands on a partial patch. - -### Krea 2 Outpaint Model Patch 🆎 - -Tells Krea 2 **where** the reference sits. Reference latents normally arrive with no position, so the model treats them as a loose style hint and reinvents the content; this registers them into the target grid at the rectangle Load Image + Pad 🆎 records on the `stitcher`, which is what makes an outpaint continue the source instead of painting something adjacent to it. Place it after any LoRA loader and before the sampler. It patches comfy's flux attention internals, so it imports them when you run it rather than at startup — a core change that moves them surfaces as an error on this node instead of quietly dropping it from the menu. - -**Extend one axis at a time.** The source has to span one whole canvas axis: pad left/right and it spans the full height, pad top/bottom and it spans the full width. Padding both at once puts the placement outside what the weights do and the new region breaks up — hard cuts at the old edge, mangled content. Do it in two runs instead. A rounding sliver counts too, so if `canvas_multiple` rounds the other axis up by even a few pixels, lower it or pick one that divides that dimension. The node prints exactly this, with the spare pixels on each axis, when it sees a placement that spans neither. - -### Frame Interpolate 🆎 - -Retimes a clip by **frames per second rather than a whole-number multiple**, so 24 to 30 works as naturally as doubling. Choose a fast `blend` crossfade or optical flow for real motion. Hard cuts are detected and held rather than interpolated across, avoiding the smeared morph other interpolators produce at a scene change. Memory is bounded by planning the work first and streaming results back to the CPU. - -### LaMa Inpaint 🆎 - -Inpaints white mask regions with a TorchScript LaMa checkpoint, preserves pixels where the mask is zero, and processes large video batches one frame at a time to keep VRAM bounded. Its panel streams each finished frame during a video run and holds the result afterwards — one preview surface, not ComfyUI's stacked underneath as well. Place `big-lama.pt` in `ComfyUI/models/lama/`; the node never performs automatic downloads. - -### Crop For Inpaint / Stitch Inpaint 🆎 - -Inpaint only where it matters: Crop For Inpaint grows the mask's bounding box by a context factor and emits the crop, the raw sampling mask, and a stitcher; Stitch Inpaint pastes the result back with a separately feathered blend so every pixel outside the blend region stays **bit-identical** to the original. The stitcher broadcasts across a video batch, so one crop serves a whole clip. An optional `fix_edge_halo` toggle removes the rim that appears when a feathered seam gets blended twice. `target_megapixels` rescales the crop to a sampler-friendly area with a selectable `rescale_algorithm`, and the `extend_*` inputs grow the frame itself for outpainting — the new bands become part of the stitched output. - -### Load Video 🆎 - -Loads a video as frames plus audio with `frame_count`, `fps`, `width`, `height`, and `duration` outputs, plus a lazy core `VIDEO` handle so the clip chains straight into ComfyUI's own video nodes. Its single responsive player includes a two-handle trim timeline: drag **IN** and **OUT** (shown as `h:mm:ss.s` timecodes you can type into), preview exactly that window, and optionally loop it. Only the selected window is decoded, with a memory guard that reports oversized trims instead of exhausting RAM; audio is extracted lazily only when consumed. Optional custom width/height with aspect-preserving single-side mode; `every_nth` thins long clips (the fps output divides to match) and `max_frames` caps the decode outright. - -### Mask Refine 🆎 - -Grows or shrinks a mask, fills enclosed holes, melts jagged edges with a `smooth` control that does not feather, feathers, and rescales the extremes with black/white points — returning both the refined mask and its inverse. Optional `guided filter` and `matting` tiers refine the edge against a guide image when you install the matching extra. The panel previews the refined mask itself, so you can see what a setting did. It opens on `expand` and `blur` alone with the rest behind **MORE**, and **AUTO** sets both from the mask's size — a feather is a fraction of the picture, so what covers a watermark rim at 576px is a smear on a thumbnail. - -### Save Video 🆎 - -Encodes frames — or a connected core `VIDEO` — to mp4 (h264/h265, CPU or NVENC), webm (vp9/av1), ProRes mov, bit-exact lossless ffv1 mkv, gif or webp. The video formats are tagged bt709 (so platforms don't shift your colors), take optional muxed audio, and embed the workflow, so a saved mp4 dragged back onto the canvas restores its graph — `save_metadata` turns that off when you'd rather not ship your prompts. `pingpong` bounces the clip for a seamless loop without repeating the turnaround frames. The responsive in-node player previews the encoded result. Wire `fps` from Load Video to preserve source timing; a connected video brings its own rate and overrides the widget. Encoding runs off the executor thread with per-frame progress, so a long export never freezes the UI. - -### Image Compare A/B 🆎 - -A/B any two images: slide mode sweeps B over A under your pointer with a seam line, or flip the whole panel with the A/B toggle — the button says which one you're looking at, and flicking between them in place is how you catch a small change. Nothing is drawn over the picture; the resolution sits centred beneath it. Passes image A through, so it sits mid-graph without rewiring. - -The two transform nodes return exactly: - -1. `image` — transformed ComfyUI `IMAGE` -2. `mask` — generated-area `MASK`, including source transparency, rotation corners, and padding - -The operation order is always **rotate → crop → pad**. New sources start with zero rotation, full crop, zero padding, and a canvas multiple of `1`. - -## Install +## Start here ```bash cd ComfyUI/custom_nodes git clone https://github.com/ausboss/ComfyUI-AusBoss.git ``` -Restart ComfyUI, then search for `AusBoss`. There are no additional Python dependencies; the pack uses Pillow, NumPy, Torch, and PyAV already distributed with ComfyUI. LaMa Inpaint additionally needs a TorchScript `big-lama.pt` checkpoint in `ComfyUI/models/lama/`. +Restart ComfyUI and search for **AusBoss**. After updating, hard-refresh the browser with **Ctrl+Shift+R**. -## Model Links +**Upgrading from 1.x:** 2.0.0 removes **LM Studio Chat**. Existing workflows using that node need a replacement; use **Text** when a fixed prompt is enough. All other public mapping keys are retained. -**lama** +The pack uses Pillow, NumPy, Torch, and PyAV supplied by ComfyUI. Model workflows need the weights listed on their Workflow Note cards. LaMa additionally needs [`big-lama.pt`](https://github.com/Sanster/models/releases/download/add_big_lama/big-lama.pt) in `ComfyUI/models/lama/`; it never downloads weights automatically. -- [big-lama.pt](https://github.com/Sanster/models/releases/download/add_big_lama/big-lama.pt) +| Find a node | What is included | +|---|---| +| [Image](#image-nodes) | Load and transform, resize, align, measure, compare, save | +| [Video](#video-nodes) | Load and trim, pick a frame, transform a clip, interpolate, save | +| [Mask and inpaint](#mask-and-inpaint-nodes) | Refine masks, LaMa removal, crop and stitch | +| [Models and conditioning](#models-and-conditioning) | LoRA stack, Krea 2 prompt and reference conditioning | +| [Workflow utilities](#workflow-utilities) | Resolution, seed, batch operations, math, text, memory, notes, timer | +| [Examples](#example-workflows) | Fifteen grouped graphs with setup cards and thumbnails | -## Model Storage Location +## Image nodes -```text -📂 ComfyUI/ -└── 📂 models/ - └── 📂 lama/ - └── big-lama.pt +### Image Crop + Rotate + Pad 🆎 + +Load an image and **rotate → crop → pad** it. Drag cyan crop handles, orange padding diamonds, and the green rotation handle directly on the compact preview, or open the full-screen editor for precise dimensions, zoom, and pan. Aspect chips pad the complete image to a chosen shape. Feather and output resizing are available on the node and in the editor. + +Returns the transformed `image` and a generated-area `mask` covering padding, source transparency, and rotation corners. This is a general transform; for an outpaint that needs a source-preserving stitcher, use **Load Image + Pad**. + +![The full-screen image editor with a rotated lake photo, cyan crop handles, orange padding handles, aspect ratios, and dimension controls.](assets/readme/image-editor.webp) + +**Shape it by eye, finish with exact values.** The same handles work on the node and in the full-screen editor. [Try the image and video transform example →](example_workflows/Image%20and%20Video%20Transform%20%28AusBoss%29.json) + +### Load Image + Pad 🆎 + +Build an outpaint canvas by dragging its edges. The card offers solid color, edge-average, edge-pixel, or blurred-image fill, a seam feather, canvas multiple, and megapixel budget. **Exact padding** opens one scrub control per side. A zero budget keeps the source size; a positive budget resizes the source before padding. + +Returns the padded `image`, padding `mask`, `width`, `height`, a `stitcher`, and a smaller unpadded `reference` for conditioning. Connect the stitcher to **Stitch Inpaint** after generation to preserve the source outside the seam. + +### Image Resize 🆎 + +Resize to **width × height**, longest edge, shortest edge, megapixels, or a scale factor. Fit inside a box, stretch to it, cover and crop, or pad with a chosen color. The card shows the controls relevant to the selected mode; width and height accept separate links. + +An optional mask follows the same geometry. Outputs are `image`, `mask`, `width`, and `height`. Pad bars become white in the mask; otherwise a missing input mask stays black. `divisible_by` rounds the dimensions to a chosen multiple, and the interpolation control selects the image filter. + +### Align Image 🆎 + +Snap dimensions to a chosen multiple through **resize**, **crop**, or **pad**. Crop and pad have position anchors; padding can use edge pixels or a solid color. Returns the aligned image and its width and height. Use it when a downstream model requires divisible dimensions without retyping a target size. + +### Image Size 🆎 + +Read `width`, `height`, `longest_edge`, `shortest_edge`, and batch `count` as integers. Wire these into resizing, latent creation, frame-count calculations, or math so the workflow follows its source dimensions. + +### Color Match 🆎 + +Match an image to a reference with **LAB**, **RGB**, **MKL**, or **histogram** transfer. Strength controls the blend; an optional mask and invert control limit the correction. With `reference_mode` set to **first frame**, every frame uses the batch's first frame as its reference. This can reduce color drift between frames, but does not stabilize motion. + +### Image Compare A/B 🆎 + +Compare two images with a sliding reveal or a full-image A/B toggle. The picture remains unobstructed, with its resolution below it. The panel grows with the node and passes image A through, so it can sit anywhere along an image wire. + +### Save Image 🆎 + +Choose an output folder, browse its subfolders, edit the filename, and check the live path preview. Counter, date, time, size, and batch-number chips build reusable names. Save **PNG**, **lossless WebP**, or **lossless JPEG XL**, with optional embedded workflow metadata. + +Link `filename` to retain an upstream name, or `caption_text` to write a matching `.txt` sidecar for each image. The counter selects a free path; turning it off deliberately reuses the chosen path. JPEG XL needs the optional `pillow-jxl-plugin` extra. See the [full naming and compatibility notes](js/docs/AUSBOSS_NODES_SaveImage.md). + +![The Save Image card with date and size naming chips beside a detailed fisherman portrait from the Krea 2 Studio workflow.](assets/readme/save-showcase.webp) + +**From final render to organized output.** A portrait from the [Krea 2 Studio example](example_workflows/Krea%202%20Studio%20%28AusBoss%29.json), saved with its date, dimensions, and workflow. + +## Video nodes + +![Animated comparison of a vertical pier clip and its wider LTX 2.3 outpainted result, showing the added lake and shoreline.](assets/readme/video-outpaint.gif) + +**Give a vertical clip a wider world.** This LTX 2.3 example extends the sides and stitches the source frames back into the result. The saved video retains the source audio; this preview is a silent loop. [Open the video outpaint workflow →](example_workflows/LTX%202.3%20Video%20Outpaint%20%28AusBoss%29.json) + +### Load Video 🆎 + +Upload a video and trim it with IN/OUT handles or typed timecodes. The player previews the selected window and can loop it. Width and height can resize during decode; setting only one preserves aspect. `every_nth` thins frames and adjusts the reported fps to preserve timing; `max_frames` limits the decode. + +Returns frames, lazy audio, frame count, fps, width, height, duration, and a core `VIDEO` handle. Only the selected window is decoded, with a memory check before a large allocation. Connect its fps downstream when saving the frames. + +### Video Crop + Rotate + Pad → Frame 🆎 + +Find **one frame** in an uploaded or local video, then apply the image transform controls. The editor provides exact frame stepping, playback, and a timeline. Returns one transformed `image` and its generated-area `mask`. Use the Clip variant for an entire sequence. + +### Video Crop + Rotate + Pad → Clip 🆎 + +Apply one transform to every frame of a trimmed clip. The node combines source selection, IN/OUT trimming, frame thinning, a frame limit, crop/rotate/pad handles, and output resizing. **Snap** trims the tail to an 8n+1 or 4n+1 frame count when the next video model needs it. + +Returns frames, mask, audio, frame count, fps, size, duration, and a `stitcher`. **Inpaint & Stitch** in the editor controls the protected source region and blend preview. Wire that stitcher directly to **Stitch Inpaint** after a video outpaint; no intermediate Crop For Inpaint is needed. Processing is chunked to limit temporary memory. + +![The full-screen video editor with a portrait clip padded to landscape, an Inpaint and Stitch panel, and a timeline with IN and OUT handles.](assets/readme/video-editor.webp) + +**Frame, trim, and prepare the blend in one editor.** The timeline and protected-region preview belong to the same Clip node used in the video outpaint above. + +### Select Frame 🆎 + +Select one unchanged frame from an image batch: **1** is the first, **-1** the last, and **-2** the next-to-last. Zero and out-of-range values report the valid range. The preview switch shows the selected frame or hides the picture and skips writing its preview file. + +### Frame Interpolate 🆎 + +Retiming uses source and target **fps**, so 24 → 30 works alongside whole-number multiples. **Blend** crossfades frames; **optical flow** uses locally cached RAFT weights. Scene-cut detection holds frames across hard cuts instead of interpolating between unrelated shots. Chunked processing bounds working memory. + +Returns the retimed frames and output fps. It does not process audio; carry the source audio separately to Save Video. See [weight setup and timing details](js/docs/AUSBOSS_NODES_FrameInterpolate.md). + +### Save Video 🆎 + +Save an image batch or a core `VIDEO` as MP4 H.264/H.265, WebM VP9/AV1, ProRes MOV, lossless FFV1 MKV, GIF, or WebP. Available encoders depend on the local FFmpeg/PyAV build; the H.264/H.265 choices include CPU and NVENC options. Video outputs carry color tags, optional audio, and optional embedded workflow metadata. + +**Ping-pong** runs forward and back without repeating the turnaround frames. A connected `VIDEO` supplies its own rate; otherwise wire fps from the loader or interpolator. The result player shows the encoded file. Dropping a supported saved video back onto the canvas restores its embedded graph. See [format and audio details](js/docs/AUSBOSS_NODES_SaveVideo.md). + +## Mask and inpaint nodes + +![Close-up comparison of a rowboat beside a pier before inpainting and a white swan in its place afterward.](assets/readme/inpaint-showcase.webp) + +**Edit the part that matters.** Mask Refine, Crop For Inpaint, and Stitch Inpaint carry a painted region through this Klein 9B edit. Both pictures show the same detail crop. [Open the inpaint workflow →](example_workflows/Klein%209B%20Inpaint%20%28AusBoss%29.json) + +### Mask Refine 🆎 + +Expand or shrink a mask, blur it, fill holes, smooth jagged edges, and adjust black and white points. The compact card starts with Expand and Blur; **More** opens the remaining controls. **AUTO** chooses expand and blur values from mask size. The optional preview shows the refined mask. + +Returns the refined mask and its inverse. **Guided filter** and **matting** use a guide image and require their optional extras; the default **off** mode has no extra dependency. + +### LaMa Inpaint 🆎 + +Replace white mask regions using a local TorchScript LaMa checkpoint. Black-mask pixels are preserved. One mask can serve an entire image batch, and video frames are processed one at a time to bound VRAM. The preview can stream completed frames during execution or be switched off. + +Put `big-lama.pt` in `ComfyUI/models/lama/`. This is a frame-by-frame remover: difficult video textures may need additional temporal cleanup. The older `SimpleWatermarkRemover` mapping remains available as a compatibility alias. + +### Crop For Inpaint 🆎 + +Crop the mask's bounding region with surrounding context, optionally resize it to a target size or megapixel budget, and return the cropped image, sampling mask, and stitcher. The card exposes the common mask and blend settings, with target sizing and canvas extension behind a disclosure. + +The sampling mask and stitch blend are separate: mask growth/blur determines what the model can edit, while the blend margin determines how the result joins the original. Increase Context or Extra context when the model needs to see more of the surroundings. The `extend_*` controls can grow the canvas for outpainting. + +### Stitch Inpaint 🆎 + +Paste a generated crop or outpaint back using a stitcher from **Crop For Inpaint**, **Load Image + Pad**, or **Video Crop + Rotate + Pad → Clip**. Pixels outside the blend region remain bit-identical to the stitcher's source. Video stitchers carry the per-frame originals. + +**Tone match** reduces color discontinuities at the seam. **Fix edge halo** corrects a twice-blended rim and uses the optional matting extra; without it, the node warns and performs the normal stitch. See the [stitcher and batch contract](js/docs/AUSBOSS_NODES_StitchInpaint.md). + +## Models and conditioning + +### LoRA Loader 🆎 + +Keep a complete stack in one node: enable each row, choose a model from the searchable folder picker, and scrub its strength. The centered strength bars compare enabled rows on one scale; disabling the strongest row rescales the remaining bars. Model and CLIP strengths can be separated, and CLIP input is optional. + +![A compact three-row LoRA Loader with Vintage Tarot enabled beside its illustrated Hermit tarot-card result.](assets/readme/lora-showcase.webp) + +**One stack, a distinct look.** Krea 2 with the Vintage Tarot LoRA enabled; the other rows are parked for later. [Try the LoRA stack example →](example_workflows/Krea%202%20Text%20to%20Image%20%2B%20LoRA%20Stack%20%28AusBoss%29.json) + +The toolbar holds the stack toggle, saved templates, reconnect, and settings. **Absorb loader chain** collects recognized loaders from the connected model chain and bypasses the originals. File metadata, optional Civitai lookup, and your saved words populate each LoRA's information card; selected trigger words flow through the `triggers` output. + +Moved files resolve by basename when the match is unambiguous. A missing enabled LoRA stops validation by default. **Stop on missing LoRA** can be turned off to warn and skip instead. A LoRA that patches nothing on the model reports a warning naming the file. + +### Krea 2 Encode 🆎 + +Encode positive and negative prompts in one card. With a VAE and reference images connected, it also attaches reference latents. Reference images are resized for conditioning; **VLM reference** controls whether the text encoder's vision tower also sees them. Leave the reference inputs empty for text-to-image. + +The prompt supports multiline editing and incoming text links. See [reference preparation](js/docs/AUSBOSS_NODES_Krea2Encode.md) and use the matching example when combining it with an outpaint LoRA. + +### Krea 2 Outpaint Model Patch 🆎 + +Register reference tokens at a defined place on the target canvas. Put the patch after LoRAs and before sampling; connect a stitcher and the reference conditioning from Krea 2 Encode. + +The card exposes **Reference** placement and **KV cache**. Placement must match the adapter: + +- **Whole canvas + AnyPaint:** the padded image is the reference, VLM reference is on, and a masked starting latent protects known pixels. Several sides can extend in one pass. This is the supplied Krea outpaint example. +- **Source rectangle + Registered Outpaint:** use the unpadded reference with VLM reference off. The source must span one complete canvas axis; extend left/right or top/bottom per pass, including any rounding padding. + +The patch depends on ComfyUI's attention internals. Its [help page](js/docs/AUSBOSS_NODES_Krea2OutpaintModelPatch.md) explains both wiring contracts and the behavior without reference conditioning. + +## Workflow utilities + +![Resolution Master with a 1344 by 768 landscape canvas and ratio controls, alongside a Seed card set to a fixed seed.](assets/readme/controls-showcase.webp) + +**Small controls you can build a graph around.** Set the canvas once and share a seed across samplers. [Explore Resolution Master →](example_workflows/Resolution%20Master%20%28AusBoss%29.json) + +### Resolution Master 🆎 + +Choose landscape or portrait, pick a ratio, adjust the megapixel budget, or drag the canvas handles. The orientation toggle remains independent of the ratio, including while square is selected. Typed width and height remain exact; gesture snapping is adjustable. + +Returns width, height, and an empty **image latent**. The gear chooses 4-, 16-, or 128-channel layout and batch size. Latent dimensions use complete downsample cells; choose dimensions compatible with the selected layout when connecting the latent to a sampler. For video, use the width and height outputs with the model's own video latent node. + +### Seed 🆎 + +Share one seed across samplers. **Random**, **Fixed**, and **Step** control what happens after generation. **New seed** rolls and pins a value; **Use last run** restores the value the backend actually used. The recent-history menu retains eight seeds and saves with the workflow. + +### Select Every Nth 🆎 + +Keep every nth image, starting after a zero-based offset. For example, nth 2 with offset 0 keeps frames 1, 3, 5; offset 1 keeps 2, 4, 6. The output retains source order. This node changes the image batch only; adjust video timing separately. + +### Split Batch 🆎 + +Split after a one-based frame index. Output **a** includes that frame; **b** contains the remainder. Both outputs must retain at least one frame, so invalid split points report the valid range. + +### Merge Batches 🆎 + +Append batch **b** after **a**. Matching sizes remain unchanged. For different resolutions, resize to a's size, resize to b's size, or stop with an error. Channel counts must already agree. + +### Text 🆎 + +A growing multiline card that passes its contents unchanged to a `STRING` wire. Use it for a prompt, caption, path, or other text shared by several nodes. Spaces and line breaks are retained. + +### Integer 🆎 + +A scrub control that emits one `INT`. Share dimensions, counts, or other whole numbers through links. + +### Float 🆎 + +A scrub control that emits one `FLOAT`. Share strengths, scales, frame rates, or durations through links. + +### Math Expression 🆎 + +Calculate from linked `a`, `b`, and `c` inputs, each accepting integers or floats. Unwired variables read as zero. Supports arithmetic and `min`, `max`, `abs`, `round`, `floor`, `ceil`, and `sqrt` through a restricted expression parser. + +Returns a float and a rounded integer; integer halves round away from zero. Use expressions such as `a * 2` or `ceil(a / 32) * 32` to derive values from upstream data. + +### Show Text 🆎 + +Display incoming text in a selectable, resizable panel and pass it through unchanged. The last displayed text saves with the workflow. Long displays may be truncated, but the output retains the complete value. Runs as an output node, so no downstream consumer is needed. + +### Free Memory 🆎 + +Pass any value through while unloading cached ComfyUI models, collecting Python garbage, and clearing the CUDA allocator cache where available. Put it between heavy stages when the next stage needs memory. It runs when its inputs change, respects graph caching, and may add model reload time downstream. + +### Workflow Note 🆎 + +Add a title, Markdown instructions, model download links and destination folders, required node packs, and useful links. Installed models and packs are checked against the current instance. **Edit** changes the card; **Banner** makes a compact section title. The note never executes, and its text is not rendered as arbitrary HTML. + +### Run Timer 🆎 + +A resizable stopwatch for the whole executing queue, with no wires. It starts when execution begins, holds the elapsed total when the run ends, and saves the last time with the graph. Recent times are available from its context menu. It is a display aid and does not change execution order. + +## Example workflows + +Open the JSON files from [`example_workflows/`](example_workflows), or use the pack's entries in ComfyUI's template browser. Each has a matching thumbnail, numbered groups, and a Workflow Note explaining setup and operation. + +Copy the files in [`example_workflows/inputs/`](example_workflows/inputs) into `ComfyUI/input/` first. The rowboat PNG includes a painted mask. Model paths can include local subfolders: select the corresponding installed file in each loader before running. A green model indicator means the file exists; it does not guarantee the loader has selected that path. Model licenses and any download access requirements remain with their authors. + +| Workflow | Purpose | Requirements | +|---|---|---| +| [Resolution Master](example_workflows/Resolution%20Master%20%28AusBoss%29.json) | Orientation, ratios, MP budgeting, and a solid-color preview | Core + this pack; no models | +| [Image and Video Transform](example_workflows/Image%20and%20Video%20Transform%20%28AusBoss%29.json) | Both transform editors, generated-area masks, alignment, and comparison | Core + this pack; included picture and clip | +| [AusBoss node tour](example_workflows/ausboss_node_tour.json) | Trim, split, merge, resize, mask, retime, compare, and save a clip | Core + this pack; included clip | +| [Krea 2 Studio](example_workflows/Krea%202%20Studio%20%28AusBoss%29.json) | Draft, learned upscale, low-denoise refinement, tone match, compare | Krea 2 Turbo components + a 4× upscaler | +| [Krea 2 Text to Image + LoRA Stack](example_workflows/Krea%202%20Text%20to%20Image%20%2B%20LoRA%20Stack%20%28AusBoss%29.json) | A stacked LoRA prompt with trigger words | Krea 2 Turbo + enabled style LoRAs; anime row is optional | +| [Krea 2 Prompt from Image](example_workflows/Krea%202%20Prompt%20from%20Image%20%28AusBoss%29.json) | Describe a picture with the text encoder, then render its prompt | Krea 2 components with core Text Generate support | +| [Krea 2 Outpaint](example_workflows/Krea%202%20Outpaint%20%28AusBoss%29.json) | Extend several sides, then stitch the source back | Krea 2 Turbo + AnyPaint LoRA | +| [Klein 9B Edit](example_workflows/Klein%209B%20Edit%20%28AusBoss%29.json) | Edit a picture at a source-derived working size | Distilled Klein 9B, Qwen encoder, Flux 2 VAE | +| [Klein 9B Inpaint](example_workflows/Klein%209B%20Inpaint%20%28AusBoss%29.json) | Refine the painted mask, crop, edit, and stitch | Same Klein components; included masked image | +| [Klein 9B Outpaint](example_workflows/Klein%209B%20Outpaint%20%28AusBoss%29.json) | Padded reference, masked starting latent, source-preserving stitch | Same Klein components + PixaOutpaint LoRA | +| [LTX 2.3 Video Outpaint](example_workflows/LTX%202.3%20Video%20Outpaint%20%28AusBoss%29.json) | Extend a clip's canvas while retaining source audio and pixels | LTX 2.3 components, distilled LoRA, outpaint IC-LoRA | +| [MiniMax H3 Text to Video](example_workflows/MiniMax%20H3%20Text%20to%20Video%20%28AusBoss%29.json) | Five-second request rounded to 124 frames, with generated audio | H3 FL2VA components + 4-step turbo LoRA | +| [MiniMax H3 Image to Video](example_workflows/MiniMax%20H3%20Image%20to%20Video%20%28AusBoss%29.json) | A short image-guided shot with generated audio | Same H3 components + turbo LoRA; included image | +| [MiniMax H3 First + Last Frame](example_workflows/MiniMax%20H3%20First%20%2B%20Last%20Frame%20%28AusBoss%29.json) | Two endpoint pictures and a 20-step base-model transition | H3 FL2VA components; two included images | +| [Simple Video Watermark Remover](example_workflows/simple_video_watermark_remover.json) | Detect and remove an overlay, with a single-frame comparison branch | **ComfyUI-RMBG / SAM3** plus `big-lama.pt` and this pack | + +The fourteen other examples use core nodes plus this pack. Model-free examples are the quickest installation check. Generation speed and memory depend on the selected weights, dimensions, frame count, and other GPU workloads; the graph settings are reproducible, hardware timing is not. + +## Editor controls and settings + +- Cyan squares crop, orange diamonds pad, and the green handle rotates. Shift snaps rotation to 15°. +- In the full-screen editor, use the mouse wheel to zoom and middle mouse or Alt-drag to pan. On the compact node stage, wheel and middle mouse continue to control the graph. +- **Alt+E** opens the selected transform editor; change it in Settings → Keybindings. +- Video editors offer frame stepping, playback, and exact timeline seeking. The Clip node's IN/OUT controls select the decoded window. +- Optional aspect presets live in `ausboss_presets.json` beside the pack. Copy [`ausboss_presets_example.json`](ausboss_presets_example.json) to start; the user file survives updates. + +**Settings → 🆎 AusBoss** contains the pack color scheme, including AusBoss, Graphite, Slate, Teal, Moss, Plum, Rust, Navy, Custom, and Theme default. Hand-colored nodes keep their overrides. Right-click a node for its **AusBoss color** menu. + +Under **Chrome**, queue status can appear in the browser title/favicon, live badges report node progress, and optional runtime badges display per-node execution time. LoRA Loader and Resolution Master have their own gear menus. + +Queued transforms can read local video paths. Live previews outside ComfyUI's folders require the explicit startup option `AUSBOSS_TRANSFORM_LOCAL_PREVIEW=1`; see [the video transform help](js/docs/AUSBOSS_NODES_VideoCropRotatePad.md). + +## Optional extras + +Install extras with the Python interpreter ComfyUI actually uses, from this pack's directory: + +```bash +python -m pip install '.[guided-filter]' # Mask Refine: guided filter +python -m pip install '.[matting]' # Mask Refine: matting; Stitch: edge halo +python -m pip install '.[jxl]' # Save Image: lossless JPEG XL ``` -After updating frontend files, hard-refresh the browser with `Ctrl+Shift+R`. +Default mask operations, ordinary stitching, PNG, and lossless WebP do not need these extras. Optical flow needs cached RAFT weights; model setup is described in the corresponding node help. Codec support depends on the ComfyUI environment. -## Quick start +## Compatibility and development -1. Add either transform node. -2. Pick or upload a source. For very large videos, switch the video node to `local path` mode. - Queued workflows always read local paths, but the editor's live preview of paths outside - ComfyUI's own folders is opt-in: start ComfyUI with `AUSBOSS_TRANSFORM_LOCAL_PREVIEW=1` - to enable it. This keeps the preview routes from exposing arbitrary files to anything - that can reach your ComfyUI server (for example on `--listen`). -3. Click **Open editor**. -4. Drag cyan crop handles, orange padding handles, or the green rotation handle. -5. Close the editor and connect `image` and `mask` downstream. +The declared minimum is ComfyUI **0.27.1**. Newer model examples require a core that includes their model family; updating only this pack cannot add missing core model support. The current review uses ComfyUI **0.34.5** with frontend **1.49.6**. The pack supports classic canvas and Nodes 2.0, and backend execution does not require an editor to be open. -Use the matching examples in [`example_workflows`](example_workflows) as small starting graphs. - -## Editor controls - -- Eight crop handles with generous hit targets -- Four visually distinct padding handles -- Rotation handle with alignment grid and `Shift` snapping to 15 degrees -- Free or ratio-locked crop -- Mouse-wheel zoom; middle-mouse or `Alt`-drag pan -- Reset view, reset padding, and reset all -- Video first/last, ±1, ±25, ±50, ±100, playback, and timeline scrubbing - -## Settings - -Under **Settings → 🆎 AusBoss** you can pick an **AusBoss node color** scheme -(Graphite, Slate, Teal, Moss, Plum, Rust, Navy, or **Custom** with your own -picked tint). It recolors every AusBoss -node in the open workflow immediately and applies to nodes you add later. -Nodes you have colored by hand keep their own colors, and **Theme default** -returns everything to the stock look. Right-click any AusBoss node for a -per-node **AusBoss color** override, and press **Alt+E** (rebindable in -Settings → Keybindings) to open the selected transform node's editor. - -Custom crop aspect-ratio presets live in an optional `ausboss_presets.json` -next to the pack — copy `ausboss_presets_example.json` to start; your file is -gitignored and survives updates. - -Under **Chrome**: the browser tab's icon and title can show rendering state -and queue depth (on by default), **live status badges** report progress on the -node itself while it works — `frame 43/120` through a video inpaint — (on by -default), and optional **node runtime badges** stamp each node with its seconds -after a run (off by default). - -## Compatibility - -- Classic V1 node definitions for broad ComfyUI compatibility -- Classic canvas and Nodes 2.0 frontend support -- API-mode execution does not require the editor to be open -- Transform editors validated on ComfyUI `0.27.1`; the pack scaffold was also validated on `0.28.0` - -The declared minimum is ComfyUI `0.27.1` (`requires-comfyui` under `[tool.comfy]` in `pyproject.toml`, which ComfyUI-Manager enforces at install time). If a frontend update changes custom-widget behavior, please include your ComfyUI and frontend versions in the issue. - -## Development +Backend changes need a full ComfyUI restart. Frontend changes need a hard refresh. Use ComfyUI's Python for backend tests: ```bash python scripts/validate_nodes.py python scripts/release_preflight.py -python -m unittest discover -s tests -p "test_*.py" -v -node --test tests/*.test.mjs +python scripts/run_python_tests.py +node --test --test-isolation=none tests/*.test.mjs ``` -See [`AGENTS.md`](AGENTS.md) for compatibility rules and [`docs/adding_a_node.md`](docs/adding_a_node.md) for the node checklist. +Backend tests run in separate processes because their offline ComfyUI stubs must not leak between files. See [`docs/live_testing.md`](docs/live_testing.md) for canvas, mouse-drag, save/reload, and workflow execution checks. [`docs/adding_a_node.md`](docs/adding_a_node.md) and [`AGENTS.md`](AGENTS.md) define the public-node and release gates. ## License diff --git a/__init__.py b/__init__.py index 7c8fa9b..e37ad6d 100644 --- a/__init__.py +++ b/__init__.py @@ -31,17 +31,21 @@ NODE_MODULES = [ "node_krea2_model_patch", "node_lama_inpaint", "node_literals", - "node_lmstudio_chat", "node_load_image_pad", "node_load_video", "node_lora_loader", "node_math", "node_refine_mask", + "node_run_timer", "node_save_image", "node_save_video", + "node_seed", + "node_resolution", "node_select_frame", "node_show_text", "node_video_crop_rotate_pad", + "node_video_crop_rotate_pad_clip", + "node_workflow_note", ] NODE_CLASS_MAPPINGS = {} diff --git a/assets/readme/controls-showcase.webp b/assets/readme/controls-showcase.webp new file mode 100644 index 0000000..cef9e74 Binary files /dev/null and b/assets/readme/controls-showcase.webp differ diff --git a/assets/readme/image-editor.webp b/assets/readme/image-editor.webp new file mode 100644 index 0000000..3debb3e Binary files /dev/null and b/assets/readme/image-editor.webp differ diff --git a/assets/readme/inpaint-showcase.webp b/assets/readme/inpaint-showcase.webp new file mode 100644 index 0000000..6cee38c Binary files /dev/null and b/assets/readme/inpaint-showcase.webp differ diff --git a/assets/readme/lora-showcase.webp b/assets/readme/lora-showcase.webp new file mode 100644 index 0000000..3244b8c Binary files /dev/null and b/assets/readme/lora-showcase.webp differ diff --git a/assets/readme/outpaint-showcase.webp b/assets/readme/outpaint-showcase.webp new file mode 100644 index 0000000..cc51e74 Binary files /dev/null and b/assets/readme/outpaint-showcase.webp differ diff --git a/assets/readme/save-showcase.webp b/assets/readme/save-showcase.webp new file mode 100644 index 0000000..2844a14 Binary files /dev/null and b/assets/readme/save-showcase.webp differ diff --git a/assets/readme/video-editor.webp b/assets/readme/video-editor.webp new file mode 100644 index 0000000..743ae01 Binary files /dev/null and b/assets/readme/video-editor.webp differ diff --git a/assets/readme/video-outpaint.gif b/assets/readme/video-outpaint.gif new file mode 100644 index 0000000..8453f1c Binary files /dev/null and b/assets/readme/video-outpaint.gif differ diff --git a/docs/adding_a_node.md b/docs/adding_a_node.md index 016c9ab..73c23b2 100644 --- a/docs/adding_a_node.md +++ b/docs/adding_a_node.md @@ -21,7 +21,7 @@ Every visible field and output must earn its place. Published IDs, input order, 1. Put independently testable processing in an underscore-prefixed helper under `nodes/`. 2. Add one V1 wrapper in `nodes/node_.py` with `INPUT_TYPES`, `RETURN_TYPES`, `FUNCTION`, `CATEGORY`, and both mapping dictionaries — keys as string literals, never a `NODE_ID` variable, or registry scanners cannot see the node. 3. Add the module to `NODE_MODULES` in `__init__.py`. -4. Add frontend JavaScript only when the normal schema cannot provide the required interaction. +4. Give the node its face. Any INT, FLOAT, BOOLEAN, COMBO or STRING widget on a public node is shown through a widget card entry in `js/widget_cards/index.js` (see AGENTS.md §Conventions: numbers scrub, booleans are off | on pills, one linkable widget per row, `top: true` or `forceInput` for values people wire). Write more frontend JavaScript only when the card grammar cannot provide the interaction (a stage, a player, an editor). 5. Namespace every route, extension, event, DOM marker, CSS class, cache, and browser state with `ausboss`. 6. Chain lifecycle hooks through `chainCallback`; never replace core or third-party prototypes directly. 7. Keep normal workflow and API execution independent from the custom frontend. @@ -35,7 +35,7 @@ Every visible field and output must earn its place. Published IDs, input order, - Add pure Python and dependency-free JavaScript tests. - Compile with ComfyUI's embedded Python. - Verify `/object_info/`, ownership, served assets, routes, API execution, and queued dimensions/masks. -- Test the actual canvas in Classic and Nodes 2.0, including save/reload, duplication, graph zoom, source replacement, and teardown. +- Test the actual canvas in Classic and Nodes 2.0, including save/reload, duplication, graph zoom, source replacement, and teardown. Drag a real link onto every card row and socket and look at where it lands (`docs/live_testing.md`); save before/after screenshots under `_scratch/node_screenshots/` for review. - Scan the diff for paths, hosts, secrets, obsolete branding, placeholders, agent attribution, and non-ASCII import output. Do not release a node that only works in the editor, only works for one source, or returns debugging outputs users do not need. diff --git a/docs/live_testing.md b/docs/live_testing.md new file mode 100644 index 0000000..033c962 --- /dev/null +++ b/docs/live_testing.md @@ -0,0 +1,90 @@ +# Live frontend testing + +Frontend work in this pack is proven on a real canvas: a node is created in a +real ComfyUI tab, real mouse drags are dispatched, and the result is a +screenshot a person looks at. Reading the code, or a test that only checks +which input received a link, has let broken designs through before (three +sockets stacked on one pixel passed every automated check). This is the +recipe. + +## The pieces + +- **ComfyUI** on `http://127.0.0.1:8188`, with this repository installed or + linked into `custom_nodes`. A changed `INPUT_TYPES` is served only after + a full restart. Stop the test instance, then start it from the ComfyUI + checkout using that installation's Python environment: + + ```bash + python main.py --listen 127.0.0.1 + ``` + + From this repository, run `python scripts/run_python_tests.py` with the + same environment to isolate ComfyUI stubs and keep CPU thread counts + bounded. Set `AUSBOSS_COMFY_ROOT` to the ComfyUI checkout to enable the + optional core video integration checks. + +- **Headless Chrome or Chromium** on an unused CDP port; these examples use + **9334**. Use a separate test profile and the installed browser executable: + + ```bash + chromium \ + --headless=new --remote-debugging-port=9334 --user-data-dir=/tmp/chrome-ausboss \ + --window-size=1800,1100 --no-first-run --no-default-browser-check about:blank & + ``` + +- **`scripts/dev/`** — small Node scripts that talk CDP directly, with no + package dependencies. Use a Node version with built-in `fetch` and `WebSocket`: + + | script | does | + |---|---| + | `cdp.mjs [eval.js] [waitMs]` | opens/reuses the Comfy tab, optionally loads a workflow, evaluates a JS file, screenshots (a returned `__clip` rect clips the shot), prints the eval result as JSON | + | `cdp_drag.mjs [check.js]` | `setup.js` returns `{from, to, __clip}` in client pixels; presses at `from`, moves to `to`, screenshots mid-drag, releases, evaluates `check.js`, screenshots again | + | `audit_workflows.mjs [workflow.json ...]` | loads every example (or selected files), exports API and loaded graphs, captures screenshots, and fails on overlaps, missing nodes, browser exceptions, or exposed AusBoss number widgets | + | `cdp_nav_debug.mjs ` | navigates the tab in place and logs every CDP event, for when a tab stops answering | + | `cdp_hang.mjs ` | pre-arms the debugger on a fresh tab, loads ComfyUI, and pauses to print the stack if the page stops answering | + + `CDP_RELOAD=1` closes the Comfy tab and opens a fresh one with the cache + cleared — required after editing any `.mjs`, which the frontend caches + hard. + +## Screenshot convention + +An eval creates the node at `pos [0, 0]`, sets `app.canvas.ds.scale = 1` and +`ds.offset = [160, 230]`, and returns +`__clip: { x: r.left + 152, y: r.top + 192, width: node.size[0] + 16, height: node.size[1] + 46 }` +(`r` is the canvas element's bounding rect). Shots go to +`_scratch/node_screenshots/` (gitignored) named `_[_state].png`, +`_mid` for a screenshot taken during a drag and `_after` for the result. +Side-by-side pairs live in `pairs/`. + +## Gotchas learned the hard way + +- **Clearing the graph reuses node ids**, and the frontend's widget store + hands a new node the previous node's widget values (a toggled `preview` + came back `false`). Bump `app.graph.last_node_id += 50` after + `app.graph.clear()`, or set the value you want explicitly. +- **A real mouse drag marks the workflow modified.** The next in-place + navigation raises ComfyUI's `beforeunload` dialog and the whole tab stops + answering every CDP call, even `1+1`; it looks exactly like a renderer + hang. The scripts auto-accept dialogs and reload into a fresh tab. +- **Reset a half-finished link drag** (`app.canvas.linkConnector.reset()`) + before clearing the graph in an eval. +- The mid-drag screenshot shows the socket lit and the tooltip, not the + greyed field: the field greys once the link exists, in the `_after` shot. +- Widget input sockets are drawn only while a link is dragged, hovered, or + connected, so a "no socket visible" screenshot at rest is normal; the + mid-drag shot is the one that shows where a link can land. +- `cdp.mjs` requests time out after 25 s; the workflow audit allows 45 s. + Inspect the browser's dialogs and logs when a request stops responding. +- Keep review renders and logs in the ignored `_scratch/` directory. Only + deliberately shipped sample media belongs in `example_workflows/inputs/`. + +## What to verify for a node face + +1. The face at rest (values, alignment, no clipped panel). +2. Every group disclosure open. +3. A real link dropped on every row and socket, `_mid` and `_after`. +4. `app.graphToPrompt()` sends the link for a linked input and the typed + value otherwise. +5. Save, reload (fresh tab), and confirm links, values and hidden state. +6. The preview switch on and off where the node has one. diff --git a/example_workflows/Image and Video Transform (AusBoss).jpg b/example_workflows/Image and Video Transform (AusBoss).jpg new file mode 100644 index 0000000..151f9ed Binary files /dev/null and b/example_workflows/Image and Video Transform (AusBoss).jpg differ diff --git a/example_workflows/Image and Video Transform (AusBoss).json b/example_workflows/Image and Video Transform (AusBoss).json new file mode 100644 index 0000000..27952a7 --- /dev/null +++ b/example_workflows/Image and Video Transform (AusBoss).json @@ -0,0 +1,926 @@ +{ + "last_node_id": 10, + "last_link_id": 8, + "nodes": [ + { + "id": 1, + "type": "AUSBOSS_NODES_WorkflowNote", + "pos": [ + -620, + 100 + ], + "size": [ + 560, + 940 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "name": "note", + "type": "STRING", + "link": null, + "widget": { + "name": "note" + } + } + ], + "outputs": [], + "properties": { + "cnr_id": "ausboss-nodes", + "Node name for S&R": "AUSBOSS_NODES_WorkflowNote", + "ver": "2.0.0" + }, + "widgets_values": [ + "{\"v\": 1, \"title\": \"Image and Video Transform\", \"subtitle\": \"Explore both editors, inspect generated-area masks, and align the final image.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"body\": \"## Setup\\nCopy the included pier PNG and MP4 from example_workflows/inputs into ComfyUI/input. No models or extra packs are needed.\\n\\n## Image editor\\nDrag the crop, padding and rotation handles on Image Crop + Rotate + Pad, or open its full-screen editor. The sample starts with a slight rotation and visible padding so the generated-area mask is easy to inspect.\\n\\n## Alignment\\nAlign Image pads the transformed image to a multiple of 64. Compare shows that aligned result against the transform. Alignment outputs the image and dimensions; the mask preview is from the transform before alignment. Use Image Resize with an input mask if image and mask must follow one resize together.\\n\\n## Video frame editor\\nThe Frame variant selects exactly one frame. Move the timeline, then queue to update the image and mask previews. For a whole trimmed sequence, use the Clip variant as shown in the LTX example.\\n\\n## Gesture guide\\nCyan squares crop, orange diamonds pad, green rotates. Shift snaps rotation. Wheel zoom and middle-button pan belong to the full-screen editor; on the compact stage they still navigate the graph.\", \"models\": [], \"packs\": []}" + ] + }, + { + "id": 2, + "type": "AUSBOSS_NODES_ImageCropRotatePad", + "pos": [ + 0, + 100 + ], + "size": [ + 440, + 900 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "COMBO", + "link": null, + "widget": { + "name": "image" + } + }, + { + "name": "rotation_degrees", + "type": "FLOAT", + "link": null, + "widget": { + "name": "rotation_degrees" + } + }, + { + "name": "crop_aspect_ratio", + "type": "COMBO", + "link": null, + "widget": { + "name": "crop_aspect_ratio" + } + }, + { + "name": "crop_x", + "type": "INT", + "link": null, + "widget": { + "name": "crop_x" + } + }, + { + "name": "crop_y", + "type": "INT", + "link": null, + "widget": { + "name": "crop_y" + } + }, + { + "name": "crop_width", + "type": "INT", + "link": null, + "widget": { + "name": "crop_width" + } + }, + { + "name": "crop_height", + "type": "INT", + "link": null, + "widget": { + "name": "crop_height" + } + }, + { + "name": "pad_left", + "type": "INT", + "link": null, + "widget": { + "name": "pad_left" + } + }, + { + "name": "pad_top", + "type": "INT", + "link": null, + "widget": { + "name": "pad_top" + } + }, + { + "name": "pad_right", + "type": "INT", + "link": null, + "widget": { + "name": "pad_right" + } + }, + { + "name": "pad_bottom", + "type": "INT", + "link": null, + "widget": { + "name": "pad_bottom" + } + }, + { + "name": "feather", + "type": "INT", + "link": null, + "widget": { + "name": "feather" + } + }, + { + "name": "canvas_multiple", + "type": "INT", + "link": null, + "widget": { + "name": "canvas_multiple" + } + }, + { + "name": "fill_color", + "type": "STRING", + "link": null, + "widget": { + "name": "fill_color" + } + }, + { + "name": "resize_to_megapixels", + "type": "BOOLEAN", + "link": null, + "widget": { + "name": "resize_to_megapixels" + } + }, + { + "name": "megapixels", + "type": "FLOAT", + "link": null, + "widget": { + "name": "megapixels" + } + }, + { + "name": "resize_method", + "type": "COMBO", + "link": null, + "widget": { + "name": "resize_method" + } + }, + { + "name": "resolution_steps", + "type": "INT", + "link": null, + "widget": { + "name": "resolution_steps" + } + } + ], + "outputs": [ + { + "name": "image", + "type": "IMAGE", + "links": [ + 3, + 5 + ], + "slot_index": 0 + }, + { + "name": "mask", + "type": "MASK", + "links": [ + 1 + ], + "slot_index": 1 + } + ], + "properties": { + "cnr_id": "ausboss-nodes", + "Node name for S&R": "AUSBOSS_NODES_ImageCropRotatePad", + "ver": "2.0.0" + }, + "widgets_values": { + "image": "ausboss_pier_sunrise.png", + "rotation_degrees": 5, + "crop_aspect_ratio": "free", + "crop_x": 0, + "crop_y": 0, + "crop_width": 0, + "crop_height": 0, + "pad_left": 96, + "pad_top": 0, + "pad_right": 96, + "pad_bottom": 0, + "feather": 8, + "canvas_multiple": 8, + "fill_color": "#808080", + "resize_to_megapixels": false, + "megapixels": 1.0, + "resize_method": "lanczos", + "resolution_steps": 1 + } + }, + { + "id": 3, + "type": "MaskToImage", + "pos": [ + 520, + 100 + ], + "size": [ + 360, + 70 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "mask", + "type": "MASK", + "link": 1 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 2 + ], + "slot_index": 0 + } + ], + "properties": { + "cnr_id": "comfy-core", + "Node name for S&R": "MaskToImage" + }, + "widgets_values": [] + }, + { + "id": 4, + "type": "PreviewImage", + "pos": [ + 520, + 220 + ], + "size": [ + 360, + 610 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 2 + } + ], + "outputs": [ + { + "name": "images", + "type": "IMAGE", + "links": [], + "slot_index": 0 + } + ], + "properties": { + "cnr_id": "comfy-core", + "Node name for S&R": "PreviewImage" + }, + "widgets_values": [], + "title": "Image transform · generated-area mask" + }, + { + "id": 5, + "type": "AUSBOSS_NODES_AlignImage", + "pos": [ + 960, + 100 + ], + "size": [ + 420, + 230 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 3 + }, + { + "name": "multiple", + "type": "INT", + "link": null, + "widget": { + "name": "multiple" + } + }, + { + "name": "mode", + "type": "COMBO", + "link": null, + "widget": { + "name": "mode" + } + }, + { + "name": "crop_position", + "type": "COMBO", + "link": null, + "widget": { + "name": "crop_position" + } + }, + { + "name": "pad_position", + "type": "COMBO", + "link": null, + "widget": { + "name": "pad_position" + } + }, + { + "name": "pad_fill", + "type": "COMBO", + "link": null, + "widget": { + "name": "pad_fill" + } + }, + { + "name": "pad_color", + "type": "STRING", + "link": null, + "widget": { + "name": "pad_color" + } + } + ], + "outputs": [ + { + "name": "image", + "type": "IMAGE", + "links": [ + 4 + ], + "slot_index": 0 + }, + { + "name": "width", + "type": "INT", + "links": [], + "slot_index": 1 + }, + { + "name": "height", + "type": "INT", + "links": [], + "slot_index": 2 + } + ], + "properties": { + "cnr_id": "ausboss-nodes", + "Node name for S&R": "AUSBOSS_NODES_AlignImage", + "ver": "2.0.0" + }, + "widgets_values": [ + 64, + "pad", + "center", + "center", + "replicate", + "#000000" + ] + }, + { + "id": 6, + "type": "AUSBOSS_NODES_Compare", + "pos": [ + 960, + 390 + ], + "size": [ + 420, + 610 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "image_a", + "type": "IMAGE", + "link": 4 + }, + { + "name": "image_b", + "type": "IMAGE", + "link": 5 + } + ], + "outputs": [ + { + "name": "image_a", + "type": "IMAGE", + "links": [], + "slot_index": 0 + } + ], + "properties": { + "cnr_id": "ausboss-nodes", + "Node name for S&R": "AUSBOSS_NODES_Compare", + "ver": "2.0.0" + }, + "widgets_values": [], + "title": "Aligned image A · transformed image B" + }, + { + "id": 7, + "type": "AUSBOSS_NODES_VideoCropRotatePad", + "pos": [ + 1460, + 100 + ], + "size": [ + 460, + 1020 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "video", + "type": "COMBO", + "link": null, + "widget": { + "name": "video" + } + }, + { + "name": "source_mode", + "type": "COMBO", + "link": null, + "widget": { + "name": "source_mode" + } + }, + { + "name": "local_path", + "type": "STRING", + "link": null, + "widget": { + "name": "local_path" + } + }, + { + "name": "seek_mode", + "type": "COMBO", + "link": null, + "widget": { + "name": "seek_mode" + } + }, + { + "name": "frame_index", + "type": "INT", + "link": null, + "widget": { + "name": "frame_index" + } + }, + { + "name": "frame_time", + "type": "FLOAT", + "link": null, + "widget": { + "name": "frame_time" + } + }, + { + "name": "rotation_degrees", + "type": "FLOAT", + "link": null, + "widget": { + "name": "rotation_degrees" + } + }, + { + "name": "crop_aspect_ratio", + "type": "COMBO", + "link": null, + "widget": { + "name": "crop_aspect_ratio" + } + }, + { + "name": "crop_x", + "type": "INT", + "link": null, + "widget": { + "name": "crop_x" + } + }, + { + "name": "crop_y", + "type": "INT", + "link": null, + "widget": { + "name": "crop_y" + } + }, + { + "name": "crop_width", + "type": "INT", + "link": null, + "widget": { + "name": "crop_width" + } + }, + { + "name": "crop_height", + "type": "INT", + "link": null, + "widget": { + "name": "crop_height" + } + }, + { + "name": "pad_left", + "type": "INT", + "link": null, + "widget": { + "name": "pad_left" + } + }, + { + "name": "pad_top", + "type": "INT", + "link": null, + "widget": { + "name": "pad_top" + } + }, + { + "name": "pad_right", + "type": "INT", + "link": null, + "widget": { + "name": "pad_right" + } + }, + { + "name": "pad_bottom", + "type": "INT", + "link": null, + "widget": { + "name": "pad_bottom" + } + }, + { + "name": "feather", + "type": "INT", + "link": null, + "widget": { + "name": "feather" + } + }, + { + "name": "canvas_multiple", + "type": "INT", + "link": null, + "widget": { + "name": "canvas_multiple" + } + }, + { + "name": "fill_color", + "type": "STRING", + "link": null, + "widget": { + "name": "fill_color" + } + } + ], + "outputs": [ + { + "name": "image", + "type": "IMAGE", + "links": [ + 6 + ], + "slot_index": 0 + }, + { + "name": "mask", + "type": "MASK", + "links": [ + 7 + ], + "slot_index": 1 + } + ], + "properties": { + "cnr_id": "ausboss-nodes", + "Node name for S&R": "AUSBOSS_NODES_VideoCropRotatePad", + "ver": "2.0.0" + }, + "widgets_values": { + "video": "ausboss_pier_9x16.mp4", + "source_mode": "input folder", + "local_path": "", + "seek_mode": "frame index", + "frame_index": 24, + "frame_time": 0.0, + "rotation_degrees": -3, + "crop_aspect_ratio": "free", + "crop_x": 0, + "crop_y": 0, + "crop_width": 0, + "crop_height": 0, + "pad_left": 96, + "pad_top": 0, + "pad_right": 96, + "pad_bottom": 0, + "feather": 24, + "canvas_multiple": 8, + "fill_color": "#808080" + } + }, + { + "id": 8, + "type": "PreviewImage", + "pos": [ + 2000, + 100 + ], + "size": [ + 460, + 440 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 6 + } + ], + "outputs": [ + { + "name": "images", + "type": "IMAGE", + "links": [], + "slot_index": 0 + } + ], + "properties": { + "cnr_id": "comfy-core", + "Node name for S&R": "PreviewImage" + }, + "widgets_values": [], + "title": "Selected video frame · transformed" + }, + { + "id": 9, + "type": "MaskToImage", + "pos": [ + 2000, + 600 + ], + "size": [ + 460, + 70 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "name": "mask", + "type": "MASK", + "link": 7 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 8 + ], + "slot_index": 0 + } + ], + "properties": { + "cnr_id": "comfy-core", + "Node name for S&R": "MaskToImage" + }, + "widgets_values": [] + }, + { + "id": 10, + "type": "PreviewImage", + "pos": [ + 2000, + 730 + ], + "size": [ + 460, + 440 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 8 + } + ], + "outputs": [ + { + "name": "images", + "type": "IMAGE", + "links": [], + "slot_index": 0 + } + ], + "properties": { + "cnr_id": "comfy-core", + "Node name for S&R": "PreviewImage" + }, + "widgets_values": [], + "title": "Video frame · generated-area mask" + } + ], + "links": [ + [ + 1, + 2, + 1, + 3, + 0, + "MASK" + ], + [ + 2, + 3, + 0, + 4, + 0, + "IMAGE" + ], + [ + 3, + 2, + 0, + 5, + 0, + "IMAGE" + ], + [ + 4, + 5, + 0, + 6, + 0, + "IMAGE" + ], + [ + 5, + 2, + 0, + 6, + 1, + "IMAGE" + ], + [ + 6, + 7, + 0, + 8, + 0, + "IMAGE" + ], + [ + 7, + 7, + 1, + 9, + 0, + "MASK" + ], + [ + 8, + 9, + 0, + 10, + 0, + "IMAGE" + ] + ], + "groups": [ + { + "title": "1 · Image editor", + "bounding": [ + -20, + 40, + 480, + 980 + ], + "color": "#235d64", + "font_size": 24, + "flags": {} + }, + { + "title": "2 · Inspect the image mask", + "bounding": [ + 500, + 40, + 400, + 810 + ], + "color": "#366451", + "font_size": 24, + "flags": {} + }, + { + "title": "3 · Align + compare", + "bounding": [ + 940, + 40, + 460, + 980 + ], + "color": "#5d527c", + "font_size": 24, + "flags": {} + }, + { + "title": "4 · Video frame editor", + "bounding": [ + 1440, + 40, + 500, + 1100 + ], + "color": "#795b38", + "font_size": 24, + "flags": {} + }, + { + "title": "5 · Inspect frame + mask", + "bounding": [ + 1980, + 40, + 500, + 1150 + ], + "color": "#345d7c", + "font_size": 24, + "flags": {} + } + ], + "config": {}, + "extra": { + "ds": { + "scale": 0.5127, + "offset": [ + 816.04, + 301.58 + ] + } + }, + "version": 0.4 +} diff --git a/example_workflows/Klein 9B Edit (AusBoss).jpg b/example_workflows/Klein 9B Edit (AusBoss).jpg new file mode 100644 index 0000000..6cd70ce Binary files /dev/null and b/example_workflows/Klein 9B Edit (AusBoss).jpg differ diff --git a/example_workflows/Klein 9B Edit (AusBoss).json b/example_workflows/Klein 9B Edit (AusBoss).json new file mode 100644 index 0000000..a2d2028 --- /dev/null +++ b/example_workflows/Klein 9B Edit (AusBoss).json @@ -0,0 +1,1143 @@ +{ + "id": "", + "revision": 0, + "last_node_id": 20, + "last_link_id": 23, + "nodes": [ + { + "id": 1, + "type": "UNETLoader", + "pos": [ + -1060, + 100 + ], + "size": [ + 430, + 82 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 14 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader" + }, + "widgets_values": [ + "Flux.2 Klein 9B/flux-2-klein-9b-int8-convrot.safetensors", + "default" + ] + }, + { + "id": 2, + "type": "CLIPLoader", + "pos": [ + -1060, + 232 + ], + "size": [ + 430, + 106 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "CLIP", + "type": "CLIP", + "links": [ + 7 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader" + }, + "widgets_values": [ + "qwen_3_8b_fp8mixed.safetensors", + "flux2", + "default" + ] + }, + { + "id": 3, + "type": "VAELoader", + "pos": [ + -1060, + 388 + ], + "size": [ + 430, + 58 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "VAE", + "type": "VAE", + "links": [ + 6, + 20 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader" + }, + "widgets_values": [ + "flux2-vae.safetensors" + ] + }, + { + "id": 4, + "type": "LoadImage", + "pos": [ + -1060, + 530 + ], + "size": [ + 430, + 690 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 1 + ] + }, + { + "name": "MASK", + "type": "MASK", + "links": [] + } + ], + "properties": { + "Node name for S&R": "LoadImage" + }, + "widgets_values": [ + "ausboss_pier_sunrise.png", + "image" + ] + }, + { + "id": 5, + "type": "AUSBOSS_NODES_ImageResize", + "pos": [ + -580, + 100 + ], + "size": [ + 460, + 330 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 1 + }, + { + "name": "mask", + "type": "MASK", + "link": null, + "shape": 7 + } + ], + "outputs": [ + { + "name": "image", + "type": "IMAGE", + "links": [ + 2, + 5, + 22 + ] + }, + { + "name": "mask", + "type": "MASK", + "links": [] + }, + { + "name": "width", + "type": "INT", + "links": [] + }, + { + "name": "height", + "type": "INT", + "links": [] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_ImageResize", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "megapixels", + 0, + 0, + 1024, + 1.0, + 0.5, + "fit", + "#000000", + 16, + "lanczos" + ], + "title": "Image Resize 🆎 · Klein works at 1 MP" + }, + { + "id": 6, + "type": "AUSBOSS_NODES_ImageSize", + "pos": [ + -580, + 480 + ], + "size": [ + 460, + 130 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 2 + } + ], + "outputs": [ + { + "name": "width", + "type": "INT", + "links": [ + 3 + ] + }, + { + "name": "height", + "type": "INT", + "links": [ + 4 + ] + }, + { + "name": "longest_edge", + "type": "INT", + "links": [] + }, + { + "name": "shortest_edge", + "type": "INT", + "links": [] + }, + { + "name": "count", + "type": "INT", + "links": [] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_ImageSize", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [], + "title": "Image Size 🆎 · same size out as in" + }, + { + "id": 7, + "type": "EmptyFlux2LatentImage", + "pos": [ + -580, + 660 + ], + "size": [ + 460, + 106 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 3 + }, + { + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 4 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 17 + ] + } + ], + "properties": { + "Node name for S&R": "EmptyFlux2LatentImage" + }, + "widgets_values": [ + 1024, + 1024, + 1 + ] + }, + { + "id": 8, + "type": "VAEEncode", + "pos": [ + -580, + 816 + ], + "size": [ + 460, + 46 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "name": "pixels", + "type": "IMAGE", + "link": 5 + }, + { + "name": "vae", + "type": "VAE", + "link": 6 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 10, + 13 + ] + } + ], + "properties": { + "Node name for S&R": "VAEEncode" + }, + "widgets_values": [], + "title": "VAE Encode · source as reference" + }, + { + "id": 9, + "type": "AUSBOSS_NODES_Text", + "pos": [ + -60, + 100 + ], + "size": [ + 420, + 190 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "text", + "type": "STRING", + "links": [ + 8 + ] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_Text", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "Change the season to winter: fresh snow on the pier and along the shore, the far mountains white, a thin skin of ice on the lake with open water around the boat, pale overcast sky. Keep the rowboat and the composition." + ], + "title": "Text 🆎 · the edit instruction" + }, + { + "id": 10, + "type": "CLIPTextEncode", + "pos": [ + -60, + 340 + ], + "size": [ + 420, + 88 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 7 + }, + { + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 8 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 9, + 11 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "" + ], + "title": "CLIP Text Encode · positive" + }, + { + "id": 11, + "type": "ReferenceLatent", + "pos": [ + -60, + 478 + ], + "size": [ + 420, + 46 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "name": "conditioning", + "type": "CONDITIONING", + "link": 9 + }, + { + "name": "latent", + "type": "LATENT", + "link": 10, + "shape": 7 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 15 + ] + } + ], + "properties": { + "Node name for S&R": "ReferenceLatent" + }, + "widgets_values": [], + "title": "Reference Latent · positive" + }, + { + "id": 12, + "type": "ConditioningZeroOut", + "pos": [ + -60, + 574 + ], + "size": [ + 420, + 46 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "name": "conditioning", + "type": "CONDITIONING", + "link": 11 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 12 + ] + } + ], + "properties": { + "Node name for S&R": "ConditioningZeroOut" + }, + "widgets_values": [] + }, + { + "id": 13, + "type": "ReferenceLatent", + "pos": [ + -60, + 670 + ], + "size": [ + 420, + 46 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "name": "conditioning", + "type": "CONDITIONING", + "link": 12 + }, + { + "name": "latent", + "type": "LATENT", + "link": 13, + "shape": 7 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 16 + ] + } + ], + "properties": { + "Node name for S&R": "ReferenceLatent" + }, + "widgets_values": [], + "title": "Reference Latent · negative" + }, + { + "id": 14, + "type": "AUSBOSS_NODES_Seed", + "pos": [ + -60, + 766 + ], + "size": [ + 420, + 216 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "seed", + "type": "INT", + "links": [ + 18 + ] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_Seed", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + 20260903, + "fixed" + ] + }, + { + "id": 15, + "type": "KSampler", + "pos": [ + 460, + 100 + ], + "size": [ + 440, + 262 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 14 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 15 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 16 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 17 + }, + { + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 18 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 19 + ] + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 20260903, + "fixed", + 4, + 1.0, + "euler", + "simple", + 1.0 + ], + "title": "KSampler · Klein: 4 steps, CFG 1" + }, + { + "id": 16, + "type": "VAEDecode", + "pos": [ + 460, + 412 + ], + "size": [ + 440, + 46 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 19 + }, + { + "name": "vae", + "type": "VAE", + "link": 20 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 21 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + }, + "widgets_values": [] + }, + { + "id": 17, + "type": "AUSBOSS_NODES_RunTimer", + "pos": [ + 460, + 508 + ], + "size": [ + 196, + 62 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_RunTimer", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [] + }, + { + "id": 18, + "type": "AUSBOSS_NODES_Compare", + "pos": [ + 960, + 100 + ], + "size": [ + 560, + 560 + ], + "flags": {}, + "order": 18, + "mode": 0, + "inputs": [ + { + "name": "image_a", + "type": "IMAGE", + "link": 21 + }, + { + "name": "image_b", + "type": "IMAGE", + "link": 22 + } + ], + "outputs": [ + { + "name": "image_a", + "type": "IMAGE", + "links": [ + 23 + ] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_Compare", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "" + ], + "title": "Image Compare A/B 🆎 · edit vs source" + }, + { + "id": 19, + "type": "AUSBOSS_NODES_SaveImage", + "pos": [ + 960, + 710 + ], + "size": [ + 560, + 390 + ], + "flags": {}, + "order": 19, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 23 + }, + { + "name": "filename", + "type": "STRING", + "link": null, + "shape": 7, + "widget": { + "name": "filename" + } + }, + { + "name": "caption_text", + "type": "STRING", + "link": null, + "shape": 7, + "widget": { + "name": "caption_text" + } + } + ], + "outputs": [ + { + "name": "file_path", + "type": "STRING", + "links": [] + }, + { + "name": "images", + "type": "IMAGE", + "links": [] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_SaveImage", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "AusBoss/klein_edit", + "png", + true, + "", + "overwrite", + "", + "" + ] + }, + { + "id": 20, + "type": "AUSBOSS_NODES_WorkflowNote", + "pos": [ + -1680, + 100 + ], + "size": [ + 560, + 900 + ], + "flags": {}, + "order": 20, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_WorkflowNote", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "{\"v\": 1, \"title\": \"Klein 9B Edit\", \"subtitle\": \"One picture in, an instruction, one picture out. Image Resize sets the working size, Image Size keeps the output the same shape, Compare shows what changed.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"## Setup\\nCopy the supplied media from `example_workflows/inputs/` into `ComfyUI/input/`. Choose each installed model in its loader; saved selections may include local subfolders. Download links below name the standard destination folders.\\n\\n## How it works\\n1. **Load Image → Image Resize** — the source is brought to a 1 MP budget in steps of 16, the size Klein 9B was trained to edit at. **Image Size** reads that size back so the output latent matches the source exactly and nothing is stretched.\\n2. **Prompt + reference** — the resized picture is VAE-encoded once and attached as a *reference latent* to both the positive prompt and the zeroed negative; that is how Klein sees the image it is editing. The **Text** node holds the instruction: say what changes and what stays.\\n3. **Sample** — the distilled recipe: 4 steps, CFG 1, `euler` / `simple`, on a fresh latent. **Seed** is pinned so reruns are comparable; *Use last run* brings a good one back.\\n4. **Compare + save** — **Compare** slides the edit against the resized source; **Save Image** writes a PNG with the workflow embedded. About 5 s on a 32 GB card once the models are loaded.\\n\\n## Try\\n- **Bigger output**: raise *Megapixels* on Image Resize to 1.5–2. Klein holds together to about 2 MP; past that details start to drift from the source.\\n- **Partial edits**: for a change confined to one region, use the *Klein 9B Inpaint* example — it crops around a painted mask and stitches only that area back.\\n- **Second reference**: a second Reference Latent chained after the first lets you hand Klein an object or a style image alongside the source.\", \"models\": [{\"name\": \"flux-2-klein-9b-int8-convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"\", \"url\": \"https://huggingface.co/Winnougan/Klein9b-Distilled-Base-INT8-Convrot/resolve/main/flux-2-klein-9b-int8-convrot.safetensors\", \"note\": \"Tested build. Select its installed subfolder in the loader; use a distilled Klein 9B model.\"}, {\"name\": \"qwen_3_8b_fp8mixed.safetensors\", \"dir\": \"text_encoders\", \"size\": \"8.7 GB\", \"url\": \"https://huggingface.co/Comfy-Org/flux2-klein-9B/resolve/main/split_files/text_encoders/qwen_3_8b_fp8mixed.safetensors\", \"note\": \"\"}, {\"name\": \"flux2-vae.safetensors\", \"dir\": \"vae\", \"size\": \"336 MB\", \"url\": \"https://huggingface.co/Comfy-Org/flux2-klein-9B/resolve/main/split_files/vae/flux2-vae.safetensors\", \"note\": \"\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_ImageResize\"}], \"links\": [{\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}" + ] + } + ], + "links": [ + [ + 1, + 4, + 0, + 5, + 0, + "IMAGE" + ], + [ + 2, + 5, + 0, + 6, + 0, + "IMAGE" + ], + [ + 3, + 6, + 0, + 7, + 0, + "INT" + ], + [ + 4, + 6, + 1, + 7, + 1, + "INT" + ], + [ + 5, + 5, + 0, + 8, + 0, + "IMAGE" + ], + [ + 6, + 3, + 0, + 8, + 1, + "VAE" + ], + [ + 7, + 2, + 0, + 10, + 0, + "CLIP" + ], + [ + 8, + 9, + 0, + 10, + 1, + "STRING" + ], + [ + 9, + 10, + 0, + 11, + 0, + "CONDITIONING" + ], + [ + 10, + 8, + 0, + 11, + 1, + "LATENT" + ], + [ + 11, + 10, + 0, + 12, + 0, + "CONDITIONING" + ], + [ + 12, + 12, + 0, + 13, + 0, + "CONDITIONING" + ], + [ + 13, + 8, + 0, + 13, + 1, + "LATENT" + ], + [ + 14, + 1, + 0, + 15, + 0, + "MODEL" + ], + [ + 15, + 11, + 0, + 15, + 1, + "CONDITIONING" + ], + [ + 16, + 13, + 0, + 15, + 2, + "CONDITIONING" + ], + [ + 17, + 7, + 0, + 15, + 3, + "LATENT" + ], + [ + 18, + 14, + 0, + 15, + 4, + "INT" + ], + [ + 19, + 15, + 0, + 16, + 0, + "LATENT" + ], + [ + 20, + 3, + 0, + 16, + 1, + "VAE" + ], + [ + 21, + 16, + 0, + 18, + 0, + "IMAGE" + ], + [ + 22, + 5, + 0, + 18, + 1, + "IMAGE" + ], + [ + 23, + 18, + 0, + 19, + 0, + "IMAGE" + ] + ], + "groups": [ + { + "id": 1, + "title": "1 · Models", + "bounding": [ + -1080, + 40, + 470, + 426 + ], + "flags": {}, + "font_size": 24, + "color": "#235d64" + }, + { + "id": 2, + "title": "2 · Source", + "bounding": [ + -1080, + 470, + 470, + 770 + ], + "flags": {}, + "font_size": 24, + "color": "#366451" + }, + { + "id": 3, + "title": "3 · Working size + reference", + "bounding": [ + -600, + 40, + 500, + 842 + ], + "flags": {}, + "font_size": 24, + "color": "#5d527c" + }, + { + "id": 4, + "title": "4 · Instruction, seed", + "bounding": [ + -80, + 40, + 460, + 962 + ], + "flags": {}, + "font_size": 24, + "color": "#795b38" + }, + { + "id": 5, + "title": "5 · Sample", + "bounding": [ + 440, + 40, + 480, + 550 + ], + "flags": {}, + "font_size": 24, + "color": "#345d7c" + }, + { + "id": 6, + "title": "6 · Compare, save", + "bounding": [ + 940, + 40, + 600, + 1080 + ], + "flags": {}, + "font_size": 24, + "color": "#65515e" + } + ], + "config": {}, + "extra": { + "ds": { + "scale": 0.4939, + "offset": [ + 1881.98, + 314.2 + ] + } + }, + "version": 0.4 +} diff --git a/example_workflows/Klein 9B Inpaint (AusBoss).jpg b/example_workflows/Klein 9B Inpaint (AusBoss).jpg new file mode 100644 index 0000000..c93e41c Binary files /dev/null and b/example_workflows/Klein 9B Inpaint (AusBoss).jpg differ diff --git a/example_workflows/Klein 9B Inpaint (AusBoss).json b/example_workflows/Klein 9B Inpaint (AusBoss).json new file mode 100644 index 0000000..2350847 --- /dev/null +++ b/example_workflows/Klein 9B Inpaint (AusBoss).json @@ -0,0 +1,1288 @@ +{ + "id": "", + "revision": 0, + "last_node_id": 22, + "last_link_id": 27, + "nodes": [ + { + "id": 1, + "type": "UNETLoader", + "pos": [ + -1060, + 100 + ], + "size": [ + 430, + 82 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 16 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader" + }, + "widgets_values": [ + "Flux.2 Klein 9B/flux-2-klein-9b-int8-convrot.safetensors", + "default" + ] + }, + { + "id": 2, + "type": "CLIPLoader", + "pos": [ + -1060, + 232 + ], + "size": [ + 430, + 106 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "CLIP", + "type": "CLIP", + "links": [ + 9 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader" + }, + "widgets_values": [ + "qwen_3_8b_fp8mixed.safetensors", + "flux2", + "default" + ] + }, + { + "id": 3, + "type": "VAELoader", + "pos": [ + -1060, + 388 + ], + "size": [ + 430, + 58 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "VAE", + "type": "VAE", + "links": [ + 8, + 22 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader" + }, + "widgets_values": [ + "flux2-vae.safetensors" + ] + }, + { + "id": 4, + "type": "LoadImage", + "pos": [ + -1060, + 530 + ], + "size": [ + 430, + 690 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 2, + 26 + ] + }, + { + "name": "MASK", + "type": "MASK", + "links": [ + 1 + ] + } + ], + "properties": { + "Node name for S&R": "LoadImage" + }, + "widgets_values": [ + "ausboss_pier_rowboat.png", + "image" + ], + "title": "Load Image · right-click → Open in MaskEditor" + }, + { + "id": 5, + "type": "AUSBOSS_NODES_RefineMask", + "pos": [ + -580, + 100 + ], + "size": [ + 460, + 470 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "mask", + "type": "MASK", + "link": 1 + }, + { + "name": "guide_image", + "type": "IMAGE", + "link": null, + "shape": 7 + } + ], + "outputs": [ + { + "name": "mask", + "type": "MASK", + "links": [ + 3 + ] + }, + { + "name": "mask_inverted", + "type": "MASK", + "links": [] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_RefineMask", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + 12, + 4.0, + true, + 0, + 0.0, + 1.0, + "off", + true + ], + "title": "Mask Refine 🆎 · grow + soften the painted mask" + }, + { + "id": 6, + "type": "AUSBOSS_NODES_CropForInpaint", + "pos": [ + -580, + 620 + ], + "size": [ + 460, + 316 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 2 + }, + { + "name": "mask", + "type": "MASK", + "link": 3 + } + ], + "outputs": [ + { + "name": "image", + "type": "IMAGE", + "links": [ + 4, + 7 + ] + }, + { + "name": "mask", + "type": "MASK", + "links": [] + }, + { + "name": "stitcher", + "type": "AUSBOSS_STITCHER", + "links": [ + 23 + ] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_CropForInpaint", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + 2.0, + 24, + 16, + 0, + 0, + 0, + 0.0, + false, + 192, + 1.0, + "bicubic", + 0, + 0, + 0, + 0 + ], + "title": "Crop For Inpaint 🆎 · the model only sees this crop" + }, + { + "id": 7, + "type": "AUSBOSS_NODES_ImageSize", + "pos": [ + -60, + 100 + ], + "size": [ + 300, + 130 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 4 + } + ], + "outputs": [ + { + "name": "width", + "type": "INT", + "links": [ + 5 + ] + }, + { + "name": "height", + "type": "INT", + "links": [ + 6 + ] + }, + { + "name": "longest_edge", + "type": "INT", + "links": [] + }, + { + "name": "shortest_edge", + "type": "INT", + "links": [] + }, + { + "name": "count", + "type": "INT", + "links": [] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_ImageSize", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [], + "title": "Image Size 🆎 · crop → latent" + }, + { + "id": 8, + "type": "EmptyFlux2LatentImage", + "pos": [ + -60, + 280 + ], + "size": [ + 400, + 106 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 5 + }, + { + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 6 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 19 + ] + } + ], + "properties": { + "Node name for S&R": "EmptyFlux2LatentImage" + }, + "widgets_values": [ + 1024, + 1024, + 1 + ] + }, + { + "id": 9, + "type": "VAEEncode", + "pos": [ + -60, + 436 + ], + "size": [ + 400, + 46 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "name": "pixels", + "type": "IMAGE", + "link": 7 + }, + { + "name": "vae", + "type": "VAE", + "link": 8 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 12, + 15 + ] + } + ], + "properties": { + "Node name for S&R": "VAEEncode" + }, + "widgets_values": [], + "title": "VAE Encode · crop as reference" + }, + { + "id": 10, + "type": "AUSBOSS_NODES_Text", + "pos": [ + -60, + 532 + ], + "size": [ + 400, + 190 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "text", + "type": "STRING", + "links": [ + 10 + ] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_Text", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "Replace the rowboat with a white swan floating on the water." + ], + "title": "Text 🆎 · the edit instruction" + }, + { + "id": 11, + "type": "CLIPTextEncode", + "pos": [ + -60, + 772 + ], + "size": [ + 400, + 88 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 9 + }, + { + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 10 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 11, + 13 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "" + ], + "title": "CLIP Text Encode · positive" + }, + { + "id": 12, + "type": "ReferenceLatent", + "pos": [ + -60, + 910 + ], + "size": [ + 400, + 46 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "name": "conditioning", + "type": "CONDITIONING", + "link": 11 + }, + { + "name": "latent", + "type": "LATENT", + "link": 12, + "shape": 7 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 17 + ] + } + ], + "properties": { + "Node name for S&R": "ReferenceLatent" + }, + "widgets_values": [], + "title": "Reference Latent · positive" + }, + { + "id": 13, + "type": "ConditioningZeroOut", + "pos": [ + -60, + 1006 + ], + "size": [ + 400, + 46 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "name": "conditioning", + "type": "CONDITIONING", + "link": 13 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 14 + ] + } + ], + "properties": { + "Node name for S&R": "ConditioningZeroOut" + }, + "widgets_values": [] + }, + { + "id": 14, + "type": "ReferenceLatent", + "pos": [ + -60, + 1102 + ], + "size": [ + 400, + 46 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "name": "conditioning", + "type": "CONDITIONING", + "link": 14 + }, + { + "name": "latent", + "type": "LATENT", + "link": 15, + "shape": 7 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 18 + ] + } + ], + "properties": { + "Node name for S&R": "ReferenceLatent" + }, + "widgets_values": [], + "title": "Reference Latent · negative" + }, + { + "id": 15, + "type": "AUSBOSS_NODES_Seed", + "pos": [ + 460, + 100 + ], + "size": [ + 460, + 216 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "seed", + "type": "INT", + "links": [ + 20 + ] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_Seed", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + 20260903, + "fixed" + ] + }, + { + "id": 16, + "type": "KSampler", + "pos": [ + 460, + 366 + ], + "size": [ + 460, + 262 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 16 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 17 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 18 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 19 + }, + { + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 20 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 21 + ] + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 20260903, + "fixed", + 4, + 1.0, + "euler", + "simple", + 1.0 + ], + "title": "KSampler · Klein: 4 steps, CFG 1" + }, + { + "id": 17, + "type": "VAEDecode", + "pos": [ + 460, + 678 + ], + "size": [ + 460, + 46 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 21 + }, + { + "name": "vae", + "type": "VAE", + "link": 22 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 24 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + }, + "widgets_values": [] + }, + { + "id": 18, + "type": "AUSBOSS_NODES_StitchInpaint", + "pos": [ + 460, + 774 + ], + "size": [ + 460, + 145 + ], + "flags": {}, + "order": 18, + "mode": 0, + "inputs": [ + { + "name": "stitcher", + "type": "AUSBOSS_STITCHER", + "link": 23 + }, + { + "name": "inpainted", + "type": "IMAGE", + "link": 24 + } + ], + "outputs": [ + { + "name": "image", + "type": "IMAGE", + "links": [ + 25 + ] + }, + { + "name": "blend_mask", + "type": "MASK", + "links": [] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_StitchInpaint", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + false, + 0.5 + ], + "title": "Stitch Inpaint 🆎 · only the mask changes" + }, + { + "id": 19, + "type": "AUSBOSS_NODES_RunTimer", + "pos": [ + 460, + 969 + ], + "size": [ + 196, + 62 + ], + "flags": {}, + "order": 19, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_RunTimer", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [] + }, + { + "id": 20, + "type": "AUSBOSS_NODES_Compare", + "pos": [ + 960, + 100 + ], + "size": [ + 560, + 560 + ], + "flags": {}, + "order": 20, + "mode": 0, + "inputs": [ + { + "name": "image_a", + "type": "IMAGE", + "link": 25 + }, + { + "name": "image_b", + "type": "IMAGE", + "link": 26 + } + ], + "outputs": [ + { + "name": "image_a", + "type": "IMAGE", + "links": [ + 27 + ] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_Compare", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "" + ], + "title": "Image Compare A/B 🆎 · result vs source" + }, + { + "id": 21, + "type": "AUSBOSS_NODES_SaveImage", + "pos": [ + 960, + 710 + ], + "size": [ + 560, + 390 + ], + "flags": {}, + "order": 21, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 27 + }, + { + "name": "filename", + "type": "STRING", + "link": null, + "shape": 7, + "widget": { + "name": "filename" + } + }, + { + "name": "caption_text", + "type": "STRING", + "link": null, + "shape": 7, + "widget": { + "name": "caption_text" + } + } + ], + "outputs": [ + { + "name": "file_path", + "type": "STRING", + "links": [] + }, + { + "name": "images", + "type": "IMAGE", + "links": [] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_SaveImage", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "AusBoss/klein_inpaint", + "png", + true, + "", + "overwrite", + "", + "" + ] + }, + { + "id": 22, + "type": "AUSBOSS_NODES_WorkflowNote", + "pos": [ + -1680, + 100 + ], + "size": [ + 560, + 1000 + ], + "flags": {}, + "order": 22, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_WorkflowNote", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "{\"v\": 1, \"title\": \"Klein 9B Inpaint\", \"subtitle\": \"Paint a mask, describe the change. Crop For Inpaint hands Klein 9B just the masked region at a sharp working size and Stitch Inpaint puts the result back where it came from.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"## Setup\\nCopy the supplied media from `example_workflows/inputs/` into `ComfyUI/input/`. Choose each installed model in its loader; saved selections may include local subfolders. Download links below name the standard destination folders.\\n\\n## How it works\\n1. **Load Image** — the sample already carries a painted mask over the rowboat. For your own picture, right-click the node → *Open in MaskEditor* and paint what should change.\\n2. **Mask Refine** — grows the mask a little and softens its edge so the seam has room to blend. The preview shows exactly what the stitch will use.\\n3. **Crop For Inpaint** — cuts the masked region plus context out of the picture and scales that crop to 1 MP. Klein only ever sees this crop, so a small edit on a big photo stays fast and sharp; **Image Size** hands the crop's size to the latent.\\n4. **Prompt + reference** — Klein 9B is an edit model: the crop goes in as a *reference latent* on both the positive and the (zeroed) negative, and the **Text** node holds the instruction. Describe the change, not the whole picture.\\n5. **Sample** — the distilled Klein recipe: 4 steps, CFG 1, `euler` / `simple`. **Seed** is pinned; *Use last run* brings a good one back.\\n6. **Stitch, compare, save** — **Stitch Inpaint** pastes only the masked area back into the original, feathered by the crop's blend margin, with a light colour match. **Compare** slides result against source; **Save Image** writes a PNG with the workflow embedded. About 3 s on a 32 GB card once the models are loaded.\\n\\n## Try\\n- **Bigger context**: raise *Context factor* (or *Context pixels*) on Crop For Inpaint when the edit needs to see more of the scene — a reflection, a shadow, a matching texture.\\n- **Soft vs hard seams**: *Blend pixels* on Crop For Inpaint is the stitch feather; *Expand* on Mask Refine decides how far outside your brush strokes the change may reach.\\n- **Removals**: \\\"remove the rowboat, calm water where it was\\\" works too; distilled Klein at CFG 1 sometimes re-renders the object instead — reroll the seed or grow the mask.\\n- Swap the sample for your own photo: everything downstream adapts to the mask.\", \"models\": [{\"name\": \"flux-2-klein-9b-int8-convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"\", \"url\": \"https://huggingface.co/Winnougan/Klein9b-Distilled-Base-INT8-Convrot/resolve/main/flux-2-klein-9b-int8-convrot.safetensors\", \"note\": \"Tested build. Select its installed subfolder in the loader; use a distilled Klein 9B model.\"}, {\"name\": \"qwen_3_8b_fp8mixed.safetensors\", \"dir\": \"text_encoders\", \"size\": \"8.7 GB\", \"url\": \"https://huggingface.co/Comfy-Org/flux2-klein-9B/resolve/main/split_files/text_encoders/qwen_3_8b_fp8mixed.safetensors\", \"note\": \"\"}, {\"name\": \"flux2-vae.safetensors\", \"dir\": \"vae\", \"size\": \"336 MB\", \"url\": \"https://huggingface.co/Comfy-Org/flux2-klein-9B/resolve/main/split_files/vae/flux2-vae.safetensors\", \"note\": \"\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_CropForInpaint\"}], \"links\": [{\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}" + ] + } + ], + "links": [ + [ + 1, + 4, + 1, + 5, + 0, + "MASK" + ], + [ + 2, + 4, + 0, + 6, + 0, + "IMAGE" + ], + [ + 3, + 5, + 0, + 6, + 1, + "MASK" + ], + [ + 4, + 6, + 0, + 7, + 0, + "IMAGE" + ], + [ + 5, + 7, + 0, + 8, + 0, + "INT" + ], + [ + 6, + 7, + 1, + 8, + 1, + "INT" + ], + [ + 7, + 6, + 0, + 9, + 0, + "IMAGE" + ], + [ + 8, + 3, + 0, + 9, + 1, + "VAE" + ], + [ + 9, + 2, + 0, + 11, + 0, + "CLIP" + ], + [ + 10, + 10, + 0, + 11, + 1, + "STRING" + ], + [ + 11, + 11, + 0, + 12, + 0, + "CONDITIONING" + ], + [ + 12, + 9, + 0, + 12, + 1, + "LATENT" + ], + [ + 13, + 11, + 0, + 13, + 0, + "CONDITIONING" + ], + [ + 14, + 13, + 0, + 14, + 0, + "CONDITIONING" + ], + [ + 15, + 9, + 0, + 14, + 1, + "LATENT" + ], + [ + 16, + 1, + 0, + 16, + 0, + "MODEL" + ], + [ + 17, + 12, + 0, + 16, + 1, + "CONDITIONING" + ], + [ + 18, + 14, + 0, + 16, + 2, + "CONDITIONING" + ], + [ + 19, + 8, + 0, + 16, + 3, + "LATENT" + ], + [ + 20, + 15, + 0, + 16, + 4, + "INT" + ], + [ + 21, + 16, + 0, + 17, + 0, + "LATENT" + ], + [ + 22, + 3, + 0, + 17, + 1, + "VAE" + ], + [ + 23, + 6, + 2, + 18, + 0, + "AUSBOSS_STITCHER" + ], + [ + 24, + 17, + 0, + 18, + 1, + "IMAGE" + ], + [ + 25, + 18, + 0, + 20, + 0, + "IMAGE" + ], + [ + 26, + 4, + 0, + 20, + 1, + "IMAGE" + ], + [ + 27, + 20, + 0, + 21, + 0, + "IMAGE" + ] + ], + "groups": [ + { + "id": 1, + "title": "1 · Models", + "bounding": [ + -1080, + 40, + 470, + 426 + ], + "flags": {}, + "font_size": 24, + "color": "#235d64" + }, + { + "id": 2, + "title": "2 · Picture + painted mask", + "bounding": [ + -1080, + 470, + 470, + 770 + ], + "flags": {}, + "font_size": 24, + "color": "#366451" + }, + { + "id": 3, + "title": "3 · Refine the mask, crop the region", + "bounding": [ + -600, + 40, + 500, + 916 + ], + "flags": {}, + "font_size": 24, + "color": "#5d527c" + }, + { + "id": 4, + "title": "4 · Prompt + reference", + "bounding": [ + -80, + 40, + 440, + 1128 + ], + "flags": {}, + "font_size": 24, + "color": "#795b38" + }, + { + "id": 5, + "title": "5 · Sample + stitch", + "bounding": [ + 440, + 40, + 500, + 1011 + ], + "flags": {}, + "font_size": 24, + "color": "#345d7c" + }, + { + "id": 6, + "title": "6 · Compare, save", + "bounding": [ + 940, + 40, + 600, + 1080 + ], + "flags": {}, + "font_size": 24, + "color": "#65515e" + } + ], + "config": {}, + "extra": { + "ds": { + "scale": 0.4939, + "offset": [ + 1881.98, + 314.2 + ] + } + }, + "version": 0.4 +} diff --git a/example_workflows/Klein 9B Outpaint (AusBoss).jpg b/example_workflows/Klein 9B Outpaint (AusBoss).jpg new file mode 100644 index 0000000..9df2842 Binary files /dev/null and b/example_workflows/Klein 9B Outpaint (AusBoss).jpg differ diff --git a/example_workflows/Klein 9B Outpaint (AusBoss).json b/example_workflows/Klein 9B Outpaint (AusBoss).json new file mode 100644 index 0000000..d70c5ec --- /dev/null +++ b/example_workflows/Klein 9B Outpaint (AusBoss).json @@ -0,0 +1,1148 @@ +{ + "id": "", + "revision": 0, + "last_node_id": 21, + "last_link_id": 29, + "nodes": [ + { + "id": 1, + "type": "UNETLoader", + "pos": [ + -1060, + 100 + ], + "size": [ + 430, + 82 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 1 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader" + }, + "widgets_values": [ + "Flux.2 Klein 9B/flux-2-klein-9b-int8-convrot.safetensors", + "default" + ] + }, + { + "id": 2, + "type": "CLIPLoader", + "pos": [ + -1060, + 232 + ], + "size": [ + 430, + 106 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "CLIP", + "type": "CLIP", + "links": [ + 6 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader" + }, + "widgets_values": [ + "qwen_3_8b_fp8mixed.safetensors", + "flux2", + "default" + ] + }, + { + "id": 3, + "type": "VAELoader", + "pos": [ + -1060, + 388 + ], + "size": [ + 430, + 58 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "VAE", + "type": "VAE", + "links": [ + 3, + 19 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader" + }, + "widgets_values": [ + "flux2-vae.safetensors" + ] + }, + { + "id": 4, + "type": "AUSBOSS_NODES_LoraLoader", + "pos": [ + -1060, + 496 + ], + "size": [ + 430, + 188 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 1 + }, + { + "name": "clip", + "type": "CLIP", + "link": null, + "shape": 7 + } + ], + "outputs": [ + { + "name": "model", + "type": "MODEL", + "links": [ + 13 + ] + }, + { + "name": "clip", + "type": "CLIP", + "links": [] + }, + { + "name": "triggers", + "type": "STRING", + "links": [] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_LoraLoader", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "[{\"name\":\"Flux.2 Klein 9B/pixaoutpaint.safetensors\",\"strength\":1,\"enabled\":true,\"triggers\":\"pixaoutpaint\"}]", + ", ", + "" + ], + "title": "LoRA Loader 🆎 · the outpaint LoRA" + }, + { + "id": 5, + "type": "AUSBOSS_NODES_LoadImagePad", + "pos": [ + -580, + 100 + ], + "size": [ + 460, + 760 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "image", + "type": "IMAGE", + "links": [ + 2, + 23 + ] + }, + { + "name": "mask", + "type": "MASK", + "links": [ + 29 + ] + }, + { + "name": "width", + "type": "INT", + "links": [] + }, + { + "name": "height", + "type": "INT", + "links": [] + }, + { + "name": "stitcher", + "type": "AUSBOSS_STITCHER", + "links": [ + 20 + ] + }, + { + "name": "reference", + "type": "IMAGE", + "links": [] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_LoadImagePad", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "ausboss_pier_sunrise.png", + 320, + 192, + 128, + 256, + "color", + "#808080", + 0.5, + 64, + 16, + 1.6, + "", + "" + ], + "title": "Load Image + Pad 🆎 · drag any edge outward" + }, + { + "id": 6, + "type": "VAEEncode", + "pos": [ + -580, + 910 + ], + "size": [ + 460, + 46 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "pixels", + "type": "IMAGE", + "link": 2 + }, + { + "name": "vae", + "type": "VAE", + "link": 3 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 9, + 12, + 28 + ] + } + ], + "properties": { + "Node name for S&R": "VAEEncode" + }, + "widgets_values": [], + "title": "VAE Encode · the grey-padded canvas" + }, + { + "id": 8, + "type": "AUSBOSS_NODES_Text", + "pos": [ + -60, + 100 + ], + "size": [ + 420, + 190 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "text", + "type": "STRING", + "links": [ + 7 + ] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_Text", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "pixaoutpaint, replace all solid gray areas with a seamless continuation of the surrounding scene." + ], + "title": "Text 🆎 · trigger word + instruction" + }, + { + "id": 9, + "type": "CLIPTextEncode", + "pos": [ + -60, + 340 + ], + "size": [ + 420, + 88 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 6 + }, + { + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 7 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 8, + 10 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "" + ], + "title": "CLIP Text Encode · positive" + }, + { + "id": 10, + "type": "ReferenceLatent", + "pos": [ + -60, + 478 + ], + "size": [ + 420, + 46 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "name": "conditioning", + "type": "CONDITIONING", + "link": 8 + }, + { + "name": "latent", + "type": "LATENT", + "link": 9, + "shape": 7 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 14 + ] + } + ], + "properties": { + "Node name for S&R": "ReferenceLatent" + }, + "widgets_values": [], + "title": "Reference Latent · positive" + }, + { + "id": 11, + "type": "ConditioningZeroOut", + "pos": [ + -60, + 574 + ], + "size": [ + 420, + 46 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "name": "conditioning", + "type": "CONDITIONING", + "link": 10 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 11 + ] + } + ], + "properties": { + "Node name for S&R": "ConditioningZeroOut" + }, + "widgets_values": [] + }, + { + "id": 12, + "type": "ReferenceLatent", + "pos": [ + -60, + 670 + ], + "size": [ + 420, + 46 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "name": "conditioning", + "type": "CONDITIONING", + "link": 11 + }, + { + "name": "latent", + "type": "LATENT", + "link": 12, + "shape": 7 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 15 + ] + } + ], + "properties": { + "Node name for S&R": "ReferenceLatent" + }, + "widgets_values": [], + "title": "Reference Latent · negative" + }, + { + "id": 13, + "type": "AUSBOSS_NODES_Seed", + "pos": [ + -60, + 766 + ], + "size": [ + 420, + 216 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "seed", + "type": "INT", + "links": [ + 17 + ] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_Seed", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + 20260903, + "fixed" + ] + }, + { + "id": 14, + "type": "KSampler", + "pos": [ + 460, + 100 + ], + "size": [ + 440, + 262 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 13 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 14 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 15 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 27 + }, + { + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 17 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 18 + ] + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 20260903, + "fixed", + 4, + 1.0, + "euler", + "simple", + 1.0 + ], + "title": "KSampler · Klein: 4 steps, CFG 1" + }, + { + "id": 15, + "type": "VAEDecode", + "pos": [ + 460, + 412 + ], + "size": [ + 440, + 46 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 18 + }, + { + "name": "vae", + "type": "VAE", + "link": 19 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 21 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + }, + "widgets_values": [] + }, + { + "id": 16, + "type": "AUSBOSS_NODES_StitchInpaint", + "pos": [ + 460, + 508 + ], + "size": [ + 440, + 145 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "name": "stitcher", + "type": "AUSBOSS_STITCHER", + "link": 20 + }, + { + "name": "inpainted", + "type": "IMAGE", + "link": 21 + } + ], + "outputs": [ + { + "name": "image", + "type": "IMAGE", + "links": [ + 22 + ] + }, + { + "name": "blend_mask", + "type": "MASK", + "links": [] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_StitchInpaint", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + false, + 1.0 + ], + "title": "Stitch Inpaint 🆎 · original pixels back, seams matched" + }, + { + "id": 17, + "type": "AUSBOSS_NODES_RunTimer", + "pos": [ + 460, + 703 + ], + "size": [ + 196, + 62 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_RunTimer", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [] + }, + { + "id": 18, + "type": "AUSBOSS_NODES_Compare", + "pos": [ + 960, + 100 + ], + "size": [ + 560, + 560 + ], + "flags": {}, + "order": 18, + "mode": 0, + "inputs": [ + { + "name": "image_a", + "type": "IMAGE", + "link": 22 + }, + { + "name": "image_b", + "type": "IMAGE", + "link": 23 + } + ], + "outputs": [ + { + "name": "image_a", + "type": "IMAGE", + "links": [ + 24 + ] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_Compare", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "" + ], + "title": "Image Compare A/B 🆎 · result vs padded canvas" + }, + { + "id": 19, + "type": "AUSBOSS_NODES_SaveImage", + "pos": [ + 960, + 710 + ], + "size": [ + 560, + 390 + ], + "flags": {}, + "order": 19, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 24 + }, + { + "name": "filename", + "type": "STRING", + "link": null, + "shape": 7, + "widget": { + "name": "filename" + } + }, + { + "name": "caption_text", + "type": "STRING", + "link": null, + "shape": 7, + "widget": { + "name": "caption_text" + } + } + ], + "outputs": [ + { + "name": "file_path", + "type": "STRING", + "links": [] + }, + { + "name": "images", + "type": "IMAGE", + "links": [] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_SaveImage", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "AusBoss/klein_outpaint", + "png", + true, + "", + "overwrite", + "", + "" + ] + }, + { + "id": 20, + "type": "AUSBOSS_NODES_WorkflowNote", + "pos": [ + -1680, + 100 + ], + "size": [ + 560, + 1000 + ], + "flags": {}, + "order": 20, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_WorkflowNote", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "{\"v\": 1, \"title\": \"Klein 9B Outpaint\", \"subtitle\": \"Extend a picture with PixaOutpaint for Klein 9B. Condition on the padded source, sample its masked latent, then restore protected pixels with Stitch Inpaint.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"## Setup\\nCopy the supplied media from `example_workflows/inputs/` into `ComfyUI/input/`. Choose the installed Klein 9B diffusion model, Qwen encoder and Flux 2 VAE in their loaders; saved selections may include local subfolders. Add PixaOutpaint at strength 1. Download links below name the standard destination folders.\\n\\n## How it works\\n1. **Load Image + Pad** — drag the borders outward. Keep the fill flat gray (#808080). The example uses a 1.6 MP canvas and a 64 px seam feather.\\n2. **Reference and starting latent** — VAE Encode supplies the padded reference to both conditioning branches. Set Latent Noise Mask also sends that encoded canvas to the sampler with the padding mask. The model generates the new borders and blends across the seam while the known image anchors the result.\\n3. **Prompt** — keep the pixaoutpaint trigger and the instruction to replace solid gray areas. In testing, alternative wording left borders unfilled even with the same seed. Add scene details after this instruction.\\n4. **Sample** — this distilled Klein 9B example uses 4 steps, CFG 1, euler/simple and full denoise. The Seed node controls repeatability.\\n5. **Stitch** — restore the source outside the 64 px feathered seam. Tone match starts at 1; reduce it if it shifts a new region too far. Compare the result against the padded input, then save.\\n\\n## If a border stays gray\\nCheck that the PixaOutpaint LoRA is enabled and the trigger matches. Restore the supplied prompt, try another seed, or extend fewer sides per pass. Tone match blends a finished outpaint; it cannot fill a region the model left gray.\\n\\n## Tested\\nFour-sided 1120 × 1456 outpaint with the supplied pier image, PixaOutpaint and the INT8 Klein 9B model. Compared raw and stitched images at the same seed: the masked starting latent and 64 px feather reduced the seam left by the empty-latent/24 px setup. The protected source interior remained pixel-exact. Image content, seed and padding amount still affect the result.\", \"models\": [{\"name\": \"flux-2-klein-9b-int8-convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"\", \"url\": \"https://huggingface.co/Winnougan/Klein9b-Distilled-Base-INT8-Convrot/resolve/main/flux-2-klein-9b-int8-convrot.safetensors\", \"note\": \"Tested build. Select its installed subfolder in the loader; use a distilled Klein 9B model.\"}, {\"name\": \"qwen_3_8b_fp8mixed.safetensors\", \"dir\": \"text_encoders\", \"size\": \"8.7 GB\", \"url\": \"https://huggingface.co/Comfy-Org/flux2-klein-9B/resolve/main/split_files/text_encoders/qwen_3_8b_fp8mixed.safetensors\", \"note\": \"\"}, {\"name\": \"flux2-vae.safetensors\", \"dir\": \"vae\", \"size\": \"336 MB\", \"url\": \"https://huggingface.co/Comfy-Org/flux2-klein-9B/resolve/main/split_files/vae/flux2-vae.safetensors\", \"note\": \"\"}, {\"name\": \"pixaoutpaint.safetensors\", \"dir\": \"loras\", \"size\": \"\", \"url\": \"https://huggingface.co/Pixaroma/experimental_loras/resolve/main/pixaoutpaint.safetensors\", \"note\": \"Pixaroma’s experimental Klein 9B outpaint LoRA. Trigger: pixaoutpaint. Strength: 1.\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_LoadImagePad\"}], \"links\": [{\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}" + ] + }, + { + "id": 21, + "type": "SetLatentNoiseMask", + "pos": [ + -580, + 1006 + ], + "size": [ + 460, + 46 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 28 + }, + { + "name": "mask", + "type": "MASK", + "link": 29 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 27 + ] + } + ], + "title": "Set Latent Noise Mask · protect the source", + "properties": { + "Node name for S&R": "SetLatentNoiseMask", + "cnr_id": "comfy-core", + "ver": "0.34.5" + }, + "widgets_values": [] + } + ], + "links": [ + [ + 1, + 1, + 0, + 4, + 0, + "MODEL" + ], + [ + 2, + 5, + 0, + 6, + 0, + "IMAGE" + ], + [ + 3, + 3, + 0, + 6, + 1, + "VAE" + ], + [ + 6, + 2, + 0, + 9, + 0, + "CLIP" + ], + [ + 7, + 8, + 0, + 9, + 1, + "STRING" + ], + [ + 8, + 9, + 0, + 10, + 0, + "CONDITIONING" + ], + [ + 9, + 6, + 0, + 10, + 1, + "LATENT" + ], + [ + 10, + 9, + 0, + 11, + 0, + "CONDITIONING" + ], + [ + 11, + 11, + 0, + 12, + 0, + "CONDITIONING" + ], + [ + 12, + 6, + 0, + 12, + 1, + "LATENT" + ], + [ + 13, + 4, + 0, + 14, + 0, + "MODEL" + ], + [ + 14, + 10, + 0, + 14, + 1, + "CONDITIONING" + ], + [ + 15, + 12, + 0, + 14, + 2, + "CONDITIONING" + ], + [ + 17, + 13, + 0, + 14, + 4, + "INT" + ], + [ + 18, + 14, + 0, + 15, + 0, + "LATENT" + ], + [ + 19, + 3, + 0, + 15, + 1, + "VAE" + ], + [ + 20, + 5, + 4, + 16, + 0, + "AUSBOSS_STITCHER" + ], + [ + 21, + 15, + 0, + 16, + 1, + "IMAGE" + ], + [ + 22, + 16, + 0, + 18, + 0, + "IMAGE" + ], + [ + 23, + 5, + 0, + 18, + 1, + "IMAGE" + ], + [ + 24, + 18, + 0, + 19, + 0, + "IMAGE" + ], + [ + 27, + 21, + 0, + 14, + 3, + "LATENT" + ], + [ + 28, + 6, + 0, + 21, + 0, + "LATENT" + ], + [ + 29, + 5, + 1, + 21, + 1, + "MASK" + ] + ], + "groups": [ + { + "id": 1, + "title": "1 · Models + outpaint LoRA", + "bounding": [ + -1080, + 40, + 470, + 664 + ], + "flags": {}, + "font_size": 24, + "color": "#235d64" + }, + { + "id": 2, + "title": "2 · Source + padding (any sides)", + "bounding": [ + -600, + 40, + 500, + 1032 + ], + "flags": {}, + "font_size": 24, + "color": "#366451" + }, + { + "id": 3, + "title": "3 · Prompt, reference, seed", + "bounding": [ + -80, + 40, + 460, + 962 + ], + "flags": {}, + "font_size": 24, + "color": "#5d527c" + }, + { + "id": 4, + "title": "4 · Sample + stitch", + "bounding": [ + 440, + 40, + 480, + 745 + ], + "flags": {}, + "font_size": 24, + "color": "#795b38" + }, + { + "id": 5, + "title": "5 · Compare, save", + "bounding": [ + 940, + 40, + 600, + 1080 + ], + "flags": {}, + "font_size": 24, + "color": "#345d7c" + } + ], + "config": {}, + "extra": { + "ds": { + "scale": 0.4939, + "offset": [ + 1881.98, + 314.2 + ] + } + }, + "version": 0.4 +} diff --git a/example_workflows/Krea 2 Outpaint (AusBoss).jpg b/example_workflows/Krea 2 Outpaint (AusBoss).jpg new file mode 100644 index 0000000..b4e262b Binary files /dev/null and b/example_workflows/Krea 2 Outpaint (AusBoss).jpg differ diff --git a/example_workflows/Krea 2 Outpaint (AusBoss).json b/example_workflows/Krea 2 Outpaint (AusBoss).json new file mode 100644 index 0000000..78cca4d --- /dev/null +++ b/example_workflows/Krea 2 Outpaint (AusBoss).json @@ -0,0 +1,1044 @@ +{ + "id": "", + "revision": 0, + "last_node_id": 17, + "last_link_id": 22, + "nodes": [ + { + "id": 1, + "type": "UNETLoader", + "pos": [ + -1060, + 100 + ], + "size": [ + 430, + 82 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 1 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader" + }, + "widgets_values": [ + "Krea 2/krea2_turbo_fp8.safetensors", + "default" + ] + }, + { + "id": 2, + "type": "CLIPLoader", + "pos": [ + -1060, + 232 + ], + "size": [ + 430, + 106 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "CLIP", + "type": "CLIP", + "links": [ + 4 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader" + }, + "widgets_values": [ + "qwen3vl_4b_fp8_scaled.safetensors", + "krea2", + "default" + ] + }, + { + "id": 3, + "type": "VAELoader", + "pos": [ + -1060, + 388 + ], + "size": [ + 430, + 58 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "VAE", + "type": "VAE", + "links": [ + 5, + 8, + 17 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader" + }, + "widgets_values": [ + "qwen_image_vae.safetensors" + ] + }, + { + "id": 4, + "type": "AUSBOSS_NODES_LoraLoader", + "pos": [ + -1060, + 496 + ], + "size": [ + 430, + 188 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 1 + }, + { + "name": "clip", + "type": "CLIP", + "link": null, + "shape": 7 + } + ], + "outputs": [ + { + "name": "model", + "type": "MODEL", + "links": [ + 2 + ] + }, + { + "name": "clip", + "type": "CLIP", + "links": [] + }, + { + "name": "triggers", + "type": "STRING", + "links": [] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_LoraLoader", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "[{\"name\": \"krea2_anypaint_rank32.safetensors\", \"strength\": 1.0, \"enabled\": true, \"triggers\": \"\"}]", + ", ", + "" + ], + "title": "LoRA Loader 🆎 · AnyPaint LoRA" + }, + { + "id": 5, + "type": "AUSBOSS_NODES_LoadImagePad", + "pos": [ + -580, + 100 + ], + "size": [ + 460, + 760 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "image", + "type": "IMAGE", + "links": [ + 6, + 7, + 21 + ] + }, + { + "name": "mask", + "type": "MASK", + "links": [ + 10 + ] + }, + { + "name": "width", + "type": "INT", + "links": [] + }, + { + "name": "height", + "type": "INT", + "links": [] + }, + { + "name": "stitcher", + "type": "AUSBOSS_STITCHER", + "links": [ + 3, + 18 + ] + }, + { + "name": "reference", + "type": "IMAGE", + "links": [] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_LoadImagePad", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "ausboss_pier_sunrise.png", + 320, + 192, + 128, + 256, + "color", + "#808080", + 0.5, + 64, + 16, + 0.0, + "", + "" + ], + "title": "Load Image + Pad 🆎 · drag any edge outward" + }, + { + "id": 6, + "type": "AUSBOSS_NODES_Krea2Encode", + "pos": [ + -60, + 100 + ], + "size": [ + 460, + 304 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 4 + }, + { + "name": "vae", + "type": "VAE", + "link": 5, + "shape": 7 + }, + { + "name": "reference", + "type": "IMAGE", + "link": 6, + "shape": 7 + }, + { + "name": "extra_image", + "type": "IMAGE", + "link": null, + "shape": 7 + } + ], + "outputs": [ + { + "name": "positive", + "type": "CONDITIONING", + "links": [ + 12 + ] + }, + { + "name": "negative", + "type": "CONDITIONING", + "links": [ + 13 + ] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_Krea2Encode", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "a long wooden pier stretching into a calm alpine lake at sunrise, soft pastel sky with thin streaks of cloud, morning mist over the water, forested mountains on both shores, a single small rowboat tied to the pier, even gentle light, photographic, complete coherent composition", + "", + true + ], + "title": "Krea 2 Encode 🆎 · describe the whole picture" + }, + { + "id": 7, + "type": "AUSBOSS_NODES_Krea2OutpaintModelPatch", + "pos": [ + -60, + 454 + ], + "size": [ + 460, + 172 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 2 + }, + { + "name": "stitcher", + "type": "AUSBOSS_STITCHER", + "link": 3 + } + ], + "outputs": [ + { + "name": "model", + "type": "MODEL", + "links": [ + 11 + ] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_Krea2OutpaintModelPatch", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + true, + "whole canvas" + ], + "title": "Krea 2 Outpaint Model Patch 🆎 · reference = whole canvas" + }, + { + "id": 8, + "type": "VAEEncode", + "pos": [ + -60, + 676 + ], + "size": [ + 460, + 46 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "name": "pixels", + "type": "IMAGE", + "link": 7 + }, + { + "name": "vae", + "type": "VAE", + "link": 8 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 9 + ] + } + ], + "properties": { + "Node name for S&R": "VAEEncode" + }, + "widgets_values": [], + "title": "VAE Encode · padded canvas" + }, + { + "id": 9, + "type": "SetLatentNoiseMask", + "pos": [ + -60, + 772 + ], + "size": [ + 460, + 46 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 9 + }, + { + "name": "mask", + "type": "MASK", + "link": 10 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 14 + ] + } + ], + "properties": { + "Node name for S&R": "SetLatentNoiseMask" + }, + "widgets_values": [], + "title": "Set Latent Noise Mask · keep the known pixels" + }, + { + "id": 10, + "type": "AUSBOSS_NODES_Seed", + "pos": [ + -60, + 868 + ], + "size": [ + 460, + 216 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "seed", + "type": "INT", + "links": [ + 15 + ] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_Seed", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + 20260903, + "fixed" + ] + }, + { + "id": 11, + "type": "KSampler", + "pos": [ + 460, + 100 + ], + "size": [ + 440, + 262 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 11 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 12 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 13 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 14 + }, + { + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 15 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 16 + ] + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 20260903, + "fixed", + 8, + 1.0, + "er_sde", + "simple", + 1.0 + ], + "title": "KSampler · Krea 2 Turbo: 8 steps, CFG 1" + }, + { + "id": 12, + "type": "VAEDecode", + "pos": [ + 460, + 412 + ], + "size": [ + 440, + 46 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 16 + }, + { + "name": "vae", + "type": "VAE", + "link": 17 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 19 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + }, + "widgets_values": [] + }, + { + "id": 13, + "type": "AUSBOSS_NODES_StitchInpaint", + "pos": [ + 460, + 508 + ], + "size": [ + 440, + 145 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "name": "stitcher", + "type": "AUSBOSS_STITCHER", + "link": 18 + }, + { + "name": "inpainted", + "type": "IMAGE", + "link": 19 + } + ], + "outputs": [ + { + "name": "image", + "type": "IMAGE", + "links": [ + 20 + ] + }, + { + "name": "blend_mask", + "type": "MASK", + "links": [] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_StitchInpaint", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + false, + 1.0 + ], + "title": "Stitch Inpaint 🆎 · original pixels back, seams matched" + }, + { + "id": 14, + "type": "AUSBOSS_NODES_RunTimer", + "pos": [ + 460, + 703 + ], + "size": [ + 196, + 62 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_RunTimer", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [] + }, + { + "id": 15, + "type": "AUSBOSS_NODES_Compare", + "pos": [ + 960, + 100 + ], + "size": [ + 560, + 560 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [ + { + "name": "image_a", + "type": "IMAGE", + "link": 20 + }, + { + "name": "image_b", + "type": "IMAGE", + "link": 21 + } + ], + "outputs": [ + { + "name": "image_a", + "type": "IMAGE", + "links": [ + 22 + ] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_Compare", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "" + ], + "title": "Image Compare A/B 🆎 · result vs padded canvas" + }, + { + "id": 16, + "type": "AUSBOSS_NODES_SaveImage", + "pos": [ + 960, + 710 + ], + "size": [ + 560, + 390 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 22 + }, + { + "name": "filename", + "type": "STRING", + "link": null, + "shape": 7, + "widget": { + "name": "filename" + } + }, + { + "name": "caption_text", + "type": "STRING", + "link": null, + "shape": 7, + "widget": { + "name": "caption_text" + } + } + ], + "outputs": [ + { + "name": "file_path", + "type": "STRING", + "links": [] + }, + { + "name": "images", + "type": "IMAGE", + "links": [] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_SaveImage", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "AusBoss/krea2_outpaint", + "png", + true, + "", + "overwrite", + "", + "" + ] + }, + { + "id": 17, + "type": "AUSBOSS_NODES_WorkflowNote", + "pos": [ + -1680, + 100 + ], + "size": [ + 560, + 1060 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_WorkflowNote", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "{\"v\": 1, \"title\": \"Krea 2 Outpaint\", \"subtitle\": \"Extend a picture on any side, all at once. The AnyPaint LoRA and the model patch make Krea 2 continue the scene into the padding instead of painting a new one beside it.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"## Setup\\nCopy the supplied media from `example_workflows/inputs/` into `ComfyUI/input/`. Choose each installed model in its loader; saved selections may include local subfolders. Download links below name the standard destination folders.\\n\\n## How it works\\n1. **Load Image + Pad** — drag any edge of the dashed rectangle outward; all four at once is fine. The padding is flat grey, the mask covers exactly the new area with a short inward feather.\\n2. **Krea 2 Encode** — describe the *whole finished picture*. The **padded canvas** rides along as the reference with *VLM reference* on, so the text encoder sees the grey and knows what is missing.\\n3. **AnyPaint LoRA + model patch** — the LoRA (yijunwang2/krea2-anypaint) was trained on exactly this: a grey-padded canvas as the reference, spread over the whole frame. The **Outpaint Model Patch** on *whole canvas* registers it there and caches its keys and values once per run.\\n4. **Noise mask** — the padded canvas is VAE-encoded and given the pad mask; the sampler restores the known pixels at every step and only paints the grey.\\n5. **Sample, stitch, save** — 8 steps, CFG 1, `er_sde` / `simple`. **Stitch Inpaint** lays the untouched source back over the result through the pad's feather. Its *Tone match* reads how far the model's new area drifted from the source along each seam and lifts it back, so the padding and the source meet on one tone. **Compare** slides result against the padded canvas. About 15 s on a 32 GB card for a 1280×1664 canvas.\\n\\n## One axis at a time? Not any more\\nThe pack's first Krea 2 outpaint pinned the *unpadded* source to its rectangle (the Registered Outpaint LoRA's contract) and that only holds up when the source spans one whole canvas axis — pad left and right, or top and bottom, never both. AnyPaint uses the padded canvas itself as the reference, so the placement is always the full frame and every side can grow in one pass. Switch the patch back to *source rectangle*, load `krea2_outpaint_rank32` and wire the pad node's *reference* output instead to get the old behaviour.\\n\\n## Try\\n- **Inpaint too**: AnyPaint treats any white mask pixel as *generate*. Paint over something in the picture with the mask editor and pad at the same time.\\n- **More canvas**: the new area copes with roughly doubling the picture; past that it starts to invent a second scene. Grow in two runs instead.\\n- **Bypass the LoRA** (Ctrl+B) to see how much of the result it carries.\", \"models\": [{\"name\": \"krea2_turbo_fp8.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"13.1 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/diffusion_models/krea2_turbo_fp8_scaled.safetensors\", \"note\": \"Tested local FP8 Turbo filename. The download is the official krea2_turbo_fp8_scaled.safetensors build; select that downloaded file in the loader if using it.\"}, {\"name\": \"qwen3vl_4b_fp8_scaled.safetensors\", \"dir\": \"text_encoders\", \"size\": \"5.2 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/text_encoders/qwen3vl_4b_fp8_scaled.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_vae.safetensors\", \"dir\": \"vae\", \"size\": \"254 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/vae/qwen_image_vae.safetensors\", \"note\": \"\"}, {\"name\": \"krea2_anypaint_rank32.safetensors\", \"dir\": \"loras\", \"size\": \"229 MB\", \"url\": \"https://huggingface.co/yijunwang2/krea2-anypaint/resolve/main/krea2_anypaint_rank32.safetensors\", \"note\": \"Krea 2 AnyPaint by yijunwang2 - arbitrary-mask inpaint + outpaint adapter; Krea 2 Community License\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_Krea2OutpaintModelPatch\"}], \"links\": [{\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}" + ] + } + ], + "links": [ + [ + 1, + 1, + 0, + 4, + 0, + "MODEL" + ], + [ + 2, + 4, + 0, + 7, + 0, + "MODEL" + ], + [ + 3, + 5, + 4, + 7, + 1, + "AUSBOSS_STITCHER" + ], + [ + 4, + 2, + 0, + 6, + 0, + "CLIP" + ], + [ + 5, + 3, + 0, + 6, + 1, + "VAE" + ], + [ + 6, + 5, + 0, + 6, + 2, + "IMAGE" + ], + [ + 7, + 5, + 0, + 8, + 0, + "IMAGE" + ], + [ + 8, + 3, + 0, + 8, + 1, + "VAE" + ], + [ + 9, + 8, + 0, + 9, + 0, + "LATENT" + ], + [ + 10, + 5, + 1, + 9, + 1, + "MASK" + ], + [ + 11, + 7, + 0, + 11, + 0, + "MODEL" + ], + [ + 12, + 6, + 0, + 11, + 1, + "CONDITIONING" + ], + [ + 13, + 6, + 1, + 11, + 2, + "CONDITIONING" + ], + [ + 14, + 9, + 0, + 11, + 3, + "LATENT" + ], + [ + 15, + 10, + 0, + 11, + 4, + "INT" + ], + [ + 16, + 11, + 0, + 12, + 0, + "LATENT" + ], + [ + 17, + 3, + 0, + 12, + 1, + "VAE" + ], + [ + 18, + 5, + 4, + 13, + 0, + "AUSBOSS_STITCHER" + ], + [ + 19, + 12, + 0, + 13, + 1, + "IMAGE" + ], + [ + 20, + 13, + 0, + 15, + 0, + "IMAGE" + ], + [ + 21, + 5, + 0, + 15, + 1, + "IMAGE" + ], + [ + 22, + 15, + 0, + 16, + 0, + "IMAGE" + ] + ], + "groups": [ + { + "id": 1, + "title": "1 · Models + LoRA", + "bounding": [ + -1080, + 40, + 470, + 664 + ], + "flags": {}, + "font_size": 24, + "color": "#235d64" + }, + { + "id": 2, + "title": "2 · Source + padding (any sides)", + "bounding": [ + -600, + 40, + 500, + 840 + ], + "flags": {}, + "font_size": 24, + "color": "#366451" + }, + { + "id": 3, + "title": "3 · Prompt, placement, noise mask", + "bounding": [ + -80, + 40, + 500, + 1064 + ], + "flags": {}, + "font_size": 24, + "color": "#5d527c" + }, + { + "id": 4, + "title": "4 · Sample + stitch", + "bounding": [ + 440, + 40, + 480, + 745 + ], + "flags": {}, + "font_size": 24, + "color": "#795b38" + }, + { + "id": 5, + "title": "5 · Compare, save", + "bounding": [ + 940, + 40, + 600, + 1080 + ], + "flags": {}, + "font_size": 24, + "color": "#345d7c" + } + ], + "config": {}, + "extra": { + "ds": { + "scale": 0.4939, + "offset": [ + 1881.98, + 314.2 + ] + } + }, + "version": 0.4 +} diff --git a/example_workflows/Krea 2 Prompt from Image (AusBoss).jpg b/example_workflows/Krea 2 Prompt from Image (AusBoss).jpg new file mode 100644 index 0000000..891cecb Binary files /dev/null and b/example_workflows/Krea 2 Prompt from Image (AusBoss).jpg differ diff --git a/example_workflows/Krea 2 Prompt from Image (AusBoss).json b/example_workflows/Krea 2 Prompt from Image (AusBoss).json new file mode 100644 index 0000000..a6b4c90 --- /dev/null +++ b/example_workflows/Krea 2 Prompt from Image (AusBoss).json @@ -0,0 +1,879 @@ +{ + "id": "", + "revision": 0, + "last_node_id": 15, + "last_link_id": 15, + "nodes": [ + { + "id": 1, + "type": "UNETLoader", + "pos": [ + -1060, + 100 + ], + "size": [ + 430, + 82 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 6 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader" + }, + "widgets_values": [ + "Krea 2/krea2_turbo_fp8.safetensors", + "default" + ] + }, + { + "id": 2, + "type": "CLIPLoader", + "pos": [ + -1060, + 232 + ], + "size": [ + 430, + 106 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "CLIP", + "type": "CLIP", + "links": [ + 1, + 5 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader" + }, + "widgets_values": [ + "qwen3vl_4b_fp8_scaled.safetensors", + "krea2", + "default" + ] + }, + { + "id": 3, + "type": "VAELoader", + "pos": [ + -1060, + 388 + ], + "size": [ + 430, + 58 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "VAE", + "type": "VAE", + "links": [ + 12 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader" + }, + "widgets_values": [ + "qwen_image_vae.safetensors" + ] + }, + { + "id": 4, + "type": "LoadImage", + "pos": [ + -1060, + 530 + ], + "size": [ + 430, + 690 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 2, + 14 + ] + }, + { + "name": "MASK", + "type": "MASK", + "links": [] + } + ], + "properties": { + "Node name for S&R": "LoadImage" + }, + "widgets_values": [ + "ausboss_pier_sunrise.png", + "image" + ], + "title": "Load Image · the picture to describe" + }, + { + "id": 5, + "type": "TextGenerate", + "pos": [ + -580, + 100 + ], + "size": [ + 460, + 412 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 1 + }, + { + "name": "sampling_mode", + "type": "COMFY_DYNAMICCOMBO_V3", + "link": null + }, + { + "name": "image", + "type": "IMAGE", + "link": 2, + "shape": 7 + }, + { + "name": "video", + "type": "IMAGE", + "link": null, + "shape": 7 + }, + { + "name": "audio", + "type": "AUDIO", + "link": null, + "shape": 7 + } + ], + "outputs": [ + { + "name": "generated_text", + "type": "STRING", + "links": [ + 3 + ] + } + ], + "properties": { + "Node name for S&R": "TextGenerate" + }, + "widgets_values": [ + "Describe this picture as one image-generation prompt: subject, setting, composition, light, colour palette, lens and mood, in 120 to 180 words of plain prose. No lists, no preamble.", + 400, + "on", + 0.7, + 64, + 0.95, + 0.05, + 1.05, + 20260903, + 0.0, + false, + true + ], + "title": "Text Generate · the Krea 2 text encoder looks at the picture" + }, + { + "id": 6, + "type": "AUSBOSS_NODES_ShowText", + "pos": [ + -580, + 562 + ], + "size": [ + 460, + 260 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "text", + "type": "STRING", + "link": 3, + "widget": { + "name": "text" + } + } + ], + "outputs": [ + { + "name": "text", + "type": "STRING", + "links": [ + 4 + ] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_ShowText", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [], + "title": "Show Text 🆎 · the prompt it wrote" + }, + { + "id": 7, + "type": "AUSBOSS_NODES_Krea2Encode", + "pos": [ + -60, + 100 + ], + "size": [ + 460, + 304 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 5 + }, + { + "name": "vae", + "type": "VAE", + "link": null, + "shape": 7 + }, + { + "name": "reference", + "type": "IMAGE", + "link": null, + "shape": 7 + }, + { + "name": "extra_image", + "type": "IMAGE", + "link": null, + "shape": 7 + }, + { + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": 4 + } + ], + "outputs": [ + { + "name": "positive", + "type": "CONDITIONING", + "links": [ + 7 + ] + }, + { + "name": "negative", + "type": "CONDITIONING", + "links": [ + 8 + ] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_Krea2Encode", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "", + "", + false + ], + "title": "Krea 2 Encode 🆎 · prompt comes in by wire" + }, + { + "id": 8, + "type": "EmptySD3LatentImage", + "pos": [ + -60, + 454 + ], + "size": [ + 460, + 106 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 9 + ] + } + ], + "properties": { + "Node name for S&R": "EmptySD3LatentImage" + }, + "widgets_values": [ + 832, + 1216, + 1 + ], + "title": "Empty Latent · 832 × 1216" + }, + { + "id": 9, + "type": "AUSBOSS_NODES_Seed", + "pos": [ + -60, + 610 + ], + "size": [ + 460, + 216 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "seed", + "type": "INT", + "links": [ + 10 + ] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_Seed", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + 20260903, + "fixed" + ] + }, + { + "id": 10, + "type": "KSampler", + "pos": [ + 460, + 100 + ], + "size": [ + 440, + 262 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 6 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 7 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 8 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 9 + }, + { + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 10 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 11 + ] + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 20260903, + "fixed", + 8, + 1.0, + "er_sde", + "simple", + 1.0 + ], + "title": "KSampler · Krea 2 Turbo: 8 steps, CFG 1" + }, + { + "id": 11, + "type": "VAEDecode", + "pos": [ + 460, + 412 + ], + "size": [ + 440, + 46 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 11 + }, + { + "name": "vae", + "type": "VAE", + "link": 12 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 13 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + }, + "widgets_values": [] + }, + { + "id": 12, + "type": "AUSBOSS_NODES_RunTimer", + "pos": [ + 460, + 508 + ], + "size": [ + 196, + 62 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_RunTimer", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [] + }, + { + "id": 13, + "type": "AUSBOSS_NODES_Compare", + "pos": [ + 960, + 100 + ], + "size": [ + 560, + 560 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "name": "image_a", + "type": "IMAGE", + "link": 13 + }, + { + "name": "image_b", + "type": "IMAGE", + "link": 14 + } + ], + "outputs": [ + { + "name": "image_a", + "type": "IMAGE", + "links": [ + 15 + ] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_Compare", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "" + ], + "title": "Image Compare A/B 🆎 · new render vs the source" + }, + { + "id": 14, + "type": "AUSBOSS_NODES_SaveImage", + "pos": [ + 960, + 710 + ], + "size": [ + 560, + 390 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 15 + }, + { + "name": "filename", + "type": "STRING", + "link": null, + "shape": 7, + "widget": { + "name": "filename" + } + }, + { + "name": "caption_text", + "type": "STRING", + "link": null, + "shape": 7, + "widget": { + "name": "caption_text" + } + } + ], + "outputs": [ + { + "name": "file_path", + "type": "STRING", + "links": [] + }, + { + "name": "images", + "type": "IMAGE", + "links": [] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_SaveImage", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "AusBoss/krea2_from_image", + "png", + true, + "", + "overwrite", + "", + "" + ] + }, + { + "id": 15, + "type": "AUSBOSS_NODES_WorkflowNote", + "pos": [ + -1680, + 100 + ], + "size": [ + 560, + 900 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_WorkflowNote", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "{\"v\": 1, \"title\": \"Krea 2 Prompt from Image\", \"subtitle\": \"Krea 2's own text encoder describes a picture, Show Text shows the prompt it wrote, and Krea 2 renders it. Nothing but ComfyUI core and this pack.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"## Setup\\nCopy the supplied media from `example_workflows/inputs/` into `ComfyUI/input/`. Choose each installed model in its loader; saved selections may include local subfolders. Download links below name the standard destination folders.\\n\\n## How it works\\n1. **Text Generate** — a ComfyUI core node. Krea 2's text encoder is a Qwen3-VL vision-language model, so the same file that will encode the prompt can also *look at a picture and write one*. No extra model, no server, nothing leaves your machine. The instruction tells it what kind of prompt to write.\\n2. **Show Text** — the generated prompt lands here so you can read it, copy it, or edit the instruction and run again.\\n3. **Krea 2 Encode** — takes the prompt by wire (the field greys out while it is connected). Unplug it to type your own.\\n4. **Sample** — the plain Krea 2 Turbo recipe: 8 steps, CFG 1, `er_sde` / `simple`, 832×1216. **Seed** is pinned; *Use last run* brings a good one back.\\n5. **Compare + save** — the new render slides against the picture it was described from. About 20 s on a 32 GB card, most of it the description.\\n\\n## Try\\n- **Style transfer by words**: tell Text Generate to describe the picture *as a watercolour* or *as a 1970s film still* and Krea 2 paints that version.\\n- **Prompt enhancer**: unplug the image and feed Text Generate a rough sentence instead — it expands it into a full prompt the same way.\\n- **Batch of ideas**: raise Text Generate's temperature toward 1.0 for more varied descriptions; lower it for a faithful one.\\n- The description reruns only when its inputs change, so reseeding the sampler is free.\", \"models\": [{\"name\": \"krea2_turbo_fp8.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"13.1 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/diffusion_models/krea2_turbo_fp8_scaled.safetensors\", \"note\": \"Tested local FP8 Turbo filename. The download is the official krea2_turbo_fp8_scaled.safetensors build; select that downloaded file in the loader if using it.\"}, {\"name\": \"qwen3vl_4b_fp8_scaled.safetensors\", \"dir\": \"text_encoders\", \"size\": \"5.2 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/text_encoders/qwen3vl_4b_fp8_scaled.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_vae.safetensors\", \"dir\": \"vae\", \"size\": \"254 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/vae/qwen_image_vae.safetensors\", \"note\": \"\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_ShowText\"}], \"links\": [{\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}" + ] + } + ], + "links": [ + [ + 1, + 2, + 0, + 5, + 0, + "CLIP" + ], + [ + 2, + 4, + 0, + 5, + 2, + "IMAGE" + ], + [ + 3, + 5, + 0, + 6, + 0, + "STRING" + ], + [ + 4, + 6, + 0, + 7, + 4, + "STRING" + ], + [ + 5, + 2, + 0, + 7, + 0, + "CLIP" + ], + [ + 6, + 1, + 0, + 10, + 0, + "MODEL" + ], + [ + 7, + 7, + 0, + 10, + 1, + "CONDITIONING" + ], + [ + 8, + 7, + 1, + 10, + 2, + "CONDITIONING" + ], + [ + 9, + 8, + 0, + 10, + 3, + "LATENT" + ], + [ + 10, + 9, + 0, + 10, + 4, + "INT" + ], + [ + 11, + 10, + 0, + 11, + 0, + "LATENT" + ], + [ + 12, + 3, + 0, + 11, + 1, + "VAE" + ], + [ + 13, + 11, + 0, + 13, + 0, + "IMAGE" + ], + [ + 14, + 4, + 0, + 13, + 1, + "IMAGE" + ], + [ + 15, + 13, + 0, + 14, + 0, + "IMAGE" + ] + ], + "groups": [ + { + "id": 1, + "title": "1 · Models", + "bounding": [ + -1080, + 40, + 470, + 426 + ], + "flags": {}, + "font_size": 24, + "color": "#235d64" + }, + { + "id": 2, + "title": "2 · Picture", + "bounding": [ + -1080, + 470, + 470, + 770 + ], + "flags": {}, + "font_size": 24, + "color": "#366451" + }, + { + "id": 3, + "title": "3 · Describe it", + "bounding": [ + -600, + 40, + 500, + 802 + ], + "flags": {}, + "font_size": 24, + "color": "#5d527c" + }, + { + "id": 4, + "title": "4 · Encode, size, seed", + "bounding": [ + -80, + 40, + 500, + 806 + ], + "flags": {}, + "font_size": 24, + "color": "#795b38" + }, + { + "id": 5, + "title": "5 · Sample", + "bounding": [ + 440, + 40, + 480, + 550 + ], + "flags": {}, + "font_size": 24, + "color": "#345d7c" + }, + { + "id": 6, + "title": "6 · Compare, save", + "bounding": [ + 940, + 40, + 600, + 1080 + ], + "flags": {}, + "font_size": 24, + "color": "#65515e" + } + ], + "config": {}, + "extra": { + "ds": { + "scale": 0.4939, + "offset": [ + 1881.98, + 314.2 + ] + } + }, + "version": 0.4 +} diff --git a/example_workflows/Krea 2 Studio (AusBoss).jpg b/example_workflows/Krea 2 Studio (AusBoss).jpg new file mode 100644 index 0000000..932eec7 Binary files /dev/null and b/example_workflows/Krea 2 Studio (AusBoss).jpg differ diff --git a/example_workflows/Krea 2 Studio (AusBoss).json b/example_workflows/Krea 2 Studio (AusBoss).json new file mode 100644 index 0000000..0ea35ea --- /dev/null +++ b/example_workflows/Krea 2 Studio (AusBoss).json @@ -0,0 +1,2016 @@ +{ + "id": "", + "revision": 0, + "last_node_id": 23, + "last_link_id": 30, + "nodes": [ + { + "id": 1, + "type": "UNETLoader", + "pos": [ + -1060, + 100 + ], + "size": [ + 430, + 82 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "unet_name", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": null + }, + { + "localized_name": "weight_dtype", + "name": "weight_dtype", + "type": "COMBO", + "widget": { + "name": "weight_dtype" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 1 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "UNETLoader" + }, + "widgets_values": [ + "Krea 2/krea2_turbo_fp8.safetensors", + "default" + ], + "widgets_values_named": { + "unet_name": "krea2_turbo_fp8_scaled.safetensors", + "weight_dtype": "default" + } + }, + { + "id": 2, + "type": "CLIPLoader", + "pos": [ + -1060, + 232 + ], + "size": [ + 430, + 106 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": null + }, + { + "localized_name": "type", + "name": "type", + "type": "COMBO", + "widget": { + "name": "type" + }, + "link": null + }, + { + "localized_name": "device", + "name": "device", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "device" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [ + 2 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "CLIPLoader" + }, + "widgets_values": [ + "qwen3vl_4b_fp8_scaled.safetensors", + "krea2", + "default" + ], + "widgets_values_named": { + "clip_name": "qwen3vl_4b_fp8_scaled.safetensors", + "type": "krea2", + "device": "default" + } + }, + { + "id": 3, + "type": "VAELoader", + "pos": [ + -1060, + 388 + ], + "size": [ + 430, + 58 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 11, + 17, + 24 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "VAELoader" + }, + "widgets_values": [ + "qwen_image_vae.safetensors" + ], + "widgets_values_named": { + "vae_name": "qwen_image_vae.safetensors" + } + }, + { + "id": 4, + "type": "AUSBOSS_NODES_LoraLoader", + "pos": [ + -1060, + 496 + ], + "size": [ + 430, + 202 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 1 + }, + { + "localized_name": "clip", + "name": "clip", + "shape": 7, + "type": "CLIP", + "link": 2 + }, + { + "localized_name": "loras", + "name": "loras", + "type": "STRING", + "widget": { + "name": "loras" + }, + "link": null + }, + { + "localized_name": "trigger_separator", + "name": "trigger_separator", + "shape": 7, + "type": "STRING", + "widget": { + "name": "trigger_separator" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "links": [ + 5, + 18 + ] + }, + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "links": [ + 3 + ] + }, + { + "localized_name": "triggers", + "name": "triggers", + "type": "STRING", + "links": [] + } + ], + "title": "LoRA Loader 🆎 · add LoRAs here", + "properties": { + "cnr_id": "ausboss-nodes", + "ver": "2.0.0", + "Node name for S&R": "AUSBOSS_NODES_LoraLoader", + "ausbossLoraLinked": true + }, + "widgets_values": [ + "[]", + ", ", + "" + ], + "widgets_values_named": { + "loras": "[]", + "trigger_separator": ", ", + "ausboss_lora_rows": "" + }, + "color": "#14424d", + "bgcolor": "#161f21" + }, + { + "id": 5, + "type": "AUSBOSS_NODES_Text", + "pos": [ + -580, + 100 + ], + "size": [ + 460, + 200 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "links": [ + 4 + ] + } + ], + "title": "Text 🆎 · your prompt", + "properties": { + "cnr_id": "ausboss-nodes", + "ver": "2.0.0", + "Node name for S&R": "AUSBOSS_NODES_Text" + }, + "widgets_values": [ + "close-up portrait photograph of a weathered fisherman in a wool sweater on a misty harbor at dawn, fine skin texture, salt-and-pepper beard, natural light, 85mm lens, shallow depth of field, photorealistic" + ], + "widgets_values_named": { + "text": "close-up portrait photograph of a weathered fisherman in a wool sweater on a misty harbor at dawn, fine skin texture, salt-and-pepper beard, natural light, 85mm lens, shallow depth of field, photorealistic" + }, + "color": "#14424d", + "bgcolor": "#161f21" + }, + { + "id": 6, + "type": "AUSBOSS_NODES_Krea2Encode", + "pos": [ + -580, + 350 + ], + "size": [ + 460, + 304 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 3 + }, + { + "localized_name": "vae", + "name": "vae", + "shape": 7, + "type": "VAE", + "link": null + }, + { + "localized_name": "reference", + "name": "reference", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "localized_name": "extra_image", + "name": "extra_image", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "localized_name": "prompt", + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": 4 + }, + { + "localized_name": "negative_prompt", + "name": "negative_prompt", + "shape": 7, + "type": "STRING", + "widget": { + "name": "negative_prompt" + }, + "link": null + }, + { + "localized_name": "vlm_reference", + "name": "vlm_reference", + "shape": 7, + "type": "BOOLEAN", + "widget": { + "name": "vlm_reference" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "links": [ + 6, + 19 + ] + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "links": [ + 7, + 20 + ] + } + ], + "properties": { + "cnr_id": "ausboss-nodes", + "ver": "2.0.0", + "Node name for S&R": "AUSBOSS_NODES_Krea2Encode" + }, + "widgets_values": [ + "", + "", + false + ], + "widgets_values_named": { + "prompt": "", + "negative_prompt": "", + "vlm_reference": false + }, + "color": "#14424d", + "bgcolor": "#161f21" + }, + { + "id": 7, + "type": "AUSBOSS_NODES_Seed", + "pos": [ + -580, + 704 + ], + "size": [ + 460, + 216 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "links": [ + 9, + 22 + ] + } + ], + "title": "Seed 🆎 · shared by both passes", + "properties": { + "cnr_id": "ausboss-nodes", + "ver": "2.0.0", + "Node name for S&R": "AUSBOSS_NODES_Seed" + }, + "widgets_values": [ + 20260903, + "fixed", + "" + ], + "widgets_values_named": { + "seed": 20260903, + "control_after_generate": "fixed", + "ausboss_seed_panel": "" + }, + "color": "#14424d", + "bgcolor": "#161f21" + }, + { + "id": 8, + "type": "EmptySD3LatentImage", + "pos": [ + -580, + 970 + ], + "size": [ + 460, + 106 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": null + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": null + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 8 + ] + } + ], + "title": "Draft size", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "EmptySD3LatentImage" + }, + "widgets_values": [ + 832, + 1216, + 1 + ], + "widgets_values_named": { + "width": 832, + "height": 1216, + "batch_size": 1 + } + }, + { + "id": 9, + "type": "KSampler", + "pos": [ + -60, + 100 + ], + "size": [ + 400, + 262 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 5 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 6 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 7 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 8 + }, + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 9 + }, + { + "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 + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 10 + ] + } + ], + "title": "KSampler · draft (8 steps, CFG 1)", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 20260903, + "fixed", + 8, + 1, + "er_sde", + "simple", + 1 + ], + "widgets_values_named": { + "seed": 20260903, + "control_after_generate": "fixed", + "steps": 8, + "cfg": 1, + "sampler_name": "er_sde", + "scheduler": "simple", + "denoise": 1 + } + }, + { + "id": 10, + "type": "VAEDecode", + "pos": [ + -60, + 412 + ], + "size": [ + 400, + 46 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 10 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 11 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 12, + 14, + 26, + 30 + ] + } + ], + "title": "VAE Decode · draft", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "VAEDecode" + } + }, + { + "id": 11, + "type": "PreviewImage", + "pos": [ + -60, + 508 + ], + "size": [ + 400, + 460 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "link": 12 + } + ], + "outputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "links": [] + } + ], + "title": "Preview · draft", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "PreviewImage" + } + }, + { + "id": 12, + "type": "UpscaleModelLoader", + "pos": [ + 380, + 100 + ], + "size": [ + 480, + 58 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "model_name", + "name": "model_name", + "type": "COMBO", + "widget": { + "name": "model_name" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "UPSCALE_MODEL", + "name": "UPSCALE_MODEL", + "type": "UPSCALE_MODEL", + "links": [ + 13 + ] + } + ], + "title": "Load Upscale Model · any ESRGAN-style 4x", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "UpscaleModelLoader" + }, + "widgets_values": [ + "Upscale-Models/4x_foolhardy_Remacri.pth" + ], + "widgets_values_named": { + "model_name": "4x_foolhardy_Remacri.pth" + } + }, + { + "id": 13, + "type": "ImageUpscaleWithModel", + "pos": [ + 380, + 208 + ], + "size": [ + 480, + 46 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "localized_name": "upscale_model", + "name": "upscale_model", + "type": "UPSCALE_MODEL", + "link": 13 + }, + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 14 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 15 + ] + } + ], + "title": "Upscale Image · learned 4x", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "ImageUpscaleWithModel" + } + }, + { + "id": 14, + "type": "AUSBOSS_NODES_ImageResize", + "pos": [ + 380, + 304 + ], + "size": [ + 480, + 334 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [ + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 15 + }, + { + "localized_name": "mask", + "name": "mask", + "shape": 7, + "type": "MASK", + "link": null + }, + { + "localized_name": "target_mode", + "name": "target_mode", + "type": "COMBO", + "widget": { + "name": "target_mode" + }, + "link": null + }, + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": null + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": null + }, + { + "localized_name": "edge_length", + "name": "edge_length", + "type": "INT", + "widget": { + "name": "edge_length" + }, + "link": null + }, + { + "localized_name": "megapixels", + "name": "megapixels", + "type": "FLOAT", + "widget": { + "name": "megapixels" + }, + "link": null + }, + { + "localized_name": "scale_factor", + "name": "scale_factor", + "type": "FLOAT", + "widget": { + "name": "scale_factor" + }, + "link": null + }, + { + "localized_name": "keep_proportion", + "name": "keep_proportion", + "type": "COMBO", + "widget": { + "name": "keep_proportion" + }, + "link": null + }, + { + "localized_name": "fill_color", + "name": "fill_color", + "type": "STRING", + "widget": { + "name": "fill_color" + }, + "link": null + }, + { + "localized_name": "divisible_by", + "name": "divisible_by", + "type": "INT", + "widget": { + "name": "divisible_by" + }, + "link": null + }, + { + "localized_name": "interpolation", + "name": "interpolation", + "type": "COMBO", + "widget": { + "name": "interpolation" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "links": [ + 16 + ] + }, + { + "localized_name": "mask", + "name": "mask", + "type": "MASK", + "links": [] + }, + { + "localized_name": "width", + "name": "width", + "type": "INT", + "links": [] + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "links": [] + } + ], + "title": "Image Resize 🆎 · refine budget (MP)", + "properties": { + "cnr_id": "ausboss-nodes", + "ver": "2.0.0", + "Node name for S&R": "AUSBOSS_NODES_ImageResize" + }, + "widgets_values": [ + "megapixels", + 0, + 0, + 1024, + 2.3, + 0.5, + "fit", + "#000000", + 16, + "lanczos" + ], + "widgets_values_named": { + "target_mode": "megapixels", + "width": 0, + "height": 0, + "edge_length": 1024, + "megapixels": 2.3, + "scale_factor": 0.5, + "keep_proportion": "fit", + "fill_color": "#000000", + "divisible_by": 16, + "interpolation": "lanczos" + }, + "color": "#14424d", + "bgcolor": "#161f21" + }, + { + "id": 15, + "type": "VAEEncode", + "pos": [ + 380, + 688 + ], + "size": [ + 480, + 46 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "localized_name": "pixels", + "name": "pixels", + "type": "IMAGE", + "link": 16 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 17 + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 21 + ] + } + ], + "title": "VAE Encode · back to latent", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "VAEEncode" + } + }, + { + "id": 16, + "type": "KSampler", + "pos": [ + 380, + 784 + ], + "size": [ + 480, + 262 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 18 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 19 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 20 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 21 + }, + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 22 + }, + { + "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 + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 23 + ] + } + ], + "title": "KSampler · refine pass (denoise 0.15)", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 20260903, + "fixed", + 8, + 1, + "er_sde", + "simple", + 0.15 + ], + "widgets_values_named": { + "seed": 20260903, + "control_after_generate": "fixed", + "steps": 8, + "cfg": 1, + "sampler_name": "er_sde", + "scheduler": "simple", + "denoise": 0.15 + } + }, + { + "id": 17, + "type": "VAEDecode", + "pos": [ + 380, + 1096 + ], + "size": [ + 480, + 46 + ], + "flags": {}, + "order": 18, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 23 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 24 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 25 + ] + } + ], + "title": "VAE Decode · refined", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "VAEDecode" + } + }, + { + "id": 18, + "type": "AUSBOSS_NODES_ColorMatch", + "pos": [ + 900, + 100 + ], + "size": [ + 440, + 227 + ], + "flags": {}, + "order": 19, + "mode": 0, + "inputs": [ + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 25 + }, + { + "localized_name": "reference", + "name": "reference", + "shape": 7, + "type": "IMAGE", + "link": 26 + }, + { + "localized_name": "mask", + "name": "mask", + "shape": 7, + "type": "MASK", + "link": null + }, + { + "localized_name": "strength", + "name": "strength", + "type": "FLOAT", + "widget": { + "name": "strength" + }, + "link": null + }, + { + "localized_name": "method", + "name": "method", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "method" + }, + "link": null + }, + { + "localized_name": "invert_mask", + "name": "invert_mask", + "shape": 7, + "type": "BOOLEAN", + "widget": { + "name": "invert_mask" + }, + "link": null + }, + { + "localized_name": "reference_mode", + "name": "reference_mode", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "reference_mode" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "links": [ + 27 + ] + } + ], + "title": "Color Match 🆎 · keep the draft's tone", + "properties": { + "cnr_id": "ausboss-nodes", + "ver": "2.0.0", + "Node name for S&R": "AUSBOSS_NODES_ColorMatch" + }, + "widgets_values": [ + 0.5, + "lab", + false, + "reference" + ], + "widgets_values_named": { + "strength": 0.5, + "method": "lab", + "invert_mask": false, + "reference_mode": "reference" + }, + "color": "#14424d", + "bgcolor": "#161f21" + }, + { + "id": 19, + "type": "ImageSharpen", + "pos": [ + 900, + 377 + ], + "size": [ + 440, + 106 + ], + "flags": {}, + "order": 20, + "mode": 0, + "inputs": [ + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 27 + }, + { + "localized_name": "sharpen_radius", + "name": "sharpen_radius", + "type": "INT", + "widget": { + "name": "sharpen_radius" + }, + "link": null + }, + { + "localized_name": "sigma", + "name": "sigma", + "type": "FLOAT", + "widget": { + "name": "sigma" + }, + "link": null + }, + { + "localized_name": "alpha", + "name": "alpha", + "type": "FLOAT", + "widget": { + "name": "alpha" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 28, + 29 + ] + } + ], + "title": "Sharpen Image · gentle", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "ImageSharpen" + }, + "widgets_values": [ + 1, + 1, + 0.15 + ], + "widgets_values_named": { + "sharpen_radius": 1, + "sigma": 1, + "alpha": 0.15 + } + }, + { + "id": 20, + "type": "AUSBOSS_NODES_SaveImage", + "pos": [ + 900, + 533 + ], + "size": [ + 440, + 382 + ], + "flags": {}, + "order": 21, + "mode": 0, + "inputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "link": 28 + }, + { + "localized_name": "filename_prefix", + "name": "filename_prefix", + "type": "STRING", + "widget": { + "name": "filename_prefix" + }, + "link": null + }, + { + "localized_name": "format", + "name": "format", + "type": "COMBO", + "widget": { + "name": "format" + }, + "link": null + }, + { + "localized_name": "save_metadata", + "name": "save_metadata", + "type": "BOOLEAN", + "widget": { + "name": "save_metadata" + }, + "link": null + }, + { + "localized_name": "exact_name", + "name": "exact_name", + "shape": 7, + "type": "STRING", + "widget": { + "name": "exact_name" + }, + "link": null + }, + { + "localized_name": "on_existing", + "name": "on_existing", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "on_existing" + }, + "link": null + }, + { + "localized_name": "output_dir", + "name": "output_dir", + "shape": 7, + "type": "STRING", + "widget": { + "name": "output_dir" + }, + "link": null + }, + { + "localized_name": "caption", + "name": "caption", + "shape": 7, + "type": "STRING", + "widget": { + "name": "caption" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "file_path", + "name": "file_path", + "type": "STRING", + "links": [] + }, + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "links": [] + } + ], + "title": "Save Image 🆎 · final", + "properties": { + "cnr_id": "ausboss-nodes", + "ver": "2.0.0", + "Node name for S&R": "AUSBOSS_NODES_SaveImage" + }, + "widgets_values": [ + "AusBoss/krea2_studio", + "png", + true, + "", + "overwrite", + "", + "" + ], + "widgets_values_named": { + "filename_prefix": "AusBoss/krea2_studio", + "format": "png", + "save_metadata": true, + "exact_name": "", + "on_existing": "overwrite", + "output_dir": "", + "caption": "" + }, + "color": "#14424d", + "bgcolor": "#161f21" + }, + { + "id": 21, + "type": "AUSBOSS_NODES_RunTimer", + "pos": [ + 900, + 965 + ], + "size": [ + 196, + 62 + ], + "flags": { + "no_title": true + }, + "order": 7, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": { + "cnr_id": "ausboss-nodes", + "ver": "2.0.0", + "Node name for S&R": "AUSBOSS_NODES_RunTimer" + }, + "color": "#07090a", + "bgcolor": "#07090a" + }, + { + "id": 22, + "type": "AUSBOSS_NODES_Compare", + "pos": [ + 1370, + 100 + ], + "size": [ + 620, + 900 + ], + "flags": {}, + "order": 22, + "mode": 0, + "inputs": [ + { + "localized_name": "image_a", + "name": "image_a", + "type": "IMAGE", + "link": 29 + }, + { + "localized_name": "image_b", + "name": "image_b", + "type": "IMAGE", + "link": 30 + } + ], + "outputs": [ + { + "localized_name": "image_a", + "name": "image_a", + "type": "IMAGE", + "links": [] + } + ], + "title": "Image Compare A/B 🆎 · final vs draft", + "properties": { + "cnr_id": "ausboss-nodes", + "ver": "2.0.0", + "Node name for S&R": "AUSBOSS_NODES_Compare", + "ausboss_compare_mode": "slide" + }, + "widgets_values": [ + "" + ], + "widgets_values_named": { + "ausboss_compare_panel": "" + }, + "color": "#14424d", + "bgcolor": "#161f21" + }, + { + "id": 23, + "type": "AUSBOSS_NODES_WorkflowNote", + "pos": [ + -1680, + 100 + ], + "size": [ + 560, + 1060 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "localized_name": "note", + "name": "note", + "type": "STRING", + "widget": { + "name": "note" + }, + "link": null + } + ], + "outputs": [], + "properties": { + "cnr_id": "ausboss-nodes", + "ver": "2.0.0", + "Node name for S&R": "AUSBOSS_NODES_WorkflowNote" + }, + "widgets_values": [ + "{\"v\": 1, \"title\": \"Krea 2 Studio\", \"subtitle\": \"Draft, then refine. A learned 4x upscale and a second low-denoise pass turn a 1 MP Krea 2 draft into a 2.3 MP image with real texture, finished with a draft-matched grade and a gentle sharpen. ComfyUI core + AusBoss, nothing else.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"## Setup\\nCopy the supplied media from `example_workflows/inputs/` into `ComfyUI/input/`. Choose each installed model in its loader; saved selections may include local subfolders. Download links below name the standard destination folders.\\n\\n## How it works\\n1. **Draft** — the plain Krea 2 Turbo recipe: 8 steps, CFG 1, `er_sde` / `simple`, 832×1216. **Seed** is pinned so both passes and every rerun line up; *Use last run* brings a good one back.\\n2. **Hires** — the draft goes through a learned 4x upscaler, **Image Resize** brings it down to the refine budget (2.3 MP, steps of 16), and a second KSampler repaints it at **denoise 0.15** with the same prompt, LoRAs and seed. That pass is where the pores, fabric and stone come from; the composition stays put.\\n3. **Finish** — **Color Match** pulls the refined image back to the draft's tone (a second pass tends to warm skin a touch), then a gentle core **Sharpen**. Both are subtle on purpose.\\n4. **Review + save** — the **Compare** slider shows final against draft; **Save Image** writes the final PNG with the workflow embedded. About 15 s on a 32 GB card.\\n\\n## Stages you can switch off\\n- Skip the refine pass: select the *refine pass* KSampler and press **Ctrl+B** (bypass). The upscaled draft flows straight to the finish.\\n- Skip the learned upscale: bypass **Upscale Image** and **Load Upscale Model** together; Image Resize then does the lift on its own.\\n- Skip a finish step: bypass Color Match or Sharpen on their own.\\n\\n## Try\\n- **Refine strength**: denoise 0.10–0.20 on the second KSampler. 0.12 is the safest on faces, 0.2 digs out more texture, 0.3 starts to speckle skin.\\n- **Refine budget**: the megapixels box on Image Resize. 2.3 MP is 1.5× the draft's edges; 4 MP works but the pass takes about twice as long.\\n- **Upscaler**: any ESRGAN-style 4x model in `models/upscale_models` — Remacri is a general-purpose pick; Siax and UltraSharp are common alternatives.\\n- Add LoRAs in the **LoRA Loader**; the *LoRA Stack* example shows how to wire their trigger words into the prompt.\\n\\nRemacri is a community ESRGAN model by FoolhardyVEVO; the Hugging Face mirror above is not the author's.\", \"models\": [{\"name\": \"krea2_turbo_fp8.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"13.1 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/diffusion_models/krea2_turbo_fp8_scaled.safetensors\", \"note\": \"Tested local FP8 Turbo filename. The download is the official krea2_turbo_fp8_scaled.safetensors build; select that downloaded file in the loader if using it.\"}, {\"name\": \"qwen3vl_4b_fp8_scaled.safetensors\", \"dir\": \"text_encoders\", \"size\": \"5.2 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/text_encoders/qwen3vl_4b_fp8_scaled.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_vae.safetensors\", \"dir\": \"vae\", \"size\": \"254 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/vae/qwen_image_vae.safetensors\", \"note\": \"\"}, {\"name\": \"4x_foolhardy_Remacri.pth\", \"dir\": \"upscale_models\", \"size\": \"67 MB\", \"url\": \"https://huggingface.co/FacehugmanIII/4x_foolhardy_Remacri/resolve/main/4x_foolhardy_Remacri.pth\", \"note\": \"community ESRGAN 4x by FoolhardyVEVO; any 4x upscaler works here\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_ImageResize\"}], \"links\": [{\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}" + ], + "widgets_values_named": { + "note": "{\"v\": 1, \"title\": \"Krea 2 Studio\", \"subtitle\": \"Draft, then refine. A learned 4x upscale and a second low-denoise pass turn a 1 MP Krea 2 draft into a 2.3 MP image with real texture, finished with a draft-matched grade and a gentle sharpen. ComfyUI core + AusBoss, nothing else.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"## How it works\\n1. **Draft** \\u2014 the plain Krea 2 Turbo recipe: 8 steps, CFG 1, `er_sde` / `simple`, 832\\u00d71216. **Seed** is pinned so both passes and every rerun line up; *Use last run* brings a good one back.\\n2. **Hires** \\u2014 the draft goes through a learned 4x upscaler, **Image Resize** brings it down to the refine budget (2.3 MP, steps of 16), and a second KSampler repaints it at **denoise 0.15** with the same prompt, LoRAs and seed. That pass is where the pores, fabric and stone come from; the composition stays put.\\n3. **Finish** \\u2014 **Color Match** pulls the refined image back to the draft's tone (a second pass tends to warm skin a touch), then a gentle core **Sharpen**. Both are subtle on purpose.\\n4. **Review + save** \\u2014 the **Compare** slider shows final against draft; **Save Image** writes the final PNG with the workflow embedded. About 15 s on a 32 GB card.\\n\\n## Stages you can switch off\\n- Skip the refine pass: select the *refine pass* KSampler and press **Ctrl+B** (bypass). The upscaled draft flows straight to the finish.\\n- Skip the learned upscale: bypass **Upscale Image** and **Load Upscale Model** together; Image Resize then does the lift on its own.\\n- Skip a finish step: bypass Color Match or Sharpen on their own.\\n\\n## Try\\n- **Refine strength**: denoise 0.10\\u20130.20 on the second KSampler. 0.12 is the safest on faces, 0.2 digs out more texture, 0.3 starts to speckle skin.\\n- **Refine budget**: the megapixels box on Image Resize. 2.3 MP is 1.5\\u00d7 the draft's edges; 4 MP works but the pass takes about twice as long.\\n- **Upscaler**: any ESRGAN-style 4x model in `models/upscale_models` \\u2014 Remacri is a general-purpose pick; Siax and UltraSharp are common alternatives.\\n- Add LoRAs in the **LoRA Loader**; the *LoRA Stack* example shows how to wire their trigger words into the prompt.\\n\\nRemacri is a community ESRGAN model by FoolhardyVEVO; the Hugging Face mirror above is not the author's.\", \"models\": [{\"name\": \"krea2_turbo_fp8_scaled.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"13.1 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/diffusion_models/krea2_turbo_fp8_scaled.safetensors\", \"note\": \"any Krea 2 Turbo build works: int8_convrot or nvfp4 for less VRAM\"}, {\"name\": \"qwen3vl_4b_fp8_scaled.safetensors\", \"dir\": \"text_encoders\", \"size\": \"5.2 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/text_encoders/qwen3vl_4b_fp8_scaled.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_vae.safetensors\", \"dir\": \"vae\", \"size\": \"254 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/vae/qwen_image_vae.safetensors\", \"note\": \"\"}, {\"name\": \"4x_foolhardy_Remacri.pth\", \"dir\": \"upscale_models\", \"size\": \"67 MB\", \"url\": \"https://huggingface.co/FacehugmanIII/4x_foolhardy_Remacri/resolve/main/4x_foolhardy_Remacri.pth\", \"note\": \"community ESRGAN 4x by FoolhardyVEVO; any 4x upscaler works here\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_ImageResize\"}], \"links\": [{\"label\": \"GitHub \\u00b7 ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai \\u00b7 AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X \\u00b7 Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}", + "ausboss_workflow_note_panel": "" + }, + "color": "#14424d", + "bgcolor": "#161f21" + } + ], + "links": [ + [ + 1, + 1, + 0, + 4, + 0, + "MODEL" + ], + [ + 2, + 2, + 0, + 4, + 1, + "CLIP" + ], + [ + 3, + 4, + 1, + 6, + 0, + "CLIP" + ], + [ + 4, + 5, + 0, + 6, + 4, + "STRING" + ], + [ + 5, + 4, + 0, + 9, + 0, + "MODEL" + ], + [ + 6, + 6, + 0, + 9, + 1, + "CONDITIONING" + ], + [ + 7, + 6, + 1, + 9, + 2, + "CONDITIONING" + ], + [ + 8, + 8, + 0, + 9, + 3, + "LATENT" + ], + [ + 9, + 7, + 0, + 9, + 4, + "INT" + ], + [ + 10, + 9, + 0, + 10, + 0, + "LATENT" + ], + [ + 11, + 3, + 0, + 10, + 1, + "VAE" + ], + [ + 12, + 10, + 0, + 11, + 0, + "IMAGE" + ], + [ + 13, + 12, + 0, + 13, + 0, + "UPSCALE_MODEL" + ], + [ + 14, + 10, + 0, + 13, + 1, + "IMAGE" + ], + [ + 15, + 13, + 0, + 14, + 0, + "IMAGE" + ], + [ + 16, + 14, + 0, + 15, + 0, + "IMAGE" + ], + [ + 17, + 3, + 0, + 15, + 1, + "VAE" + ], + [ + 18, + 4, + 0, + 16, + 0, + "MODEL" + ], + [ + 19, + 6, + 0, + 16, + 1, + "CONDITIONING" + ], + [ + 20, + 6, + 1, + 16, + 2, + "CONDITIONING" + ], + [ + 21, + 15, + 0, + 16, + 3, + "LATENT" + ], + [ + 22, + 7, + 0, + 16, + 4, + "INT" + ], + [ + 23, + 16, + 0, + 17, + 0, + "LATENT" + ], + [ + 24, + 3, + 0, + 17, + 1, + "VAE" + ], + [ + 25, + 17, + 0, + 18, + 0, + "IMAGE" + ], + [ + 26, + 10, + 0, + 18, + 1, + "IMAGE" + ], + [ + 27, + 18, + 0, + 19, + 0, + "IMAGE" + ], + [ + 28, + 19, + 0, + 20, + 0, + "IMAGE" + ], + [ + 29, + 19, + 0, + 22, + 0, + "IMAGE" + ], + [ + 30, + 10, + 0, + 22, + 1, + "IMAGE" + ] + ], + "groups": [ + { + "id": 1, + "title": "1 · Models + LoRA stack", + "bounding": [ + -1080, + 40, + 470, + 678 + ], + "flags": {}, + "color": "#235d64" + }, + { + "id": 2, + "title": "2 · Prompt, seed, draft size", + "bounding": [ + -600, + 40, + 500, + 1056 + ], + "flags": {}, + "color": "#366451" + }, + { + "id": 3, + "title": "3 · Draft (1 MP)", + "bounding": [ + -80, + 40, + 440, + 948 + ], + "flags": {}, + "color": "#5d527c" + }, + { + "id": 4, + "title": "4 · Hires: learned upscale + refine pass (2.3 MP)", + "bounding": [ + 360, + 40, + 520, + 1122 + ], + "flags": {}, + "color": "#795b38" + }, + { + "id": 5, + "title": "5 · Finish, compare, save", + "bounding": [ + 880, + 40, + 1130, + 1007 + ], + "flags": {}, + "color": "#345d7c" + } + ], + "config": {}, + "extra": { + "ds": { + "scale": 0.432, + "offset": [ + 1905.19, + 363.52 + ] + } + }, + "version": 0.4 +} diff --git a/example_workflows/Krea 2 Text to Image + LoRA Stack (AusBoss).jpg b/example_workflows/Krea 2 Text to Image + LoRA Stack (AusBoss).jpg new file mode 100644 index 0000000..120d459 Binary files /dev/null and b/example_workflows/Krea 2 Text to Image + LoRA Stack (AusBoss).jpg differ diff --git a/example_workflows/Krea 2 Text to Image + LoRA Stack (AusBoss).json b/example_workflows/Krea 2 Text to Image + LoRA Stack (AusBoss).json new file mode 100644 index 0000000..ef302d8 --- /dev/null +++ b/example_workflows/Krea 2 Text to Image + LoRA Stack (AusBoss).json @@ -0,0 +1,847 @@ +{ + "id": "ausboss-showcase", + "revision": 0, + "last_node_id": 15, + "last_link_id": 15, + "nodes": [ + { + "id": 1, + "type": "AUSBOSS_NODES_WorkflowNote", + "pos": [ + -1680, + 100 + ], + "size": [ + 560, + 1010 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_WorkflowNote", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "{\"v\": 1, \"title\": \"Krea 2 Text to Image + LoRA Stack\", \"subtitle\": \"The clean Krea 2 Turbo graph with one LoRA Loader for the whole stack. Trigger words flow into the prompt on their own wire.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"## Setup\\nCopy the supplied media from `example_workflows/inputs/` into `ComfyUI/input/`. Choose each installed model in its loader; saved selections may include local subfolders. Download links below name the standard destination folders.\\n\\n## How it works\\n1. **LoRA Loader** holds every LoRA in one node. The pill turns a row on or off, the name is a drag-to-scrub strength, and the **ⓘ** card lists the file's trigger words — click a word to add it to the `triggers` output.\\n2. **Concatenate** puts those trigger words in front of your prompt, so switching LoRAs never means editing the prompt.\\n3. **Krea 2 Encode** encodes both prompts in one node. **Seed** is pinned (Fixed) to reproduce this exact image; switch it to Random for variations, and *Use last run* brings a good one back.\\n4. **Queue.** The Run Timer shows how long the render took.\\n\\n## Try\\n- The tarot row is enabled. The optional anime row starts off; download and select that LoRA before enabling it.\\n- Drag a row's name left or right to scrub its strength. Shift is the fine step.\\n- Gear menu → **Absorb loader chain** pulls any other LoRA loaders wired into the model line into this stack.\\n- 8 steps, CFG 1, `er_sde` / `simple` is the Krea 2 Turbo recipe. 832×1216 is the portrait size the tarot look likes.\", \"models\": [{\"name\": \"krea2_turbo_fp8.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"13.1 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/diffusion_models/krea2_turbo_fp8_scaled.safetensors\", \"note\": \"Tested local FP8 Turbo filename. The download is the official krea2_turbo_fp8_scaled.safetensors build; select that downloaded file in the loader if using it.\"}, {\"name\": \"qwen3vl_4b_fp8_scaled.safetensors\", \"dir\": \"text_encoders\", \"size\": \"5.2 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/text_encoders/qwen3vl_4b_fp8_scaled.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_vae.safetensors\", \"dir\": \"vae\", \"size\": \"254 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/vae/qwen_image_vae.safetensors\", \"note\": \"\"}, {\"name\": \"krea2_vintagetarot.safetensors\", \"dir\": \"loras\", \"size\": \"469 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/loras/krea2_vintagetarot.safetensors\", \"note\": \"trigger: vintage tarot style\"}, {\"name\": \"krea2_retroanime.safetensors\", \"dir\": \"loras\", \"size\": \"469 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Krea-2/resolve/main/loras/krea2_retroanime.safetensors\", \"note\": \"trigger: purple retro anime style — off by default, flip it on to swap the look\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_LoraLoader\"}], \"links\": [{\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}" + ] + }, + { + "id": 2, + "type": "UNETLoader", + "pos": [ + -1060, + 100 + ], + "size": [ + 430, + 82 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 1 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "UNETLoader", + "models": [ + { + "name": "krea2_turbo_fp8_scaled.safetensors", + "url": "https://huggingface.co/Comfy-Org/Krea-2/resolve/main/diffusion_models/krea2_turbo_fp8_scaled.safetensors", + "directory": "diffusion_models" + } + ] + }, + "widgets_values": [ + "Krea 2/krea2_turbo_fp8.safetensors", + "default" + ] + }, + { + "id": 3, + "type": "CLIPLoader", + "pos": [ + -1060, + 232 + ], + "size": [ + 430, + 106 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "CLIP", + "type": "CLIP", + "links": [ + 2 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "CLIPLoader", + "models": [ + { + "name": "qwen3vl_4b_fp8_scaled.safetensors", + "url": "https://huggingface.co/Comfy-Org/Krea-2/resolve/main/text_encoders/qwen3vl_4b_fp8_scaled.safetensors", + "directory": "text_encoders" + } + ] + }, + "widgets_values": [ + "qwen3vl_4b_fp8_scaled.safetensors", + "krea2", + "default" + ] + }, + { + "id": 4, + "type": "VAELoader", + "pos": [ + -1060, + 388 + ], + "size": [ + 430, + 58 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "VAE", + "type": "VAE", + "links": [ + 14 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "models": [ + { + "name": "qwen_image_vae.safetensors", + "url": "https://huggingface.co/Comfy-Org/Krea-2/resolve/main/vae/qwen_image_vae.safetensors", + "directory": "vae" + } + ] + }, + "widgets_values": [ + "qwen_image_vae.safetensors" + ] + }, + { + "id": 5, + "type": "AUSBOSS_NODES_LoraLoader", + "pos": [ + -1060, + 496 + ], + "size": [ + 430, + 224 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 1 + }, + { + "name": "clip", + "type": "CLIP", + "link": 2 + } + ], + "outputs": [ + { + "name": "model", + "type": "MODEL", + "links": [ + 8 + ], + "slot_index": 0 + }, + { + "name": "clip", + "type": "CLIP", + "links": [ + 5 + ], + "slot_index": 1 + }, + { + "name": "triggers", + "type": "STRING", + "links": [ + 3 + ], + "slot_index": 2 + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_LoraLoader", + "models": [ + { + "name": "krea2_vintagetarot.safetensors", + "url": "https://huggingface.co/Comfy-Org/Krea-2/resolve/main/loras/krea2_vintagetarot.safetensors", + "directory": "loras" + }, + { + "name": "krea2_retroanime.safetensors", + "url": "https://huggingface.co/Comfy-Org/Krea-2/resolve/main/loras/krea2_retroanime.safetensors", + "directory": "loras" + } + ], + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "[{\"name\": \"krea2_vintagetarot.safetensors\", \"strength\": 1.0, \"enabled\": true, \"triggers\": \"vintage tarot style\"}, {\"name\": \"krea2_retroanime.safetensors\", \"strength\": 0.9, \"enabled\": false, \"triggers\": \"purple retro anime style\"}]", + ", ", + "" + ], + "title": "LoRA Loader 🆎 · the whole stack in one node" + }, + { + "id": 6, + "type": "EmptySD3LatentImage", + "pos": [ + -1060, + 830 + ], + "size": [ + 430, + 106 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 11 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "EmptySD3LatentImage" + }, + "widgets_values": [ + 832, + 1216, + 1 + ] + }, + { + "id": 7, + "type": "AUSBOSS_NODES_Text", + "pos": [ + -580, + 100 + ], + "size": [ + 460, + 200 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "text", + "type": "STRING", + "links": [ + 4 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_Text", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "a knight in ornate armor kneeling in a moonlit forest clearing, holding a glowing lantern, fireflies, mist between the trees, a banner reading \"THE HERMIT\" along the bottom, rich symbolic illustration" + ], + "title": "Text 🆎 · your prompt" + }, + { + "id": 8, + "type": "StringConcatenate", + "pos": [ + -580, + 350 + ], + "size": [ + 460, + 166 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "string_a", + "type": "STRING", + "link": 3, + "widget": { + "name": "string_a" + } + }, + { + "name": "string_b", + "type": "STRING", + "link": 4, + "widget": { + "name": "string_b" + } + } + ], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "links": [ + 6, + 7 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "StringConcatenate" + }, + "widgets_values": [ + "", + "", + ", " + ], + "title": "Concatenate · trigger words + prompt" + }, + { + "id": 9, + "type": "AUSBOSS_NODES_Krea2Encode", + "pos": [ + -580, + 566 + ], + "size": [ + 460, + 304 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 5 + }, + { + "name": "prompt", + "type": "STRING", + "link": 6, + "widget": { + "name": "prompt" + } + }, + { + "name": "vae", + "type": "VAE", + "link": null + }, + { + "name": "reference", + "type": "IMAGE", + "link": null + }, + { + "name": "extra_image", + "type": "IMAGE", + "link": null + } + ], + "outputs": [ + { + "name": "positive", + "type": "CONDITIONING", + "links": [ + 9 + ], + "slot_index": 0 + }, + { + "name": "negative", + "type": "CONDITIONING", + "links": [ + 10 + ], + "slot_index": 1 + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_Krea2Encode", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "", + "", + false + ], + "title": "Krea 2 Encode 🆎" + }, + { + "id": 10, + "type": "AUSBOSS_NODES_Seed", + "pos": [ + -580, + 920 + ], + "size": [ + 460, + 216 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "seed", + "type": "INT", + "links": [ + 12 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_Seed", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + 20260902, + "fixed" + ] + }, + { + "id": 11, + "type": "KSampler", + "pos": [ + -80, + 100 + ], + "size": [ + 320, + 262 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 8 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 9 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 10 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 11 + }, + { + "name": "seed", + "type": "INT", + "link": 12, + "widget": { + "name": "seed" + } + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 13 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 20260902, + "fixed", + 8, + 1.0, + "er_sde", + "simple", + 1.0 + ] + }, + { + "id": 12, + "type": "VAEDecode", + "pos": [ + -80, + 412 + ], + "size": [ + 320, + 46 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 13 + }, + { + "name": "vae", + "type": "VAE", + "link": 14 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 15 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + }, + "widgets_values": [] + }, + { + "id": 13, + "type": "AUSBOSS_NODES_RunTimer", + "pos": [ + -80, + 508 + ], + "size": [ + 196, + 62 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_RunTimer", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [] + }, + { + "id": 14, + "type": "AUSBOSS_NODES_ShowText", + "pos": [ + -80, + 620 + ], + "size": [ + 320, + 160 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "name": "text", + "type": "STRING", + "link": 7, + "widget": { + "name": "text" + } + } + ], + "outputs": [ + { + "name": "text", + "type": "STRING", + "links": [], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_ShowText", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [], + "title": "Show Text 🆎 · what the encoder got" + }, + { + "id": 15, + "type": "SaveImage", + "pos": [ + 280, + 100 + ], + "size": [ + 560, + 760 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 15 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "SaveImage" + }, + "widgets_values": [ + "AusBoss/krea2_lora_stack" + ] + } + ], + "links": [ + [ + 1, + 2, + 0, + 5, + 0, + "MODEL" + ], + [ + 2, + 3, + 0, + 5, + 1, + "CLIP" + ], + [ + 3, + 5, + 2, + 8, + 0, + "STRING" + ], + [ + 4, + 7, + 0, + 8, + 1, + "STRING" + ], + [ + 5, + 5, + 1, + 9, + 0, + "CLIP" + ], + [ + 6, + 8, + 0, + 9, + 1, + "STRING" + ], + [ + 7, + 8, + 0, + 14, + 0, + "STRING" + ], + [ + 8, + 5, + 0, + 11, + 0, + "MODEL" + ], + [ + 9, + 9, + 0, + 11, + 1, + "CONDITIONING" + ], + [ + 10, + 9, + 1, + 11, + 2, + "CONDITIONING" + ], + [ + 11, + 6, + 0, + 11, + 3, + "LATENT" + ], + [ + 12, + 10, + 0, + 11, + 4, + "INT" + ], + [ + 13, + 11, + 0, + 12, + 0, + "LATENT" + ], + [ + 14, + 4, + 0, + 12, + 1, + "VAE" + ], + [ + 15, + 12, + 0, + 15, + 0, + "IMAGE" + ] + ], + "groups": [ + { + "id": 1, + "title": "1 · Models + LoRA stack", + "bounding": [ + -1080, + 40, + 470, + 700 + ], + "color": "#235d64", + "font_size": 24, + "flags": {} + }, + { + "id": 2, + "title": "2 · Size", + "bounding": [ + -1080, + 770, + 470, + 186 + ], + "color": "#366451", + "font_size": 24, + "flags": {} + }, + { + "id": 3, + "title": "3 · Prompt: triggers + text → encode", + "bounding": [ + -600, + 40, + 500, + 1116 + ], + "color": "#5d527c", + "font_size": 24, + "flags": {} + }, + { + "id": 4, + "title": "4 · Sample + save", + "bounding": [ + -100, + 40, + 960, + 840 + ], + "color": "#795b38", + "font_size": 24, + "flags": {} + } + ], + "config": {}, + "extra": { + "ds": { + "scale": 0.6231, + "offset": [ + 1848.39, + 242.83 + ] + } + }, + "version": 0.4 +} diff --git a/example_workflows/LTX 2.3 Video Outpaint (AusBoss).jpg b/example_workflows/LTX 2.3 Video Outpaint (AusBoss).jpg new file mode 100644 index 0000000..2b36614 Binary files /dev/null and b/example_workflows/LTX 2.3 Video Outpaint (AusBoss).jpg differ diff --git a/example_workflows/LTX 2.3 Video Outpaint (AusBoss).json b/example_workflows/LTX 2.3 Video Outpaint (AusBoss).json new file mode 100644 index 0000000..b941af4 --- /dev/null +++ b/example_workflows/LTX 2.3 Video Outpaint (AusBoss).json @@ -0,0 +1,2246 @@ +{ + "id": "ausboss-showcase-ltx-video-outpaint", + "revision": 0, + "last_node_id": 22, + "last_link_id": 33, + "nodes": [ + { + "id": 1, + "type": "AUSBOSS_NODES_WorkflowNote", + "pos": [ + -1740, + 100 + ], + "size": [ + 560, + 1120 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "note", + "name": "note", + "type": "STRING", + "widget": { + "name": "note" + }, + "link": null + } + ], + "outputs": [], + "properties": { + "cnr_id": "ausboss-nodes", + "ver": "2.0.0", + "Node name for S&R": "AUSBOSS_NODES_WorkflowNote" + }, + "widgets_values": [ + "{\"v\": 1, \"title\": \"LTX 2.3 Video Outpaint\", \"subtitle\": \"Turn a vertical clip into a 16:9 one. An in-context LoRA fills the black bars with scene that moves with the footage, and Stitch puts your original pixels back.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"## Setup\\nCopy the supplied media from `example_workflows/inputs/` into `ComfyUI/input/`. Choose each installed model in its loader; saved selections may include local subfolders. Download links below name the standard destination folders.\\n\\n## How it works\\n1. **Video Crop + Rotate + Pad → Clip** — pick or upload a clip on the node's source card (the sample clip is linked at the bottom of this card; drop it in ComfyUI/input). Open the editor, choose **16:9** and click **Pad to aspect**: the whole clip stays and centered black bands bring the canvas to 16:9. The crop, padding and rotate handles also work right on the node's preview — cut off a border or a logo, straighten a tilt. Keep `fill_color` #000000 and `feather` 0: pure black with a hard edge is the LoRA's *paint here* signal. Grey bands in the result mean feather was on.\\n2. **Trim** — drag the IN/OUT handles on the node's strip or type exact times; **Snap** 8n+1 drops trailing frames to a count LTX keeps (49, 97, 121, 145), so any IN/OUT window works; **Limit** 97 caps the sample at about four seconds. **Resize** stays on at 0.86 MP, steps 32, which lands the padded sample on 1280×704.\\n3. **Prompt** — describe the *whole finished shot*, not just the new bands. The LTX text encoder is the Gemma model plus the small projection file in the second slot.\\n4. **Sample** — the guide frames ride along in-context (`LTXV Add Guide` + `Get IC-LoRA Parameters`), 8 steps, CFG 1 on the distilled LoRA. About a minute for 97 frames on a 32 GB card.\\n5. **Stitch + save** — the clip node's `stitcher` output feeds Stitch Inpaint, which pastes the source frames back bit-for-bit and blends the new bands in across the **Blend** set in the editor's Inpaint & Stitch section (32 px here); its `color_match` pulls the bands onto the footage's tone. Save Video muxes the original audio.\\n\\n## Tips\\n- Wider bands cost nothing extra: the model paints whatever is black. Pad one axis per run; for a landscape clip choose **9:16** and Pad to aspect instead.\\n- A seam still showing? In the editor, turn on **Show blend**, raise **Blend**, or give **Grow paste** a few pixels so the generation repaints the source edge too.\\n- Dark footage (night, underwater) can read as *bar* to the model. The LoRA author's fix is a gamma round trip: brighten before the canvas, invert after decode.\\n- The seed is pinned so your first run matches this one. Switch it to randomize for variations; the source frames never change.\\n- Runs out of VRAM? Drop to 960×544, or swap the transformer for the int8_convrot build listed on its download.\\n- LTX 2.3 is released under the LTX-2 Community License; the outpaint IC-LoRA is by oumoumad on Hugging Face.\", \"models\": [{\"name\": \"ltx-2.3-22b-dev_transformer_only_fp8_scaled.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"21.9 GB\", \"url\": \"https://huggingface.co/Kijai/LTX2.3_comfy/resolve/main/diffusion_models/ltx-2.3-22b-dev_transformer_only_fp8_scaled.safetensors\", \"note\": \"the dev transformer; int8_convrot in the same folder fits smaller cards\"}, {\"name\": \"gemma_3_12B_it_fp8_scaled.safetensors\", \"dir\": \"text_encoders\", \"size\": \"12.3 GB\", \"url\": \"https://huggingface.co/Comfy-Org/ltx-2/resolve/main/split_files/text_encoders/gemma_3_12B_it_fp8_scaled.safetensors\", \"note\": \"fp4_mixed (8.8 GB) also works\"}, {\"name\": \"ltx-2.3_text_projection_bf16.safetensors\", \"dir\": \"text_encoders\", \"size\": \"2.2 GB\", \"url\": \"https://huggingface.co/Kijai/LTX2.3_comfy/resolve/main/text_encoders/ltx-2.3_text_projection_bf16.safetensors\", \"note\": \"second slot of the text encoder loader\"}, {\"name\": \"LTX23_video_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"1.4 GB\", \"url\": \"https://huggingface.co/Kijai/LTX2.3_comfy/resolve/main/vae/LTX23_video_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"ltx-2.3-22b-distilled-1.1_lora-dynamic_fro09_avg_rank_111_bf16.safetensors\", \"dir\": \"loras\", \"size\": \"2.6 GB\", \"url\": \"https://huggingface.co/Kijai/LTX2.3_comfy/resolve/main/loras/ltx-2.3-22b-distilled-1.1_lora-dynamic_fro09_avg_rank_111_bf16.safetensors\", \"note\": \"distilled LoRA: 8 steps, CFG 1\"}, {\"name\": \"ltx-2.3-22b-ic-lora-outpaint.safetensors\", \"dir\": \"loras\", \"size\": \"1.2 GB\", \"url\": \"https://huggingface.co/oumoumad/LTX-2.3-22b-IC-LoRA-Outpaint/resolve/main/ltx-2.3-22b-ic-lora-outpaint.safetensors\", \"note\": \"outpaint IC-LoRA by oumoumad, fills pure-black bars\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_VideoCropRotatePadClip\"}], \"links\": [{\"label\": \"Sample clip · ausboss_pier_9x16.mp4\", \"url\": \"https://raw.githubusercontent.com/ausboss/ComfyUI-AusBoss/main/example_workflows/inputs/ausboss_pier_9x16.mp4\"}, {\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}" + ], + "widgets_values_named": { + "note": "{\"v\": 1, \"title\": \"LTX 2.3 Video Outpaint\", \"subtitle\": \"Turn a vertical clip into a 16:9 one. An in-context LoRA fills the black bars with scene that moves with the footage, and Stitch puts your original pixels back.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"## How it works\\n1. **Video Crop + Rotate + Pad → Clip** — pick or upload a clip on the node's source card (the sample clip is linked at the bottom of this card; drop it in ComfyUI/input). Open the editor, choose **16:9** and click **Pad to aspect**: the whole clip stays and centered black bands bring the canvas to 16:9. The crop, padding and rotate handles also work right on the node's preview — cut off a border or a logo, straighten a tilt. Keep `fill_color` #000000 and `feather` 0: pure black is the LoRA's *paint here* signal.\\n2. **Trim** — drag the IN/OUT handles on the node's strip or type exact times; **Limit** 97 keeps the sample at 8n+1 frames (49, 97, 121, 145). **Resize** stays on at 0.86 MP, steps 32, which lands the padded sample on 1280×704.\\n3. **Prompt** — describe the *whole finished shot*, not just the new bands. The LTX text encoder is the Gemma model plus the small projection file in the second slot.\\n4. **Sample** — the guide frames ride along in-context (`LTXV Add Guide` + `Get IC-LoRA Parameters`), 8 steps, CFG 1 on the distilled LoRA. About a minute for 97 frames on a 32 GB card.\\n5. **Stitch + save** — the clip node's `stitcher` output feeds Stitch Inpaint, which pastes the source frames back bit-for-bit and blends the new bands in across the **Blend** set in the editor's Inpaint & Stitch section (32 px here); its `color_match` pulls the bands onto the footage's tone. Save Video muxes the original audio.\\n\\n## Tips\\n- Wider bands cost nothing extra: the model paints whatever is black. Pad one axis per run; for a landscape clip choose **9:16** and Pad to aspect instead.\\n- A seam still showing? In the editor, turn on **Show blend**, raise **Blend**, or give **Grow paste** a few pixels so the generation repaints the source edge too.\\n- Dark footage (night, underwater) can read as *bar* to the model. The LoRA author's fix is a gamma round trip: brighten before the canvas, invert after decode.\\n- The seed is pinned so your first run matches this one. Switch it to randomize for variations; the source frames never change.\\n- Runs out of VRAM? Drop to 960×544, or swap the transformer for the int8_convrot build listed on its download.\\n- LTX 2.3 is released under the LTX-2 Community License; the outpaint IC-LoRA is by oumoumad on Hugging Face.\", \"models\": [{\"name\": \"ltx-2.3-22b-dev_transformer_only_fp8_scaled.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"21.9 GB\", \"url\": \"https://huggingface.co/Kijai/LTX2.3_comfy/resolve/main/diffusion_models/ltx-2.3-22b-dev_transformer_only_fp8_scaled.safetensors\", \"note\": \"the dev transformer; int8_convrot in the same folder fits smaller cards\"}, {\"name\": \"gemma_3_12B_it_fp8_scaled.safetensors\", \"dir\": \"text_encoders\", \"size\": \"12.3 GB\", \"url\": \"https://huggingface.co/Comfy-Org/ltx-2/resolve/main/split_files/text_encoders/gemma_3_12B_it_fp8_scaled.safetensors\", \"note\": \"fp4_mixed (8.8 GB) also works\"}, {\"name\": \"ltx-2.3_text_projection_bf16.safetensors\", \"dir\": \"text_encoders\", \"size\": \"2.2 GB\", \"url\": \"https://huggingface.co/Kijai/LTX2.3_comfy/resolve/main/text_encoders/ltx-2.3_text_projection_bf16.safetensors\", \"note\": \"second slot of the text encoder loader\"}, {\"name\": \"LTX23_video_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"1.4 GB\", \"url\": \"https://huggingface.co/Kijai/LTX2.3_comfy/resolve/main/vae/LTX23_video_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"ltx-2.3-22b-distilled-1.1_lora-dynamic_fro09_avg_rank_111_bf16.safetensors\", \"dir\": \"loras\", \"size\": \"2.6 GB\", \"url\": \"https://huggingface.co/Kijai/LTX2.3_comfy/resolve/main/loras/ltx-2.3-22b-distilled-1.1_lora-dynamic_fro09_avg_rank_111_bf16.safetensors\", \"note\": \"distilled LoRA: 8 steps, CFG 1\"}, {\"name\": \"ltx-2.3-22b-ic-lora-outpaint.safetensors\", \"dir\": \"loras\", \"size\": \"1.2 GB\", \"url\": \"https://huggingface.co/oumoumad/LTX-2.3-22b-IC-LoRA-Outpaint/resolve/main/ltx-2.3-22b-ic-lora-outpaint.safetensors\", \"note\": \"outpaint IC-LoRA by oumoumad, fills pure-black bars\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_VideoCropRotatePadClip\"}], \"links\": [{\"label\": \"Sample clip · ausboss_pier_9x16.mp4\", \"url\": \"https://raw.githubusercontent.com/ausboss/ComfyUI-AusBoss/main/example_workflows/inputs/ausboss_pier_9x16.mp4\"}, {\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}", + "ausboss_workflow_note_panel": "" + }, + "color": "#14424d", + "bgcolor": "#161f21" + }, + { + "id": 2, + "type": "UNETLoader", + "pos": [ + -1120, + 100 + ], + "size": [ + 430, + 82 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "unet_name", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": null + }, + { + "localized_name": "weight_dtype", + "name": "weight_dtype", + "type": "COMBO", + "widget": { + "name": "weight_dtype" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "slot_index": 0, + "links": [ + 1 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "UNETLoader", + "models": [ + { + "name": "ltx-2.3-22b-dev_transformer_only_fp8_scaled.safetensors", + "url": "https://huggingface.co/Kijai/LTX2.3_comfy/resolve/main/diffusion_models/ltx-2.3-22b-dev_transformer_only_fp8_scaled.safetensors", + "directory": "diffusion_models" + } + ] + }, + "widgets_values": [ + "LTXV 2.3/ltx-2.3-22b-dev_transformer_only_fp8_scaled.safetensors", + "default" + ], + "widgets_values_named": { + "unet_name": "ltx-2.3-22b-dev_transformer_only_fp8_scaled.safetensors", + "weight_dtype": "default" + } + }, + { + "id": 3, + "type": "LoraLoaderModelOnly", + "pos": [ + -1120, + 232 + ], + "size": [ + 430, + 82 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 1 + }, + { + "localized_name": "lora_name", + "name": "lora_name", + "type": "COMBO", + "widget": { + "name": "lora_name" + }, + "link": null + }, + { + "localized_name": "strength_model", + "name": "strength_model", + "type": "FLOAT", + "widget": { + "name": "strength_model" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "slot_index": 0, + "links": [ + 2 + ] + } + ], + "title": "Load LoRA · distilled (8 steps)", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "LoraLoaderModelOnly", + "models": [ + { + "name": "ltx-2.3-22b-distilled-1.1_lora-dynamic_fro09_avg_rank_111_bf16.safetensors", + "url": "https://huggingface.co/Kijai/LTX2.3_comfy/resolve/main/loras/ltx-2.3-22b-distilled-1.1_lora-dynamic_fro09_avg_rank_111_bf16.safetensors", + "directory": "loras" + } + ] + }, + "widgets_values": [ + "ltx2.3/ltx-2.3-22b-distilled-1.1_lora-dynamic_fro09_avg_rank_111_bf16.safetensors", + 0.5 + ], + "widgets_values_named": { + "lora_name": "ltx-2.3-22b-distilled-1.1_lora-dynamic_fro09_avg_rank_111_bf16.safetensors", + "strength_model": 0.5 + } + }, + { + "id": 4, + "type": "LoraLoaderModelOnly", + "pos": [ + -1120, + 364 + ], + "size": [ + 430, + 82 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 2 + }, + { + "localized_name": "lora_name", + "name": "lora_name", + "type": "COMBO", + "widget": { + "name": "lora_name" + }, + "link": null + }, + { + "localized_name": "strength_model", + "name": "strength_model", + "type": "FLOAT", + "widget": { + "name": "strength_model" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "slot_index": 0, + "links": [ + 3, + 19 + ] + } + ], + "title": "Load LoRA · outpaint IC-LoRA", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "LoraLoaderModelOnly", + "models": [ + { + "name": "ltx-2.3-22b-ic-lora-outpaint.safetensors", + "url": "https://huggingface.co/oumoumad/LTX-2.3-22b-IC-LoRA-Outpaint/resolve/main/ltx-2.3-22b-ic-lora-outpaint.safetensors", + "directory": "loras" + } + ] + }, + "widgets_values": [ + "ltx2.3/ltx-2.3-22b-ic-lora-outpaint.safetensors", + 1 + ], + "widgets_values_named": { + "lora_name": "ltx-2.3-22b-ic-lora-outpaint.safetensors", + "strength_model": 1 + } + }, + { + "id": 5, + "type": "GetICLoRAParameters", + "pos": [ + -1120, + 496 + ], + "size": [ + 300, + 46 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "localized_name": "iclora_model", + "name": "iclora_model", + "type": "MODEL", + "link": 3 + } + ], + "outputs": [ + { + "localized_name": "iclora_parameters", + "name": "iclora_parameters", + "type": "IC_LORA_PARAMETERS", + "slot_index": 0, + "links": [ + 18 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "GetICLoRAParameters" + } + }, + { + "id": 6, + "type": "DualCLIPLoader", + "pos": [ + -1120, + 592 + ], + "size": [ + 430, + 130 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name1", + "name": "clip_name1", + "type": "COMBO", + "widget": { + "name": "clip_name1" + }, + "link": null + }, + { + "localized_name": "clip_name2", + "name": "clip_name2", + "type": "COMBO", + "widget": { + "name": "clip_name2" + }, + "link": null + }, + { + "localized_name": "type", + "name": "type", + "type": "COMBO", + "widget": { + "name": "type" + }, + "link": null + }, + { + "localized_name": "device", + "name": "device", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "device" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "slot_index": 0, + "links": [ + 4, + 5 + ] + } + ], + "title": "DualCLIPLoader · Gemma + LTX projection", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "DualCLIPLoader", + "models": [ + { + "name": "gemma_3_12B_it_fp8_scaled.safetensors", + "url": "https://huggingface.co/Comfy-Org/ltx-2/resolve/main/split_files/text_encoders/gemma_3_12B_it_fp8_scaled.safetensors", + "directory": "text_encoders" + }, + { + "name": "ltx-2.3_text_projection_bf16.safetensors", + "url": "https://huggingface.co/Kijai/LTX2.3_comfy/resolve/main/text_encoders/ltx-2.3_text_projection_bf16.safetensors", + "directory": "text_encoders" + } + ] + }, + "widgets_values": [ + "gemma_3_12B_it_fp8_scaled.safetensors", + "LTX/ltx-2.3_text_projection_bf16.safetensors", + "ltxv", + "default" + ], + "widgets_values_named": { + "clip_name1": "gemma_3_12B_it_fp8_scaled.safetensors", + "clip_name2": "ltx-2.3_text_projection_bf16.safetensors", + "type": "ltxv", + "device": "default" + } + }, + { + "id": 7, + "type": "VAELoader", + "pos": [ + -1120, + 772 + ], + "size": [ + 430, + 58 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "slot_index": 0, + "links": [ + 15, + 28 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "VAELoader", + "models": [ + { + "name": "LTX23_video_vae_bf16.safetensors", + "url": "https://huggingface.co/Kijai/LTX2.3_comfy/resolve/main/vae/LTX23_video_vae_bf16.safetensors", + "directory": "vae" + } + ] + }, + "widgets_values": [ + "LTX/LTX23_video_vae_bf16.safetensors" + ], + "widgets_values_named": { + "vae_name": "LTX23_video_vae_bf16.safetensors" + } + }, + { + "id": 8, + "type": "AUSBOSS_NODES_VideoCropRotatePadClip", + "pos": [ + -630, + 100 + ], + "size": [ + 450, + 720 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "video", + "name": "video", + "type": "COMBO", + "widget": { + "name": "video" + }, + "link": null + }, + { + "localized_name": "source_mode", + "name": "source_mode", + "type": "COMBO", + "widget": { + "name": "source_mode" + }, + "link": null + }, + { + "localized_name": "local_path", + "name": "local_path", + "type": "STRING", + "widget": { + "name": "local_path" + }, + "link": null + }, + { + "localized_name": "start_seconds", + "name": "start_seconds", + "type": "FLOAT", + "widget": { + "name": "start_seconds" + }, + "link": null + }, + { + "localized_name": "end_seconds", + "name": "end_seconds", + "type": "FLOAT", + "widget": { + "name": "end_seconds" + }, + "link": null + }, + { + "localized_name": "seek_mode", + "name": "seek_mode", + "type": "COMBO", + "widget": { + "name": "seek_mode" + }, + "link": null + }, + { + "localized_name": "frame_index", + "name": "frame_index", + "type": "INT", + "widget": { + "name": "frame_index" + }, + "link": null + }, + { + "localized_name": "frame_time", + "name": "frame_time", + "type": "FLOAT", + "widget": { + "name": "frame_time" + }, + "link": null + }, + { + "localized_name": "rotation_degrees", + "name": "rotation_degrees", + "type": "FLOAT", + "widget": { + "name": "rotation_degrees" + }, + "link": null + }, + { + "localized_name": "crop_aspect_ratio", + "name": "crop_aspect_ratio", + "type": "COMBO", + "widget": { + "name": "crop_aspect_ratio" + }, + "link": null + }, + { + "localized_name": "crop_x", + "name": "crop_x", + "type": "INT", + "widget": { + "name": "crop_x" + }, + "link": null + }, + { + "localized_name": "crop_y", + "name": "crop_y", + "type": "INT", + "widget": { + "name": "crop_y" + }, + "link": null + }, + { + "localized_name": "crop_width", + "name": "crop_width", + "type": "INT", + "widget": { + "name": "crop_width" + }, + "link": null + }, + { + "localized_name": "crop_height", + "name": "crop_height", + "type": "INT", + "widget": { + "name": "crop_height" + }, + "link": null + }, + { + "localized_name": "pad_left", + "name": "pad_left", + "type": "INT", + "widget": { + "name": "pad_left" + }, + "link": null + }, + { + "localized_name": "pad_top", + "name": "pad_top", + "type": "INT", + "widget": { + "name": "pad_top" + }, + "link": null + }, + { + "localized_name": "pad_right", + "name": "pad_right", + "type": "INT", + "widget": { + "name": "pad_right" + }, + "link": null + }, + { + "localized_name": "pad_bottom", + "name": "pad_bottom", + "type": "INT", + "widget": { + "name": "pad_bottom" + }, + "link": null + }, + { + "localized_name": "feather", + "name": "feather", + "type": "INT", + "widget": { + "name": "feather" + }, + "link": null + }, + { + "localized_name": "canvas_multiple", + "name": "canvas_multiple", + "type": "INT", + "widget": { + "name": "canvas_multiple" + }, + "link": null + }, + { + "localized_name": "fill_color", + "name": "fill_color", + "type": "STRING", + "widget": { + "name": "fill_color" + }, + "link": null + }, + { + "localized_name": "resize_to_megapixels", + "name": "resize_to_megapixels", + "type": "BOOLEAN", + "widget": { + "name": "resize_to_megapixels" + }, + "link": null + }, + { + "localized_name": "megapixels", + "name": "megapixels", + "type": "FLOAT", + "widget": { + "name": "megapixels" + }, + "link": null + }, + { + "localized_name": "resize_method", + "name": "resize_method", + "type": "COMBO", + "widget": { + "name": "resize_method" + }, + "link": null + }, + { + "localized_name": "resolution_steps", + "name": "resolution_steps", + "type": "INT", + "widget": { + "name": "resolution_steps" + }, + "link": null + }, + { + "localized_name": "every_nth", + "name": "every_nth", + "shape": 7, + "type": "INT", + "widget": { + "name": "every_nth" + }, + "link": null + }, + { + "localized_name": "max_frames", + "name": "max_frames", + "shape": 7, + "type": "INT", + "widget": { + "name": "max_frames" + }, + "link": null + }, + { + "localized_name": "stitch_blend", + "name": "stitch_blend", + "shape": 7, + "type": "INT", + "widget": { + "name": "stitch_blend" + }, + "link": null + }, + { + "localized_name": "stitch_grow", + "name": "stitch_grow", + "shape": 7, + "type": "INT", + "widget": { + "name": "stitch_grow" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "frames", + "name": "frames", + "type": "IMAGE", + "slot_index": 0, + "links": [ + 6 + ] + }, + { + "localized_name": "mask", + "name": "mask", + "type": "MASK", + "slot_index": 1, + "links": [] + }, + { + "localized_name": "audio", + "name": "audio", + "type": "AUDIO", + "slot_index": 2, + "links": [ + 32 + ] + }, + { + "localized_name": "frame_count", + "name": "frame_count", + "type": "INT", + "slot_index": 3, + "links": [ + 12 + ] + }, + { + "localized_name": "fps", + "name": "fps", + "type": "FLOAT", + "slot_index": 4, + "links": [ + 9, + 33 + ] + }, + { + "localized_name": "width", + "name": "width", + "type": "INT", + "slot_index": 5, + "links": [ + 10 + ] + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "slot_index": 6, + "links": [ + 11 + ] + }, + { + "localized_name": "duration", + "name": "duration", + "type": "FLOAT", + "slot_index": 7, + "links": [] + }, + { + "localized_name": "stitcher", + "name": "stitcher", + "type": "AUSBOSS_STITCHER", + "slot_index": 8, + "links": [ + 29 + ] + } + ], + "title": "Video Crop + Rotate + Pad → Clip 🆎 · 16:9 · Pad to aspect", + "properties": { + "cnr_id": "ausboss-nodes", + "ver": "2.0.0", + "Node name for S&R": "AUSBOSS_NODES_VideoCropRotatePadClip", + "ausboss_fit_aspect": "16:9" + }, + "widgets_values": { + "video": "ausboss_pier_9x16.mp4", + "source_mode": "input folder", + "local_path": "", + "start_seconds": 0, + "end_seconds": 0, + "seek_mode": "frame index", + "frame_index": 0, + "frame_time": 0, + "rotation_degrees": 0, + "crop_aspect_ratio": "free", + "crop_x": 0, + "crop_y": 0, + "crop_width": 0, + "crop_height": 0, + "pad_left": 622, + "pad_top": 0, + "pad_right": 623, + "pad_bottom": 0, + "feather": 0, + "canvas_multiple": 1, + "fill_color": "#000000", + "resize_to_megapixels": true, + "megapixels": 0.86, + "resize_method": "lanczos", + "resolution_steps": 32, + "every_nth": 1, + "max_frames": 97, + "stitch_blend": 32, + "stitch_grow": 0, + "frame_snap": "8n+1" + }, + "widgets_values_named": { + "video": "ausboss_pier_9x16.mp4", + "source_mode": "input folder", + "local_path": "", + "start_seconds": 0, + "end_seconds": 0, + "seek_mode": "frame index", + "frame_index": 0, + "frame_time": 0, + "rotation_degrees": 0, + "crop_aspect_ratio": "free", + "crop_x": 0, + "crop_y": 0, + "crop_width": 0, + "crop_height": 0, + "pad_left": 622, + "pad_top": 0, + "pad_right": 623, + "pad_bottom": 0, + "feather": 0, + "canvas_multiple": 1, + "fill_color": "#000000", + "resize_to_megapixels": true, + "megapixels": 0.86, + "resize_method": "lanczos", + "resolution_steps": 32, + "every_nth": 1, + "max_frames": 97, + "stitch_blend": 32, + "stitch_grow": 0, + "ausboss_transform_preview": "", + "frame_snap": "8n+1" + }, + "color": "#14424d", + "bgcolor": "#161f21" + }, + { + "id": 9, + "type": "LTXVPreprocess", + "pos": [ + -630, + 890 + ], + "size": [ + 300, + 58 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 6 + }, + { + "localized_name": "img_compression", + "name": "img_compression", + "type": "INT", + "widget": { + "name": "img_compression" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "output_image", + "name": "output_image", + "type": "IMAGE", + "slot_index": 0, + "links": [ + 17 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "LTXVPreprocess" + }, + "widgets_values": [ + 18 + ], + "widgets_values_named": { + "img_compression": 18 + } + }, + { + "id": 11, + "type": "CLIPTextEncode", + "pos": [ + -140, + 100 + ], + "size": [ + 460, + 200 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 4 + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "slot_index": 0, + "links": [ + 7 + ] + } + ], + "title": "Positive · the whole finished shot", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "A wooden pier stretches into a calm alpine lake at sunrise, mist drifting over still water, forested mountains on both shores under a soft pastel sky, a small rowboat tied to the posts, the camera pans slowly across the scene, photorealistic, cinematic wide shot, natural light, high detail" + ], + "widgets_values_named": { + "text": "A wooden pier stretches into a calm alpine lake at sunrise, mist drifting over still water, forested mountains on both shores under a soft pastel sky, a small rowboat tied to the posts, the camera pans slowly across the scene, photorealistic, cinematic wide shot, natural light, high detail" + } + }, + { + "id": 12, + "type": "CLIPTextEncode", + "pos": [ + -140, + 350 + ], + "size": [ + 460, + 130 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 5 + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "slot_index": 0, + "links": [ + 8 + ] + } + ], + "title": "Negative", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "blurry, out of focus, low quality, distorted, black bars, letterbox, border, frame, text, watermark, flicker, jitter, static, still image" + ], + "widgets_values_named": { + "text": "blurry, out of focus, low quality, distorted, black bars, letterbox, border, frame, text, watermark, flicker, jitter, static, still image" + } + }, + { + "id": 13, + "type": "LTXVConditioning", + "pos": [ + -140, + 530 + ], + "size": [ + 300, + 78 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 7 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 8 + }, + { + "localized_name": "frame_rate", + "name": "frame_rate", + "type": "FLOAT", + "widget": { + "name": "frame_rate" + }, + "link": 9 + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "slot_index": 0, + "links": [ + 13 + ] + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "slot_index": 1, + "links": [ + 14 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "LTXVConditioning" + }, + "widgets_values": [ + 24 + ], + "widgets_values_named": { + "frame_rate": 24 + } + }, + { + "id": 14, + "type": "EmptyLTXVLatentVideo", + "pos": [ + -140, + 660 + ], + "size": [ + 300, + 130 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 10 + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 11 + }, + { + "localized_name": "length", + "name": "length", + "type": "INT", + "widget": { + "name": "length" + }, + "link": 12 + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "slot_index": 0, + "links": [ + 16 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "EmptyLTXVLatentVideo" + }, + "widgets_values": [ + 1280, + 704, + 97, + 1 + ], + "widgets_values_named": { + "width": 1280, + "height": 704, + "length": 97, + "batch_size": 1 + } + }, + { + "id": 15, + "type": "LTXVAddGuide", + "pos": [ + -140, + 840 + ], + "size": [ + 300, + 202 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 13 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 14 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 15 + }, + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "link": 16 + }, + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 17 + }, + { + "localized_name": "attention_mask", + "name": "attention_mask", + "shape": 7, + "type": "MASK", + "link": null + }, + { + "localized_name": "iclora_parameters", + "name": "iclora_parameters", + "shape": 7, + "type": "IC_LORA_PARAMETERS", + "link": 18 + }, + { + "localized_name": "frame_idx", + "name": "frame_idx", + "type": "INT", + "widget": { + "name": "frame_idx" + }, + "link": null + }, + { + "localized_name": "strength", + "name": "strength", + "type": "FLOAT", + "widget": { + "name": "strength" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "slot_index": 0, + "links": [ + 20, + 24 + ] + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "slot_index": 1, + "links": [ + 21, + 25 + ] + }, + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "slot_index": 2, + "links": [ + 22 + ] + } + ], + "title": "LTXV Add Guide · in-context frames", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "LTXVAddGuide" + }, + "widgets_values": [ + 0, + 1 + ], + "widgets_values_named": { + "frame_idx": 0, + "strength": 1 + } + }, + { + "id": 16, + "type": "AUSBOSS_NODES_Seed", + "pos": [ + -140, + 1092 + ], + "size": [ + 460, + 216 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "slot_index": 0, + "links": [ + 23 + ] + } + ], + "properties": { + "cnr_id": "ausboss-nodes", + "ver": "2.0.0", + "Node name for S&R": "AUSBOSS_NODES_Seed" + }, + "widgets_values": [ + 20260903, + "fixed", + "" + ], + "widgets_values_named": { + "seed": 20260903, + "control_after_generate": "fixed", + "ausboss_seed_panel": "" + }, + "color": "#14424d", + "bgcolor": "#161f21" + }, + { + "id": 17, + "type": "KSampler", + "pos": [ + -140, + 1358 + ], + "size": [ + 320, + 262 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 19 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 20 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 21 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 22 + }, + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 23 + }, + { + "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 + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "slot_index": 0, + "links": [ + 26 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 20260903, + "fixed", + 8, + 1, + "euler_ancestral", + "linear_quadratic", + 1 + ], + "widgets_values_named": { + "seed": 20260903, + "control_after_generate": "fixed", + "steps": 8, + "cfg": 1, + "sampler_name": "euler_ancestral", + "scheduler": "linear_quadratic", + "denoise": 1 + } + }, + { + "id": 18, + "type": "AUSBOSS_NODES_RunTimer", + "pos": [ + -140, + 1670 + ], + "size": [ + 176, + 56 + ], + "flags": { + "no_title": true + }, + "order": 6, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": { + "cnr_id": "ausboss-nodes", + "ver": "2.0.0", + "Node name for S&R": "AUSBOSS_NODES_RunTimer" + }, + "color": "#07090a", + "bgcolor": "#07090a" + }, + { + "id": 19, + "type": "LTXVCropGuides", + "pos": [ + 380, + 100 + ], + "size": [ + 300, + 78 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 24 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 25 + }, + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "link": 26 + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "slot_index": 0, + "links": [] + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "slot_index": 1, + "links": [] + }, + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "slot_index": 2, + "links": [ + 27 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "LTXVCropGuides" + } + }, + { + "id": 20, + "type": "VAEDecodeTiled", + "pos": [ + 380, + 228 + ], + "size": [ + 300, + 150 + ], + "flags": {}, + "order": 18, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 27 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 28 + }, + { + "localized_name": "tile_size", + "name": "tile_size", + "type": "INT", + "widget": { + "name": "tile_size" + }, + "link": null + }, + { + "localized_name": "overlap", + "name": "overlap", + "type": "INT", + "widget": { + "name": "overlap" + }, + "link": null + }, + { + "localized_name": "temporal_size", + "name": "temporal_size", + "type": "INT", + "widget": { + "name": "temporal_size" + }, + "link": null + }, + { + "localized_name": "temporal_overlap", + "name": "temporal_overlap", + "type": "INT", + "widget": { + "name": "temporal_overlap" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "slot_index": 0, + "links": [ + 30 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "VAEDecodeTiled" + }, + "widgets_values": [ + 768, + 64, + 4096, + 64 + ], + "widgets_values_named": { + "tile_size": 768, + "overlap": 64, + "temporal_size": 4096, + "temporal_overlap": 64 + } + }, + { + "id": 21, + "type": "AUSBOSS_NODES_StitchInpaint", + "pos": [ + 380, + 428 + ], + "size": [ + 400, + 145 + ], + "flags": {}, + "order": 19, + "mode": 0, + "inputs": [ + { + "localized_name": "stitcher", + "name": "stitcher", + "type": "AUSBOSS_STITCHER", + "link": 29 + }, + { + "localized_name": "inpainted", + "name": "inpainted", + "type": "IMAGE", + "link": 30 + }, + { + "localized_name": "fix_edge_halo", + "name": "fix_edge_halo", + "shape": 7, + "type": "BOOLEAN", + "widget": { + "name": "fix_edge_halo" + }, + "link": null + }, + { + "localized_name": "color_match", + "name": "color_match", + "shape": 7, + "type": "FLOAT", + "widget": { + "name": "color_match" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "slot_index": 0, + "links": [ + 31 + ] + }, + { + "localized_name": "blend_mask", + "name": "blend_mask", + "type": "MASK", + "slot_index": 1, + "links": [] + } + ], + "title": "Stitch Inpaint 🆎 · original pixels back, tone matched", + "properties": { + "cnr_id": "ausboss-nodes", + "ver": "2.0.0", + "Node name for S&R": "AUSBOSS_NODES_StitchInpaint" + }, + "widgets_values": [ + false, + 1 + ], + "widgets_values_named": { + "fix_edge_halo": false, + "color_match": 1 + }, + "color": "#14424d", + "bgcolor": "#161f21" + }, + { + "id": 22, + "type": "AUSBOSS_NODES_SaveVideo", + "pos": [ + 380, + 623 + ], + "size": [ + 620, + 560 + ], + "flags": {}, + "order": 20, + "mode": 0, + "inputs": [ + { + "localized_name": "frames", + "name": "frames", + "shape": 7, + "type": "IMAGE", + "link": 31 + }, + { + "localized_name": "audio", + "name": "audio", + "shape": 7, + "type": "AUDIO", + "link": 32 + }, + { + "localized_name": "video", + "name": "video", + "shape": 7, + "type": "VIDEO", + "link": null + }, + { + "localized_name": "fps", + "name": "fps", + "type": "FLOAT", + "widget": { + "name": "fps" + }, + "link": 33 + }, + { + "localized_name": "filename_prefix", + "name": "filename_prefix", + "type": "STRING", + "widget": { + "name": "filename_prefix" + }, + "link": null + }, + { + "localized_name": "crf", + "name": "crf", + "type": "INT", + "widget": { + "name": "crf" + }, + "link": null + }, + { + "localized_name": "format", + "name": "format", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "format" + }, + "link": null + }, + { + "localized_name": "pingpong", + "name": "pingpong", + "shape": 7, + "type": "BOOLEAN", + "widget": { + "name": "pingpong" + }, + "link": null + }, + { + "localized_name": "save_metadata", + "name": "save_metadata", + "shape": 7, + "type": "BOOLEAN", + "widget": { + "name": "save_metadata" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "file_path", + "name": "file_path", + "type": "STRING", + "slot_index": 0, + "links": [] + } + ], + "title": "Save Video 🆎 · with the source audio", + "properties": { + "cnr_id": "ausboss-nodes", + "ver": "2.0.0", + "Node name for S&R": "AUSBOSS_NODES_SaveVideo", + "ausboss_save_video_loop": true + }, + "widgets_values": { + "fps": 24, + "filename_prefix": "AusBoss/ltx_video_outpaint", + "crf": 17, + "format": "mp4 h264", + "pingpong": false, + "save_metadata": true + }, + "widgets_values_named": { + "fps": 24, + "filename_prefix": "AusBoss/ltx_video_outpaint", + "crf": 17, + "format": "mp4 h264", + "pingpong": false, + "save_metadata": true, + "ausboss_save_video_viewer": "" + }, + "color": "#14424d", + "bgcolor": "#161f21" + } + ], + "links": [ + [ + 1, + 2, + 0, + 3, + 0, + "MODEL" + ], + [ + 2, + 3, + 0, + 4, + 0, + "MODEL" + ], + [ + 3, + 4, + 0, + 5, + 0, + "MODEL" + ], + [ + 4, + 6, + 0, + 11, + 0, + "CLIP" + ], + [ + 5, + 6, + 0, + 12, + 0, + "CLIP" + ], + [ + 6, + 8, + 0, + 9, + 0, + "IMAGE" + ], + [ + 7, + 11, + 0, + 13, + 0, + "CONDITIONING" + ], + [ + 8, + 12, + 0, + 13, + 1, + "CONDITIONING" + ], + [ + 9, + 8, + 4, + 13, + 2, + "FLOAT" + ], + [ + 10, + 8, + 5, + 14, + 0, + "INT" + ], + [ + 11, + 8, + 6, + 14, + 1, + "INT" + ], + [ + 12, + 8, + 3, + 14, + 2, + "INT" + ], + [ + 13, + 13, + 0, + 15, + 0, + "CONDITIONING" + ], + [ + 14, + 13, + 1, + 15, + 1, + "CONDITIONING" + ], + [ + 15, + 7, + 0, + 15, + 2, + "VAE" + ], + [ + 16, + 14, + 0, + 15, + 3, + "LATENT" + ], + [ + 17, + 9, + 0, + 15, + 4, + "IMAGE" + ], + [ + 18, + 5, + 0, + 15, + 6, + "IC_LORA_PARAMETERS" + ], + [ + 19, + 4, + 0, + 17, + 0, + "MODEL" + ], + [ + 20, + 15, + 0, + 17, + 1, + "CONDITIONING" + ], + [ + 21, + 15, + 1, + 17, + 2, + "CONDITIONING" + ], + [ + 22, + 15, + 2, + 17, + 3, + "LATENT" + ], + [ + 23, + 16, + 0, + 17, + 4, + "INT" + ], + [ + 24, + 15, + 0, + 19, + 0, + "CONDITIONING" + ], + [ + 25, + 15, + 1, + 19, + 1, + "CONDITIONING" + ], + [ + 26, + 17, + 0, + 19, + 2, + "LATENT" + ], + [ + 27, + 19, + 2, + 20, + 0, + "LATENT" + ], + [ + 28, + 7, + 0, + 20, + 1, + "VAE" + ], + [ + 29, + 8, + 8, + 21, + 0, + "AUSBOSS_STITCHER" + ], + [ + 30, + 20, + 0, + 21, + 1, + "IMAGE" + ], + [ + 31, + 21, + 0, + 22, + 0, + "IMAGE" + ], + [ + 32, + 8, + 2, + 22, + 1, + "AUDIO" + ], + [ + 33, + 8, + 4, + 22, + 3, + "FLOAT" + ] + ], + "groups": [ + { + "id": 1, + "title": "1 · Models", + "bounding": [ + -1140, + 40, + 470, + 810 + ], + "color": "#235d64", + "flags": {} + }, + { + "id": 2, + "title": "2 · Source video + canvas", + "bounding": [ + -650, + 40, + 490, + 928 + ], + "color": "#366451", + "flags": {} + }, + { + "id": 3, + "title": "3 · Prompt", + "bounding": [ + -160, + 40, + 500, + 588 + ], + "color": "#5d527c", + "flags": {} + }, + { + "id": 4, + "title": "4 · Sample", + "bounding": [ + -160, + 600, + 500, + 1146 + ], + "color": "#795b38", + "flags": {} + }, + { + "id": 5, + "title": "5 · Decode, stitch, save", + "bounding": [ + 360, + 40, + 660, + 1163 + ], + "color": "#345d7c", + "flags": {} + } + ], + "config": {}, + "extra": { + "ds": { + "scale": 0.4263, + "offset": [ + 1967.66, + 368.78 + ] + } + }, + "version": 0.4 +} diff --git a/example_workflows/MiniMax H3 First + Last Frame (AusBoss).jpg b/example_workflows/MiniMax H3 First + Last Frame (AusBoss).jpg new file mode 100644 index 0000000..ef26c7a Binary files /dev/null and b/example_workflows/MiniMax H3 First + Last Frame (AusBoss).jpg differ diff --git a/example_workflows/MiniMax H3 First + Last Frame (AusBoss).json b/example_workflows/MiniMax H3 First + Last Frame (AusBoss).json new file mode 100644 index 0000000..9cb29d4 --- /dev/null +++ b/example_workflows/MiniMax H3 First + Last Frame (AusBoss).json @@ -0,0 +1,1640 @@ +{ + "id": "38d93e0d-9cc8-493e-8338-09eb994e7406", + "revision": 0, + "last_node_id": 71, + "last_link_id": 24, + "nodes": [ + { + "id": 51, + "type": "UNETLoader", + "pos": [ + 0, + 100 + ], + "size": [ + 440, + 82 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "unet_name", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": null + }, + { + "localized_name": "weight_dtype", + "name": "weight_dtype", + "type": "COMBO", + "widget": { + "name": "weight_dtype" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 23 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "UNETLoader", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + "minimax-h3/minimax_h3_fl2va_pruned_int8_convrot.safetensors", + "default" + ], + "widgets_values_named": { + "unet_name": "minimax-h3/minimax_h3_fl2va_pruned_int8_convrot.safetensors", + "weight_dtype": "default" + } + }, + { + "id": 52, + "type": "CLIPLoader", + "pos": [ + 0, + 232 + ], + "size": [ + 440, + 106 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": null + }, + { + "localized_name": "type", + "name": "type", + "type": "COMBO", + "widget": { + "name": "type" + }, + "link": null + }, + { + "localized_name": "device", + "name": "device", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "device" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [ + 4 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "CLIPLoader", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + "minimax-h3/qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors", + "minimax", + "default" + ], + "widgets_values_named": { + "clip_name": "minimax-h3/qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors", + "type": "minimax", + "device": "default" + } + }, + { + "id": 53, + "type": "VAELoader", + "pos": [ + 0, + 388 + ], + "size": [ + 440, + 58 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 5, + 18 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "VAELoader", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + "minimax_h3_video_vae_fp16.safetensors" + ], + "widgets_values_named": { + "vae_name": "minimax_h3_video_vae_fp16.safetensors" + } + }, + { + "id": 54, + "type": "VAELoader", + "pos": [ + 0, + 496 + ], + "size": [ + 440, + 58 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 20 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "VAELoader", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + "minimax_h3_audio_vae_fp32.safetensors" + ], + "widgets_values_named": { + "vae_name": "minimax_h3_audio_vae_fp32.safetensors" + } + }, + { + "id": 56, + "type": "MiniMaxH3SigmaShift", + "pos": [ + 0, + 604 + ], + "size": [ + 440, + 82 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 23 + }, + { + "localized_name": "shift_video", + "name": "shift_video", + "type": "FLOAT", + "widget": { + "name": "shift_video" + }, + "link": null + }, + { + "localized_name": "shift_audio", + "name": "shift_audio", + "type": "FLOAT", + "widget": { + "name": "shift_audio" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 13 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "MiniMaxH3SigmaShift", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + 12, + 3 + ], + "widgets_values_named": { + "shift_video": 12, + "shift_audio": 3 + } + }, + { + "id": 57, + "type": "AUSBOSS_NODES_Resolution", + "pos": [ + 1040, + 100 + ], + "size": [ + 340, + 531 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "link": null + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "link": null + }, + { + "localized_name": "latent", + "name": "latent", + "shape": 7, + "type": "COMBO", + "link": null + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "shape": 7, + "type": "INT", + "link": null + } + ], + "outputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "links": [ + 6 + ] + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "links": [ + 7 + ] + }, + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "links": null + } + ], + "properties": { + "cnr_id": "ausboss-nodes", + "ver": "2.0.0", + "Node name for S&R": "AUSBOSS_NODES_Resolution", + "ausbossResolutionPortrait": true, + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + 768, + 1120, + "16ch", + 1, + "" + ], + "widgets_values_named": { + "width": 768, + "height": 1120, + "latent": "16ch", + "batch_size": 1, + "ausboss_resolution_panel": "" + }, + "color": "#14424d", + "bgcolor": "#161f21" + }, + { + "id": 58, + "type": "AUSBOSS_NODES_Float", + "pos": [ + 1040, + 681 + ], + "size": [ + 440, + 94 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "FLOAT", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "float", + "name": "float", + "type": "FLOAT", + "links": [ + 3 + ] + } + ], + "properties": { + "cnr_id": "ausboss-nodes", + "ver": "2.0.0", + "Node name for S&R": "AUSBOSS_NODES_Float", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + 5, + "" + ], + "widgets_values_named": { + "value": 5, + "ausboss_widget_card": "" + }, + "color": "#14424d", + "bgcolor": "#161f21" + }, + { + "id": 59, + "type": "AUSBOSS_NODES_MathExpression", + "pos": [ + 1040, + 825 + ], + "size": [ + 440, + 134 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "localized_name": "a", + "name": "a", + "shape": 7, + "type": "FLOAT,INT", + "link": 3 + }, + { + "localized_name": "b", + "name": "b", + "shape": 7, + "type": "FLOAT,INT", + "link": null + }, + { + "localized_name": "c", + "name": "c", + "shape": 7, + "type": "FLOAT,INT", + "link": null + }, + { + "localized_name": "expression", + "name": "expression", + "type": "STRING", + "widget": { + "name": "expression" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "float", + "name": "float", + "type": "FLOAT", + "links": null + }, + { + "localized_name": "int", + "name": "int", + "type": "INT", + "links": [ + 8 + ] + } + ], + "properties": { + "cnr_id": "ausboss-nodes", + "ver": "2.0.0", + "Node name for S&R": "AUSBOSS_NODES_MathExpression", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + "max(5, ceil((a * 24 - 5) / 17) * 17 + 5)", + "" + ], + "widgets_values_named": { + "expression": "max(5, ceil((a * 24 - 5) / 17) * 17 + 5)", + "ausboss_widget_card": "" + }, + "color": "#14424d", + "bgcolor": "#161f21" + }, + { + "id": 60, + "type": "AUSBOSS_NODES_Text", + "pos": [ + 1560, + 100 + ], + "size": [ + 460, + 340 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "links": [ + 9 + ] + } + ], + "properties": { + "cnr_id": "ausboss-nodes", + "ver": "2.0.0", + "Node name for S&R": "AUSBOSS_NODES_Text", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + "How the reference pictures align with the target video — Picture 1 (from Shot 1) aligns with the 0.00-second mark of the target video; Picture 2 (from Shot 1) aligns with the 5.17-second mark of the target video.\n\nintegrated_multimodal_description: [Shot 1] Live-action, cinematic, the opening composition matches Picture 1: a wide view across an alpine lake at sunrise, with the low wooden pier entering from the right and a small rowboat beside its far end. The camera holds a static shot. The boat glides across the water toward its position in Picture 2, leaving small spreading ripples. Thin mist drifts above the water. Keep the pier low in the frame and the distant mountains and shoreline consistent. The view settles into the boat position, spacing, viewpoint and lighting of Picture 2.\n\noverall_soundscape: Soft water laps against the wooden posts throughout, with faint distant birdsong.\n\nnon_diegetic_music: N/A" + ], + "widgets_values_named": { + "text": "How the reference pictures align with the target video — Picture 1 (from Shot 1) aligns with the 0.00-second mark of the target video; Picture 2 (from Shot 1) aligns with the 5.17-second mark of the target video.\n\nintegrated_multimodal_description: [Shot 1] Live-action, cinematic, the opening composition matches Picture 1: a wide view across an alpine lake at sunrise, with the low wooden pier entering from the right and a small rowboat beside its far end. The camera holds a static shot. The boat glides across the water toward its position in Picture 2, leaving small spreading ripples. Thin mist drifts above the water. Keep the pier low in the frame and the distant mountains and shoreline consistent. The view settles into the boat position, spacing, viewpoint and lighting of Picture 2.\n\noverall_soundscape: Soft water laps against the wooden posts throughout, with faint distant birdsong.\n\nnon_diegetic_music: N/A" + }, + "color": "#14424d", + "bgcolor": "#161f21" + }, + { + "id": 61, + "type": "MiniMaxH3ImageToVideo", + "pos": [ + 1560, + 490 + ], + "size": [ + 460, + 260 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 4 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 5 + }, + { + "localized_name": "first_frame", + "name": "first_frame", + "shape": 7, + "type": "IMAGE", + "link": 10 + }, + { + "localized_name": "last_frame", + "name": "last_frame", + "shape": 7, + "type": "IMAGE", + "link": 11 + }, + { + "localized_name": "prompt", + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": 9 + }, + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 6 + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 7 + }, + { + "localized_name": "length", + "name": "length", + "type": "INT", + "widget": { + "name": "length" + }, + "link": 8 + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "links": [ + 12, + 14 + ] + }, + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 16 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "MiniMaxH3ImageToVideo", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + "", + 1344, + 768, + 124 + ], + "widgets_values_named": { + "prompt": "", + "width": 1344, + "height": 768, + "length": 124 + } + }, + { + "id": 62, + "type": "LoadImage", + "pos": [ + 520, + 100 + ], + "size": [ + 440, + 490 + ], + "flags": {}, + "order": 7, + "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", + "links": [ + 10 + ] + }, + { + "localized_name": "MASK", + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "title": "Opening image", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "LoadImage", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + "ausboss_pier_sunrise.png", + "image" + ], + "widgets_values_named": { + "image": "ausboss_pier_sunrise.png", + "upload": "image" + } + }, + { + "id": 63, + "type": "LoadImage", + "pos": [ + 520, + 640 + ], + "size": [ + 440, + 490 + ], + "flags": {}, + "order": 8, + "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", + "links": [ + 11 + ] + }, + { + "localized_name": "MASK", + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "title": "Ending image", + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "LoadImage", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + "ausboss_pier_rowboat.png", + "image" + ], + "widgets_values_named": { + "image": "ausboss_pier_rowboat.png", + "upload": "image" + } + }, + { + "id": 64, + "type": "ConditioningZeroOut", + "pos": [ + 1560, + 800 + ], + "size": [ + 460, + 26 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "localized_name": "conditioning", + "name": "conditioning", + "type": "CONDITIONING", + "link": 12 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 15 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "ConditioningZeroOut", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + } + }, + { + "id": 65, + "type": "KSampler", + "pos": [ + 2100, + 366 + ], + "size": [ + 440, + 262 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 13 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 14 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 15 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 16 + }, + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 24 + }, + { + "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 + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 17, + 19 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "KSampler", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + 20260905, + "fixed", + 20, + 1, + "res_multistep", + "simple", + 1 + ], + "widgets_values_named": { + "seed": 20260905, + "control_after_generate": "randomize", + "steps": 20, + "cfg": 1, + "sampler_name": "res_multistep", + "scheduler": "simple", + "denoise": 1 + } + }, + { + "id": 66, + "type": "VAEDecode", + "pos": [ + 2100, + 678 + ], + "size": [ + 440, + 46 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 17 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 18 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 21 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "VAEDecode", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + } + }, + { + "id": 67, + "type": "VAEDecodeAudio", + "pos": [ + 2100, + 774 + ], + "size": [ + 440, + 46 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 19 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 20 + } + ], + "outputs": [ + { + "localized_name": "AUDIO", + "name": "AUDIO", + "type": "AUDIO", + "links": [ + 22 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "VAEDecodeAudio", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + } + }, + { + "id": 68, + "type": "AUSBOSS_NODES_SaveVideo", + "pos": [ + 2620, + 100 + ], + "size": [ + 620, + 700 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [ + { + "localized_name": "frames", + "name": "frames", + "shape": 7, + "type": "IMAGE", + "link": 21 + }, + { + "localized_name": "audio", + "name": "audio", + "shape": 7, + "type": "AUDIO", + "link": 22 + }, + { + "localized_name": "video", + "name": "video", + "shape": 7, + "type": "VIDEO", + "link": null + }, + { + "localized_name": "fps", + "name": "fps", + "type": "FLOAT", + "link": null + }, + { + "localized_name": "filename_prefix", + "name": "filename_prefix", + "type": "STRING", + "widget": { + "name": "filename_prefix" + }, + "link": null + }, + { + "localized_name": "crf", + "name": "crf", + "type": "INT", + "link": null + }, + { + "localized_name": "format", + "name": "format", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "format" + }, + "link": null + }, + { + "localized_name": "pingpong", + "name": "pingpong", + "shape": 7, + "type": "BOOLEAN", + "widget": { + "name": "pingpong" + }, + "link": null + }, + { + "localized_name": "save_metadata", + "name": "save_metadata", + "shape": 7, + "type": "BOOLEAN", + "widget": { + "name": "save_metadata" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "file_path", + "name": "file_path", + "type": "STRING", + "links": null + } + ], + "properties": { + "cnr_id": "ausboss-nodes", + "ver": "2.0.0", + "Node name for S&R": "AUSBOSS_NODES_SaveVideo", + "ausboss_save_video_loop": true, + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": { + "fps": 24, + "filename_prefix": "AusBoss/minimax_h3_first_last_base", + "crf": 17, + "format": "mp4 h264", + "pingpong": false, + "save_metadata": true + }, + "widgets_values_named": { + "fps": 24, + "filename_prefix": "AusBoss/minimax_h3_first_last_base", + "crf": 17, + "format": "mp4 h264", + "pingpong": false, + "save_metadata": true, + "ausboss_widget_card": "", + "ausboss_save_video_viewer": "" + }, + "color": "#14424d", + "bgcolor": "#161f21" + }, + { + "id": 69, + "type": "AUSBOSS_NODES_WorkflowNote", + "pos": [ + -620, + 100 + ], + "size": [ + 560, + 1100 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "localized_name": "note", + "name": "note", + "type": "STRING", + "widget": { + "name": "note" + }, + "link": null + } + ], + "outputs": [], + "properties": { + "cnr_id": "ausboss-nodes", + "ver": "2.0.0", + "Node name for S&R": "AUSBOSS_NODES_WorkflowNote", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.8" + } + }, + "widgets_values": [ + "{\"v\": 1, \"title\": \"MiniMax H3 First + Last Frame\", \"subtitle\": \"Two endpoint images, a stable transition and native audio.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"## Setup\\nCopy the supplied media from `example_workflows/inputs/` into `ComfyUI/input/`. Choose each installed model in its loader; saved selections may include local subfolders. Download links below name the standard destination folders.\\n\\n## Start here\\nChoose the four installed H3 model files. This example uses the base model without a turbo LoRA: 20 res_multistep/simple steps, CFG 1, video sigma shift 12 and audio shift 3. It is slower than the turbo examples, but the tested shot keeps the scene much more consistent.\\n\\n## Your shot\\nUpload the two sample images from example_workflows/inputs, or choose your own. Opening image and Ending image connect separately to H3. Resolution Master sets the target canvas to 768 × 1120. Similar framing and lighting in both images make the transition easier.\\n\\nSeconds rounds up to the 17k+5 grid at 24 fps: five seconds becomes 124 frames, or 5.17 seconds. Keep the frame rate at 24. If you change Seconds, update the final alignment time in the prompt too (frame count / 24).\\n\\nThe supplied prompt keeps the camera fixed while the boat, water and mist move gently. The image inputs guide the opening and ending; inspect the render before using it. Both video and native audio are saved together.\\n\\n## Why no turbo LoRA?\\nIn a same-seed comparison, the 4-step LoRA invented large foreground objects and an unnatural spray arc. The 20-step base run kept a stable lake and pier with subtle motion. That is evidence for this sample, not a guarantee for every shot.\\n\\n[Native H3 workflow documentation](https://docs.comfy.org/tutorials/video/minimax/minimax-h3)\", \"models\": [{\"name\": \"minimax_h3_fl2va_pruned_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"21 GB\", \"url\": \"https://huggingface.co/Comfy-Org/MiniMax-H3/resolve/main/diffusion_models/minimax_h3_fl2va_pruned_int8_convrot.safetensors\", \"note\": \"fp8_scaled (21 GB) and nvfp4 builds of the pruned fl2va model also work\"}, {\"name\": \"qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors\", \"dir\": \"text_encoders\", \"size\": \"15.7 GB\", \"url\": \"https://huggingface.co/Comfy-Org/MiniMax-H3/resolve/main/text_encoders/qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors\", \"note\": \"does not need a Blackwell card\"}, {\"name\": \"minimax_h3_video_vae_fp16.safetensors\", \"dir\": \"vae\", \"size\": \"5.2 GB\", \"url\": \"https://huggingface.co/Comfy-Org/MiniMax-H3/resolve/main/vae/minimax_h3_video_vae_fp16.safetensors\", \"note\": \"\"}, {\"name\": \"minimax_h3_audio_vae_fp32.safetensors\", \"dir\": \"vae\", \"size\": \"605 MB\", \"url\": \"https://huggingface.co/Comfy-Org/MiniMax-H3/resolve/main/vae/minimax_h3_audio_vae_fp32.safetensors\", \"note\": \"\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_MathExpression\"}]}" + ], + "widgets_values_named": { + "note": "{\"v\":1,\"title\":\"MiniMax H3 First + Last Frame\",\"subtitle\":\"Two endpoint images, a stable transition and native audio.\",\"author\":\"AusBoss\",\"layout\":\"card\",\"accent\":\"\",\"body\":\"## Start here\\nChoose the four installed H3 model files. This example uses the base model without a turbo LoRA: 20 res_multistep/simple steps, CFG 1, video sigma shift 12 and audio shift 3. It is slower than the turbo examples, but the tested shot keeps the scene much more consistent.\\n\\n## Your shot\\nUpload the two sample images from example_workflows/inputs, or choose your own. Opening image and Ending image connect separately to H3. Resolution Master sets the target canvas to 768 × 1120. Similar framing and lighting in both images make the transition easier.\\n\\nSeconds rounds up to the 17k+5 grid at 24 fps: five seconds becomes 124 frames, or 5.17 seconds. Keep the frame rate at 24. If you change Seconds, update the final alignment time in the prompt too (frame count / 24).\\n\\nThe supplied prompt keeps the camera fixed while the boat, water and mist move gently. The image inputs guide the opening and ending; inspect the render before using it. Both video and native audio are saved together.\\n\\n## Why no turbo LoRA?\\nIn a same-seed comparison, the 4-step LoRA invented large foreground objects and an unnatural spray arc. The 20-step base run kept a stable lake and pier with subtle motion. That is evidence for this sample, not a guarantee for every shot.\\n\\n[Native H3 workflow documentation](https://docs.comfy.org/tutorials/video/minimax/minimax-h3)\"}", + "ausboss_workflow_note_panel": "" + }, + "color": "#14424d", + "bgcolor": "#161f21" + }, + { + "id": 70, + "type": "AUSBOSS_NODES_Seed", + "pos": [ + 2100, + 100 + ], + "size": [ + 440, + 216 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "seed", + "type": "INT", + "links": [ + 24 + ] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_Seed", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + 20260905, + "fixed" + ] + }, + { + "id": 71, + "type": "AUSBOSS_NODES_RunTimer", + "pos": [ + 2100, + 870 + ], + "size": [ + 196, + 62 + ], + "flags": {}, + "order": 19, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_RunTimer", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [] + } + ], + "links": [ + [ + 3, + 58, + 0, + 59, + 0, + "FLOAT" + ], + [ + 4, + 52, + 0, + 61, + 0, + "CLIP" + ], + [ + 5, + 53, + 0, + 61, + 1, + "VAE" + ], + [ + 6, + 57, + 0, + 61, + 5, + "INT" + ], + [ + 7, + 57, + 1, + 61, + 6, + "INT" + ], + [ + 8, + 59, + 1, + 61, + 7, + "INT" + ], + [ + 9, + 60, + 0, + 61, + 4, + "STRING" + ], + [ + 10, + 62, + 0, + 61, + 2, + "IMAGE" + ], + [ + 11, + 63, + 0, + 61, + 3, + "IMAGE" + ], + [ + 12, + 61, + 0, + 64, + 0, + "CONDITIONING" + ], + [ + 13, + 56, + 0, + 65, + 0, + "MODEL" + ], + [ + 14, + 61, + 0, + 65, + 1, + "CONDITIONING" + ], + [ + 15, + 64, + 0, + 65, + 2, + "CONDITIONING" + ], + [ + 16, + 61, + 1, + 65, + 3, + "LATENT" + ], + [ + 17, + 65, + 0, + 66, + 0, + "LATENT" + ], + [ + 18, + 53, + 0, + 66, + 1, + "VAE" + ], + [ + 19, + 65, + 0, + 67, + 0, + "LATENT" + ], + [ + 20, + 54, + 0, + 67, + 1, + "VAE" + ], + [ + 21, + 66, + 0, + 68, + 0, + "IMAGE" + ], + [ + 22, + 67, + 0, + 68, + 1, + "AUDIO" + ], + [ + 23, + 51, + 0, + 56, + 0, + "MODEL" + ], + [ + 24, + 70, + 0, + 65, + 4, + "INT" + ] + ], + "groups": [ + { + "title": "1 · Models", + "bounding": [ + -20, + 40, + 480, + 666 + ], + "color": "#235d64", + "font_size": 24, + "flags": {} + }, + { + "title": "2 · Opening + ending pictures", + "bounding": [ + 500, + 40, + 480, + 1110 + ], + "color": "#366451", + "font_size": 24, + "flags": {} + }, + { + "title": "3 · Canvas + duration", + "bounding": [ + 1020, + 40, + 480, + 939 + ], + "color": "#5d527c", + "font_size": 24, + "flags": {} + }, + { + "title": "4 · Shot + conditioning", + "bounding": [ + 1540, + 40, + 500, + 806 + ], + "color": "#795b38", + "font_size": 24, + "flags": {} + }, + { + "title": "5 · Seed, sample + decode", + "bounding": [ + 2080, + 40, + 480, + 912 + ], + "color": "#345d7c", + "font_size": 24, + "flags": {} + }, + { + "title": "6 · Video + audio output", + "bounding": [ + 2600, + 40, + 660, + 780 + ], + "color": "#65515e", + "font_size": 24, + "flags": {} + } + ], + "config": {}, + "extra": { + "ds": { + "scale": 0.4112, + "offset": [ + 854.55, + 383.42 + ] + }, + "comfyui_mcp": { + "workflow_uuid": "9afa7ed4-022f-436f-955b-6fc59c8a4479" + }, + "ue_links": [] + }, + "version": 0.4 +} diff --git a/example_workflows/MiniMax H3 Image to Video (AusBoss).jpg b/example_workflows/MiniMax H3 Image to Video (AusBoss).jpg new file mode 100644 index 0000000..30c1d03 Binary files /dev/null and b/example_workflows/MiniMax H3 Image to Video (AusBoss).jpg differ diff --git a/example_workflows/MiniMax H3 Image to Video (AusBoss).json b/example_workflows/MiniMax H3 Image to Video (AusBoss).json new file mode 100644 index 0000000..0ddb85f --- /dev/null +++ b/example_workflows/MiniMax H3 Image to Video (AusBoss).json @@ -0,0 +1,1224 @@ +{ + "id": "", + "revision": 0, + "last_node_id": 21, + "last_link_id": 24, + "nodes": [ + { + "id": 1, + "type": "UNETLoader", + "pos": [ + -1060, + 100 + ], + "size": [ + 430, + 82 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 1 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader" + }, + "widgets_values": [ + "minimax-h3/minimax_h3_fl2va_pruned_int8_convrot.safetensors", + "default" + ] + }, + { + "id": 2, + "type": "CLIPLoader", + "pos": [ + -1060, + 232 + ], + "size": [ + 430, + 106 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "CLIP", + "type": "CLIP", + "links": [ + 11 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader" + }, + "widgets_values": [ + "minimax-h3/qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors", + "minimax", + "default" + ] + }, + { + "id": 3, + "type": "VAELoader", + "pos": [ + -1060, + 388 + ], + "size": [ + 430, + 58 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "VAE", + "type": "VAE", + "links": [ + 12, + 20 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader" + }, + "widgets_values": [ + "minimax_h3_video_vae_fp16.safetensors" + ], + "title": "Load VAE · video" + }, + { + "id": 4, + "type": "VAELoader", + "pos": [ + -1060, + 496 + ], + "size": [ + 430, + 58 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "VAE", + "type": "VAE", + "links": [ + 22 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader" + }, + "widgets_values": [ + "minimax_h3_audio_vae_fp32.safetensors" + ], + "title": "Load VAE · audio" + }, + { + "id": 5, + "type": "AUSBOSS_NODES_LoraLoader", + "pos": [ + -1060, + 604 + ], + "size": [ + 430, + 188 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 1 + }, + { + "name": "clip", + "type": "CLIP", + "link": null, + "shape": 7 + } + ], + "outputs": [ + { + "name": "model", + "type": "MODEL", + "links": [ + 2 + ] + }, + { + "name": "clip", + "type": "CLIP", + "links": [] + }, + { + "name": "triggers", + "type": "STRING", + "links": [] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_LoraLoader", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "[{\"name\":\"minimaxh3/minimax_h3_fl2v_turbo_4step_v1.0_768p_comfyui_bf16.safetensors\",\"strength\":1,\"enabled\":true,\"triggers\":\"\"}]", + ", ", + "" + ], + "title": "LoRA Loader 🆎 · turbo LoRA, model only" + }, + { + "id": 6, + "type": "MiniMaxH3SigmaShift", + "pos": [ + -1060, + 842 + ], + "size": [ + 430, + 82 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 2 + } + ], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 14 + ] + } + ], + "properties": { + "Node name for S&R": "MiniMaxH3SigmaShift" + }, + "widgets_values": [ + 6.0, + 3.0 + ], + "title": "MiniMax H3 Sigma Shift · for the turbo LoRA" + }, + { + "id": 7, + "type": "LoadImage", + "pos": [ + -580, + 100 + ], + "size": [ + 460, + 720 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 3 + ] + }, + { + "name": "MASK", + "type": "MASK", + "links": [] + } + ], + "properties": { + "Node name for S&R": "LoadImage" + }, + "widgets_values": [ + "ausboss_pier_sunrise.png", + "image" + ], + "title": "Load Image · first frame" + }, + { + "id": 8, + "type": "AUSBOSS_NODES_ImageResize", + "pos": [ + -580, + 870 + ], + "size": [ + 460, + 330 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 3 + }, + { + "name": "mask", + "type": "MASK", + "link": null, + "shape": 7 + } + ], + "outputs": [ + { + "name": "image", + "type": "IMAGE", + "links": [ + 4, + 5 + ] + }, + { + "name": "mask", + "type": "MASK", + "links": [] + }, + { + "name": "width", + "type": "INT", + "links": [] + }, + { + "name": "height", + "type": "INT", + "links": [] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_ImageResize", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "shortest_edge", + 0, + 0, + 768, + 1.0, + 0.5, + "fit", + "#000000", + 32, + "lanczos" + ], + "title": "Image Resize 🆎 · H3's native 768 px short edge" + }, + { + "id": 9, + "type": "AUSBOSS_NODES_ImageSize", + "pos": [ + -60, + 100 + ], + "size": [ + 420, + 130 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 4 + } + ], + "outputs": [ + { + "name": "width", + "type": "INT", + "links": [ + 6 + ] + }, + { + "name": "height", + "type": "INT", + "links": [ + 7 + ] + }, + { + "name": "longest_edge", + "type": "INT", + "links": [] + }, + { + "name": "shortest_edge", + "type": "INT", + "links": [] + }, + { + "name": "count", + "type": "INT", + "links": [] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_ImageSize", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [], + "title": "Image Size 🆎 · → width, height" + }, + { + "id": 10, + "type": "AUSBOSS_NODES_Float", + "pos": [ + -60, + 280 + ], + "size": [ + 420, + 100 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "float", + "type": "FLOAT", + "links": [ + 8 + ] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_Float", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + 3.0 + ], + "title": "Float 🆎 · seconds" + }, + { + "id": 11, + "type": "AUSBOSS_NODES_MathExpression", + "pos": [ + -60, + 430 + ], + "size": [ + 420, + 134 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "name": "a", + "type": "FLOAT,INT", + "link": 8, + "shape": 7 + }, + { + "name": "b", + "type": "FLOAT,INT", + "link": null, + "shape": 7 + }, + { + "name": "c", + "type": "FLOAT,INT", + "link": null, + "shape": 7 + } + ], + "outputs": [ + { + "name": "float", + "type": "FLOAT", + "links": [] + }, + { + "name": "int", + "type": "INT", + "links": [ + 9 + ] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_MathExpression", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "max(5, ceil((a * 24 - 5) / 17) * 17 + 5)", + "" + ], + "title": "Math Expression 🆎 · seconds → H3 frame count" + }, + { + "id": 12, + "type": "AUSBOSS_NODES_Text", + "pos": [ + -60, + 614 + ], + "size": [ + 420, + 230 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "text", + "type": "STRING", + "links": [ + 10 + ] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_Text", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "A still alpine lake at sunrise. The small rowboat drifts slowly away from the pier, rocking on gentle waves, its oar dipping into the water. Mist rolls across the lake and a flock of birds crosses the sky from left to right. The camera slowly dollies forward along the pier. Audio: water lapping against the posts, a rope creaking, distant birdsong." + ], + "title": "Text 🆎 · shot, motion and audio in one block" + }, + { + "id": 13, + "type": "MiniMaxH3ImageToVideo", + "pos": [ + -60, + 894 + ], + "size": [ + 420, + 220 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 11 + }, + { + "name": "vae", + "type": "VAE", + "link": 12 + }, + { + "name": "first_frame", + "type": "IMAGE", + "link": 5, + "shape": 7 + }, + { + "name": "last_frame", + "type": "IMAGE", + "link": null, + "shape": 7 + }, + { + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": 10 + }, + { + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 6 + }, + { + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 7 + }, + { + "name": "length", + "type": "INT", + "widget": { + "name": "length" + }, + "link": 9 + } + ], + "outputs": [ + { + "name": "positive", + "type": "CONDITIONING", + "links": [ + 13, + 15 + ] + }, + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 17 + ] + } + ], + "properties": { + "Node name for S&R": "MiniMaxH3ImageToVideo" + }, + "widgets_values": [ + "", + 1344, + 768, + 73 + ], + "title": "MiniMax H3 Image to Video" + }, + { + "id": 14, + "type": "ConditioningZeroOut", + "pos": [ + -60, + 1164 + ], + "size": [ + 420, + 46 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "name": "conditioning", + "type": "CONDITIONING", + "link": 13 + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 16 + ] + } + ], + "properties": { + "Node name for S&R": "ConditioningZeroOut" + }, + "widgets_values": [] + }, + { + "id": 15, + "type": "AUSBOSS_NODES_Seed", + "pos": [ + 460, + 100 + ], + "size": [ + 440, + 216 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "seed", + "type": "INT", + "links": [ + 18 + ] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_Seed", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + 20260903, + "fixed" + ] + }, + { + "id": 16, + "type": "KSampler", + "pos": [ + 460, + 366 + ], + "size": [ + 440, + 262 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 14 + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 15 + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 16 + }, + { + "name": "latent_image", + "type": "LATENT", + "link": 17 + }, + { + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 18 + } + ], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 19, + 21 + ] + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 20260903, + "fixed", + 4, + 1.0, + "euler", + "simple", + 1.0 + ], + "title": "KSampler · turbo: 4 steps, CFG 1" + }, + { + "id": 17, + "type": "VAEDecode", + "pos": [ + 460, + 678 + ], + "size": [ + 440, + 46 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 19 + }, + { + "name": "vae", + "type": "VAE", + "link": 20 + } + ], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 23 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + }, + "widgets_values": [], + "title": "VAE Decode · video" + }, + { + "id": 18, + "type": "VAEDecodeAudio", + "pos": [ + 460, + 774 + ], + "size": [ + 440, + 46 + ], + "flags": {}, + "order": 18, + "mode": 0, + "inputs": [ + { + "name": "samples", + "type": "LATENT", + "link": 21 + }, + { + "name": "vae", + "type": "VAE", + "link": 22 + } + ], + "outputs": [ + { + "name": "AUDIO", + "type": "AUDIO", + "links": [ + 24 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecodeAudio" + }, + "widgets_values": [], + "title": "VAE Decode Audio" + }, + { + "id": 19, + "type": "AUSBOSS_NODES_RunTimer", + "pos": [ + 460, + 870 + ], + "size": [ + 196, + 62 + ], + "flags": {}, + "order": 19, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_RunTimer", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [] + }, + { + "id": 20, + "type": "AUSBOSS_NODES_SaveVideo", + "pos": [ + 960, + 100 + ], + "size": [ + 620, + 620 + ], + "flags": {}, + "order": 20, + "mode": 0, + "inputs": [ + { + "name": "frames", + "type": "IMAGE", + "link": 23, + "shape": 7 + }, + { + "name": "audio", + "type": "AUDIO", + "link": 24, + "shape": 7 + }, + { + "name": "video", + "type": "VIDEO", + "link": null, + "shape": 7 + } + ], + "outputs": [ + { + "name": "file_path", + "type": "STRING", + "links": [] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_SaveVideo", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + 24.0, + "AusBoss/minimax_h3_i2v", + 17, + "mp4 h264", + false, + true + ], + "title": "Save Video 🆎 · 24 fps, with the model's own audio" + }, + { + "id": 21, + "type": "AUSBOSS_NODES_WorkflowNote", + "pos": [ + -1680, + 100 + ], + "size": [ + 560, + 1100 + ], + "flags": {}, + "order": 21, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_WorkflowNote", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "{\"v\": 1, \"title\": \"MiniMax H3 Image to Video\", \"subtitle\": \"A picture becomes a short clip with its own sound. Image Resize sets H3's native canvas, Math Expression turns seconds into H3's frame grid, Save Video keeps the audio.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"## Setup\\nCopy the supplied media from `example_workflows/inputs/` into `ComfyUI/input/`. Choose each installed model in its loader; saved selections may include local subfolders. Download links below name the standard destination folders.\\n\\n## How it works\\n1. **Models** — the pruned int8 H3 model, its 32B Qwen3-VL text encoder, and the two VAEs (H3 generates picture *and* stereo sound in one pass, so there is a video VAE and an audio VAE). **LoRA Loader** adds the LightX2V turbo LoRA on the model only, and **Sigma Shift** is lowered to 6 for it.\\n2. **First frame** — **Image Resize** brings the picture to H3's native canvas: a 768 px short edge in steps of 32. **Image Size** reads the result back and drives the width and height of the video node, so the clip has exactly the shape of the picture.\\n3. **Length** — H3 only accepts frame counts of 17·k + 5 at 24 fps. Type the seconds you want into **Float**; **Math Expression** turns them into the next valid count (3 s → 73 frames).\\n4. **Prompt** — one block describing the shot, the motion and the audio. H3 reads the audio sentence as literally as the picture one.\\n5. **Sample + decode** — 4 steps, CFG 1, `euler` / `simple`. The latent is decoded twice: frames through the video VAE, sound through the audio VAE.\\n6. **Save Video** — mp4 at 24 fps with the generated audio muxed in and the workflow embedded. About 40 s for 73 frames at 768×1120 on a 32 GB card; the text encoder is the slow part the first time.\\n\\n## Try\\n- **Longer**: 5 s → 124 frames. VRAM and time grow with length; 8 s is about the practical ceiling at 768p on 32 GB.\\n- **Quality over speed**: swap in the 8-step turbo LoRA (same folder), set 8 steps. Without any LoRA H3 wants 20 steps and the default shift of 12.\\n- **Last frame too**: wire a second picture into `last_frame` and H3 animates between them.\\n- **Text to video**: disconnect `first_frame`; width and height then come from the Image Size node's picture, so replace it with an Integer pair or type them in.\\n\\nFrame count rounds up to the next valid 17k+5 value, so a requested six seconds produces 158 frames (6.58 seconds), never a shorter clip. This quick example starts at three seconds, below H3’s usual training range; start around five seconds when judging motion quality.\", \"models\": [{\"name\": \"minimax_h3_fl2va_pruned_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"21 GB\", \"url\": \"https://huggingface.co/Comfy-Org/MiniMax-H3/resolve/main/diffusion_models/minimax_h3_fl2va_pruned_int8_convrot.safetensors\", \"note\": \"fp8_scaled (21 GB) and nvfp4 builds of the pruned fl2va model also work\"}, {\"name\": \"qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors\", \"dir\": \"text_encoders\", \"size\": \"15.7 GB\", \"url\": \"https://huggingface.co/Comfy-Org/MiniMax-H3/resolve/main/text_encoders/qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors\", \"note\": \"does not need a Blackwell card\"}, {\"name\": \"minimax_h3_video_vae_fp16.safetensors\", \"dir\": \"vae\", \"size\": \"5.2 GB\", \"url\": \"https://huggingface.co/Comfy-Org/MiniMax-H3/resolve/main/vae/minimax_h3_video_vae_fp16.safetensors\", \"note\": \"\"}, {\"name\": \"minimax_h3_audio_vae_fp32.safetensors\", \"dir\": \"vae\", \"size\": \"605 MB\", \"url\": \"https://huggingface.co/Comfy-Org/MiniMax-H3/resolve/main/vae/minimax_h3_audio_vae_fp32.safetensors\", \"note\": \"\"}, {\"name\": \"minimax_h3_fl2v_turbo_4step_v1.0_768p_comfyui_bf16.safetensors\", \"dir\": \"loras\", \"size\": \"2.0 GB\", \"url\": \"https://huggingface.co/Comfy-Org/MiniMax-H3/resolve/main/loras/minimax_h3_fl2v_turbo_4step_v1.0_768p_comfyui_bf16.safetensors\", \"note\": \"LightX2V turbo LoRA; the 8-step build from the same folder trades speed for a little quality\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_MathExpression\"}], \"links\": [{\"label\": \"GitHub · ausboss\", \"url\": \"https://github.com/ausboss\"}, {\"label\": \"Civitai · AusBoss\", \"url\": \"https://civitai.com/user/AusBoss\"}, {\"label\": \"X · Zanzibased\", \"url\": \"https://x.com/Zanzibased\"}]}" + ] + } + ], + "links": [ + [ + 1, + 1, + 0, + 5, + 0, + "MODEL" + ], + [ + 2, + 5, + 0, + 6, + 0, + "MODEL" + ], + [ + 3, + 7, + 0, + 8, + 0, + "IMAGE" + ], + [ + 4, + 8, + 0, + 9, + 0, + "IMAGE" + ], + [ + 5, + 8, + 0, + 13, + 2, + "IMAGE" + ], + [ + 6, + 9, + 0, + 13, + 5, + "INT" + ], + [ + 7, + 9, + 1, + 13, + 6, + "INT" + ], + [ + 8, + 10, + 0, + 11, + 0, + "FLOAT" + ], + [ + 9, + 11, + 1, + 13, + 7, + "INT" + ], + [ + 10, + 12, + 0, + 13, + 4, + "STRING" + ], + [ + 11, + 2, + 0, + 13, + 0, + "CLIP" + ], + [ + 12, + 3, + 0, + 13, + 1, + "VAE" + ], + [ + 13, + 13, + 0, + 14, + 0, + "CONDITIONING" + ], + [ + 14, + 6, + 0, + 16, + 0, + "MODEL" + ], + [ + 15, + 13, + 0, + 16, + 1, + "CONDITIONING" + ], + [ + 16, + 14, + 0, + 16, + 2, + "CONDITIONING" + ], + [ + 17, + 13, + 1, + 16, + 3, + "LATENT" + ], + [ + 18, + 15, + 0, + 16, + 4, + "INT" + ], + [ + 19, + 16, + 0, + 17, + 0, + "LATENT" + ], + [ + 20, + 3, + 0, + 17, + 1, + "VAE" + ], + [ + 21, + 16, + 0, + 18, + 0, + "LATENT" + ], + [ + 22, + 4, + 0, + 18, + 1, + "VAE" + ], + [ + 23, + 17, + 0, + 20, + 0, + "IMAGE" + ], + [ + 24, + 18, + 0, + 20, + 1, + "AUDIO" + ] + ], + "groups": [ + { + "id": 1, + "title": "1 · Models, turbo LoRA", + "bounding": [ + -1080, + 40, + 470, + 904 + ], + "flags": {}, + "font_size": 24, + "color": "#235d64" + }, + { + "id": 2, + "title": "2 · First frame", + "bounding": [ + -600, + 40, + 500, + 1180 + ], + "flags": {}, + "font_size": 24, + "color": "#366451" + }, + { + "id": 3, + "title": "3 · Size, length, prompt", + "bounding": [ + -80, + 40, + 460, + 1190 + ], + "flags": {}, + "font_size": 24, + "color": "#5d527c" + }, + { + "id": 4, + "title": "4 · Sample + decode", + "bounding": [ + 440, + 40, + 480, + 912 + ], + "flags": {}, + "font_size": 24, + "color": "#795b38" + }, + { + "id": 5, + "title": "5 · Save", + "bounding": [ + 940, + 40, + 660, + 700 + ], + "flags": {}, + "font_size": 24, + "color": "#345d7c" + } + ], + "config": {}, + "extra": { + "ds": { + "scale": 0.485, + "offset": [ + 1884.95, + 320.52 + ] + } + }, + "version": 0.4 +} diff --git a/example_workflows/MiniMax H3 Text to Video (AusBoss).jpg b/example_workflows/MiniMax H3 Text to Video (AusBoss).jpg new file mode 100644 index 0000000..a351868 Binary files /dev/null and b/example_workflows/MiniMax H3 Text to Video (AusBoss).jpg differ diff --git a/example_workflows/MiniMax H3 Text to Video (AusBoss).json b/example_workflows/MiniMax H3 Text to Video (AusBoss).json new file mode 100644 index 0000000..3bcc265 --- /dev/null +++ b/example_workflows/MiniMax H3 Text to Video (AusBoss).json @@ -0,0 +1,1553 @@ +{ + "id": "00000000-0000-0000-0000-000000000000", + "revision": 0, + "last_node_id": 71, + "last_link_id": 21, + "nodes": [ + { + "id": 51, + "type": "UNETLoader", + "pos": [ + 0, + 100 + ], + "size": [ + 440, + 82 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "unet_name", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": null + }, + { + "localized_name": "weight_dtype", + "name": "weight_dtype", + "type": "COMBO", + "widget": { + "name": "weight_dtype" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 1 + ] + } + ], + "properties": { + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + }, + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "UNETLoader" + }, + "widgets_values": [ + "minimax-h3/minimax_h3_fl2va_pruned_int8_convrot.safetensors", + "default" + ], + "widgets_values_named": { + "unet_name": "minimax-h3/minimax_h3_fl2va_pruned_int8_convrot.safetensors", + "weight_dtype": "default" + } + }, + { + "id": 52, + "type": "CLIPLoader", + "pos": [ + 0, + 232 + ], + "size": [ + 440, + 106 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": null + }, + { + "localized_name": "type", + "name": "type", + "type": "COMBO", + "widget": { + "name": "type" + }, + "link": null + }, + { + "localized_name": "device", + "name": "device", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "device" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [ + 4 + ] + } + ], + "properties": { + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + }, + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "CLIPLoader" + }, + "widgets_values": [ + "minimax-h3/qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors", + "minimax", + "default" + ], + "widgets_values_named": { + "clip_name": "minimax-h3/qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors", + "type": "minimax", + "device": "default" + } + }, + { + "id": 53, + "type": "VAELoader", + "pos": [ + 0, + 388 + ], + "size": [ + 440, + 58 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 5, + 16 + ] + } + ], + "properties": { + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + }, + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "VAELoader" + }, + "widgets_values": [ + "minimax_h3_video_vae_fp16.safetensors" + ], + "widgets_values_named": { + "vae_name": "minimax_h3_video_vae_fp16.safetensors" + } + }, + { + "id": 54, + "type": "VAELoader", + "pos": [ + 0, + 496 + ], + "size": [ + 440, + 58 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 18 + ] + } + ], + "properties": { + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + }, + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "VAELoader" + }, + "widgets_values": [ + "minimax_h3_audio_vae_fp32.safetensors" + ], + "widgets_values_named": { + "vae_name": "minimax_h3_audio_vae_fp32.safetensors" + } + }, + { + "id": 55, + "type": "AUSBOSS_NODES_LoraLoader", + "pos": [ + 0, + 604 + ], + "size": [ + 440, + 188 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 1 + }, + { + "localized_name": "clip", + "name": "clip", + "shape": 7, + "type": "CLIP", + "link": null + }, + { + "localized_name": "loras", + "name": "loras", + "type": "STRING", + "widget": { + "name": "loras" + }, + "link": null + }, + { + "localized_name": "trigger_separator", + "name": "trigger_separator", + "shape": 7, + "type": "STRING", + "widget": { + "name": "trigger_separator" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "links": [ + 2 + ] + }, + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "links": null + }, + { + "localized_name": "triggers", + "name": "triggers", + "type": "STRING", + "links": null + } + ], + "properties": { + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + }, + "cnr_id": "ausboss-nodes", + "ver": "2.0.0", + "Node name for S&R": "AUSBOSS_NODES_LoraLoader", + "ausbossLoraLinked": true + }, + "widgets_values": [ + "[{\"name\":\"minimaxh3/minimax_h3_fl2v_turbo_4step_v1.0_768p_comfyui_bf16.safetensors\",\"strength\":1,\"enabled\":true,\"triggers\":\"\"}]", + ", ", + "" + ], + "widgets_values_named": { + "loras": "[{\"name\":\"minimaxh3/minimax_h3_fl2v_turbo_4step_v1.0_768p_comfyui_bf16.safetensors\",\"strength\":1,\"enabled\":true,\"triggers\":\"\"}]", + "trigger_separator": ", ", + "ausboss_lora_rows": "" + }, + "color": "#14424d", + "bgcolor": "#161f21" + }, + { + "id": 56, + "type": "MiniMaxH3SigmaShift", + "pos": [ + 0, + 842 + ], + "size": [ + 440, + 82 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 2 + }, + { + "localized_name": "shift_video", + "name": "shift_video", + "type": "FLOAT", + "widget": { + "name": "shift_video" + }, + "link": null + }, + { + "localized_name": "shift_audio", + "name": "shift_audio", + "type": "FLOAT", + "widget": { + "name": "shift_audio" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 11 + ] + } + ], + "properties": { + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + }, + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "MiniMaxH3SigmaShift" + }, + "widgets_values": [ + 6, + 3 + ], + "widgets_values_named": { + "shift_video": 6, + "shift_audio": 3 + } + }, + { + "id": 57, + "type": "AUSBOSS_NODES_Resolution", + "pos": [ + 520, + 100 + ], + "size": [ + 340, + 531 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "link": null + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "link": null + }, + { + "localized_name": "latent", + "name": "latent", + "shape": 7, + "type": "COMBO", + "link": null + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "shape": 7, + "type": "INT", + "link": null + } + ], + "outputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "links": [ + 6 + ] + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "links": [ + 7 + ] + }, + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "links": null + } + ], + "properties": { + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + }, + "cnr_id": "ausboss-nodes", + "ver": "2.0.0", + "Node name for S&R": "AUSBOSS_NODES_Resolution", + "ausbossResolutionPortrait": false + }, + "widgets_values": [ + 1344, + 768, + "16ch", + 1, + "" + ], + "widgets_values_named": { + "width": 1344, + "height": 768, + "latent": "16ch", + "batch_size": 1, + "ausboss_resolution_panel": "" + }, + "color": "#14424d", + "bgcolor": "#161f21" + }, + { + "id": 58, + "type": "AUSBOSS_NODES_Float", + "pos": [ + 520, + 681 + ], + "size": [ + 440, + 94 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "FLOAT", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "float", + "name": "float", + "type": "FLOAT", + "links": [ + 3 + ] + } + ], + "properties": { + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + }, + "cnr_id": "ausboss-nodes", + "ver": "2.0.0", + "Node name for S&R": "AUSBOSS_NODES_Float" + }, + "widgets_values": [ + 5, + "" + ], + "widgets_values_named": { + "value": 5, + "ausboss_widget_card": "" + }, + "color": "#14424d", + "bgcolor": "#161f21" + }, + { + "id": 59, + "type": "AUSBOSS_NODES_MathExpression", + "pos": [ + 520, + 825 + ], + "size": [ + 440, + 134 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "localized_name": "a", + "name": "a", + "shape": 7, + "type": "FLOAT,INT", + "link": 3 + }, + { + "localized_name": "b", + "name": "b", + "shape": 7, + "type": "FLOAT,INT", + "link": null + }, + { + "localized_name": "c", + "name": "c", + "shape": 7, + "type": "FLOAT,INT", + "link": null + }, + { + "localized_name": "expression", + "name": "expression", + "type": "STRING", + "widget": { + "name": "expression" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "float", + "name": "float", + "type": "FLOAT", + "links": null + }, + { + "localized_name": "int", + "name": "int", + "type": "INT", + "links": [ + 8 + ] + } + ], + "properties": { + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + }, + "cnr_id": "ausboss-nodes", + "ver": "2.0.0", + "Node name for S&R": "AUSBOSS_NODES_MathExpression" + }, + "widgets_values": [ + "max(5, ceil((a * 24 - 5) / 17) * 17 + 5)", + "" + ], + "widgets_values_named": { + "expression": "max(5, ceil((a * 24 - 5) / 17) * 17 + 5)", + "ausboss_widget_card": "" + }, + "color": "#14424d", + "bgcolor": "#161f21" + }, + { + "id": 60, + "type": "AUSBOSS_NODES_Text", + "pos": [ + 1040, + 100 + ], + "size": [ + 460, + 340 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "links": [ + 9 + ] + } + ], + "properties": { + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + }, + "cnr_id": "ausboss-nodes", + "ver": "2.0.0", + "Node name for S&R": "AUSBOSS_NODES_Text" + }, + "widgets_values": [ + "A wide cinematic view of an alpine lake at sunrise. A wooden pier leads into the still water, and a small rowboat rocks gently beside it. Mist drifts toward the distant forested mountains. The camera slowly moves forward. Audio: gentle water against the posts, distant birds." + ], + "widgets_values_named": { + "text": "A wide cinematic view of an alpine lake at sunrise. A wooden pier leads into the still water, and a small rowboat rocks gently beside it. Mist drifts toward the distant forested mountains. The camera slowly moves forward. Audio: gentle water against the posts, distant birds." + }, + "color": "#14424d", + "bgcolor": "#161f21" + }, + { + "id": 61, + "type": "MiniMaxH3ImageToVideo", + "pos": [ + 1040, + 490 + ], + "size": [ + 460, + 260 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 4 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 5 + }, + { + "localized_name": "first_frame", + "name": "first_frame", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "localized_name": "last_frame", + "name": "last_frame", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "localized_name": "prompt", + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": 9 + }, + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 6 + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 7 + }, + { + "localized_name": "length", + "name": "length", + "type": "INT", + "widget": { + "name": "length" + }, + "link": 8 + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "links": [ + 10, + 12 + ] + }, + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 14 + ] + } + ], + "properties": { + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + }, + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "MiniMaxH3ImageToVideo" + }, + "widgets_values": [ + "", + 1344, + 768, + 124 + ], + "widgets_values_named": { + "prompt": "", + "width": 1344, + "height": 768, + "length": 124 + } + }, + { + "id": 62, + "type": "ConditioningZeroOut", + "pos": [ + 1040, + 800 + ], + "size": [ + 460, + 26 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "localized_name": "conditioning", + "name": "conditioning", + "type": "CONDITIONING", + "link": 10 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 13 + ] + } + ], + "properties": { + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + }, + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "ConditioningZeroOut" + } + }, + { + "id": 63, + "type": "KSampler", + "pos": [ + 1580, + 366 + ], + "size": [ + 440, + 262 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 11 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 12 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 13 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 14 + }, + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 21 + }, + { + "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 + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 15, + 17 + ] + } + ], + "properties": { + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + }, + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 20260905, + "fixed", + 4, + 1, + "res_multistep", + "simple", + 1 + ], + "widgets_values_named": { + "seed": 20260905, + "control_after_generate": "randomize", + "steps": 4, + "cfg": 1, + "sampler_name": "res_multistep", + "scheduler": "simple", + "denoise": 1 + } + }, + { + "id": 64, + "type": "VAEDecode", + "pos": [ + 1580, + 678 + ], + "size": [ + 440, + 46 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 15 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 16 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 19 + ] + } + ], + "properties": { + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + }, + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "VAEDecode" + } + }, + { + "id": 65, + "type": "VAEDecodeAudio", + "pos": [ + 1580, + 774 + ], + "size": [ + 440, + 46 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 17 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 18 + } + ], + "outputs": [ + { + "localized_name": "AUDIO", + "name": "AUDIO", + "type": "AUDIO", + "links": [ + 20 + ] + } + ], + "properties": { + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + }, + "cnr_id": "comfy-core", + "ver": "0.34.0", + "Node name for S&R": "VAEDecodeAudio" + } + }, + { + "id": 66, + "type": "AUSBOSS_NODES_SaveVideo", + "pos": [ + 2100, + 100 + ], + "size": [ + 620, + 700 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "localized_name": "frames", + "name": "frames", + "shape": 7, + "type": "IMAGE", + "link": 19 + }, + { + "localized_name": "audio", + "name": "audio", + "shape": 7, + "type": "AUDIO", + "link": 20 + }, + { + "localized_name": "video", + "name": "video", + "shape": 7, + "type": "VIDEO", + "link": null + }, + { + "localized_name": "fps", + "name": "fps", + "type": "FLOAT", + "link": null + }, + { + "localized_name": "filename_prefix", + "name": "filename_prefix", + "type": "STRING", + "widget": { + "name": "filename_prefix" + }, + "link": null + }, + { + "localized_name": "crf", + "name": "crf", + "type": "INT", + "link": null + }, + { + "localized_name": "format", + "name": "format", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "format" + }, + "link": null + }, + { + "localized_name": "pingpong", + "name": "pingpong", + "shape": 7, + "type": "BOOLEAN", + "widget": { + "name": "pingpong" + }, + "link": null + }, + { + "localized_name": "save_metadata", + "name": "save_metadata", + "shape": 7, + "type": "BOOLEAN", + "widget": { + "name": "save_metadata" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "file_path", + "name": "file_path", + "type": "STRING", + "links": null + } + ], + "properties": { + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + }, + "cnr_id": "ausboss-nodes", + "ver": "2.0.0", + "Node name for S&R": "AUSBOSS_NODES_SaveVideo", + "ausboss_save_video_loop": true + }, + "widgets_values": { + "fps": 24, + "filename_prefix": "AusBoss/minimax_h3_text", + "crf": 17, + "format": "mp4 h264", + "pingpong": false, + "save_metadata": true + }, + "widgets_values_named": { + "fps": 24, + "filename_prefix": "AusBoss/minimax_h3_text", + "crf": 17, + "format": "mp4 h264", + "pingpong": false, + "save_metadata": true, + "ausboss_widget_card": "", + "ausboss_save_video_viewer": "" + }, + "color": "#14424d", + "bgcolor": "#161f21" + }, + { + "id": 67, + "type": "AUSBOSS_NODES_WorkflowNote", + "pos": [ + -620, + 100 + ], + "size": [ + 560, + 1100 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "note", + "name": "note", + "type": "STRING", + "widget": { + "name": "note" + }, + "link": null + } + ], + "outputs": [], + "properties": { + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + }, + "cnr_id": "ausboss-nodes", + "ver": "2.0.0", + "Node name for S&R": "AUSBOSS_NODES_WorkflowNote" + }, + "widgets_values": [ + "{\"v\": 1, \"title\": \"MiniMax H3 Text to Video\", \"subtitle\": \"One shot with native audio.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"## Setup\\nCopy the supplied media from `example_workflows/inputs/` into `ComfyUI/input/`. Choose each installed model in its loader; saved selections may include local subfolders. Download links below name the standard destination folders.\\n\\n## Start here\\nChoose the four installed models and the 4-step turbo LoRA, then edit the prompt. Resolution Master supplies dimensions; this example uses 1344 × 768. The frame calculation rounds up to the 17k+5 grid: five seconds becomes 124 frames at 24 fps. Keep the frame rate at 24 for H3.\\n\\nBoth frame inputs are intentionally empty for text-to-video.\\n\\nThe turbo LoRA and four sampling steps belong together. For the base model, remove the turbo LoRA and follow its current sampling recommendations. Higher resolutions need more memory.\\n\\n[Native H3 workflow documentation](https://docs.comfy.org/tutorials/video/minimax/minimax-h3)\", \"models\": [{\"name\": \"minimax_h3_fl2va_pruned_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"21 GB\", \"url\": \"https://huggingface.co/Comfy-Org/MiniMax-H3/resolve/main/diffusion_models/minimax_h3_fl2va_pruned_int8_convrot.safetensors\", \"note\": \"fp8_scaled (21 GB) and nvfp4 builds of the pruned fl2va model also work\"}, {\"name\": \"qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors\", \"dir\": \"text_encoders\", \"size\": \"15.7 GB\", \"url\": \"https://huggingface.co/Comfy-Org/MiniMax-H3/resolve/main/text_encoders/qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors\", \"note\": \"does not need a Blackwell card\"}, {\"name\": \"minimax_h3_video_vae_fp16.safetensors\", \"dir\": \"vae\", \"size\": \"5.2 GB\", \"url\": \"https://huggingface.co/Comfy-Org/MiniMax-H3/resolve/main/vae/minimax_h3_video_vae_fp16.safetensors\", \"note\": \"\"}, {\"name\": \"minimax_h3_audio_vae_fp32.safetensors\", \"dir\": \"vae\", \"size\": \"605 MB\", \"url\": \"https://huggingface.co/Comfy-Org/MiniMax-H3/resolve/main/vae/minimax_h3_audio_vae_fp32.safetensors\", \"note\": \"\"}, {\"name\": \"minimax_h3_fl2v_turbo_4step_v1.0_768p_comfyui_bf16.safetensors\", \"dir\": \"loras\", \"size\": \"2.0 GB\", \"url\": \"https://huggingface.co/Comfy-Org/MiniMax-H3/resolve/main/loras/minimax_h3_fl2v_turbo_4step_v1.0_768p_comfyui_bf16.safetensors\", \"note\": \"LightX2V turbo LoRA; the 8-step build from the same folder trades speed for a little quality\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_MathExpression\"}]}" + ], + "widgets_values_named": { + "note": "{\"v\":1,\"title\":\"MiniMax H3 Text to Video\",\"subtitle\":\"One shot with native audio.\",\"author\":\"AusBoss\",\"layout\":\"card\",\"accent\":\"\",\"body\":\"## Start here\\nChoose the four installed models and the 4-step turbo LoRA, then edit the prompt. Resolution Master supplies dimensions; this example uses 1344 × 768. The frame calculation rounds up to the 17k+5 grid: five seconds becomes 124 frames at 24 fps. Keep the frame rate at 24 for H3.\\n\\nBoth frame inputs are intentionally empty for text-to-video.\\n\\nThe turbo LoRA and four sampling steps belong together. For the base model, remove the turbo LoRA and follow its current sampling recommendations. Higher resolutions need more memory.\\n\\n[Native H3 workflow documentation](https://docs.comfy.org/tutorials/video/minimax/minimax-h3)\"}", + "ausboss_workflow_note_panel": "" + }, + "color": "#14424d", + "bgcolor": "#161f21" + }, + { + "id": 70, + "type": "AUSBOSS_NODES_Seed", + "pos": [ + 1580, + 100 + ], + "size": [ + 440, + 216 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "seed", + "type": "INT", + "links": [ + 21 + ] + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_Seed", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + 20260905, + "fixed" + ] + }, + { + "id": 71, + "type": "AUSBOSS_NODES_RunTimer", + "pos": [ + 1580, + 870 + ], + "size": [ + 196, + 62 + ], + "flags": {}, + "order": 19, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_RunTimer", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [] + } + ], + "links": [ + [ + 1, + 51, + 0, + 55, + 0, + "MODEL" + ], + [ + 2, + 55, + 0, + 56, + 0, + "MODEL" + ], + [ + 3, + 58, + 0, + 59, + 0, + "FLOAT" + ], + [ + 4, + 52, + 0, + 61, + 0, + "CLIP" + ], + [ + 5, + 53, + 0, + 61, + 1, + "VAE" + ], + [ + 6, + 57, + 0, + 61, + 5, + "INT" + ], + [ + 7, + 57, + 1, + 61, + 6, + "INT" + ], + [ + 8, + 59, + 1, + 61, + 7, + "INT" + ], + [ + 9, + 60, + 0, + 61, + 4, + "STRING" + ], + [ + 10, + 61, + 0, + 62, + 0, + "CONDITIONING" + ], + [ + 11, + 56, + 0, + 63, + 0, + "MODEL" + ], + [ + 12, + 61, + 0, + 63, + 1, + "CONDITIONING" + ], + [ + 13, + 62, + 0, + 63, + 2, + "CONDITIONING" + ], + [ + 14, + 61, + 1, + 63, + 3, + "LATENT" + ], + [ + 15, + 63, + 0, + 64, + 0, + "LATENT" + ], + [ + 16, + 53, + 0, + 64, + 1, + "VAE" + ], + [ + 17, + 63, + 0, + 65, + 0, + "LATENT" + ], + [ + 18, + 54, + 0, + 65, + 1, + "VAE" + ], + [ + 19, + 64, + 0, + 66, + 0, + "IMAGE" + ], + [ + 20, + 65, + 0, + 66, + 1, + "AUDIO" + ], + [ + 21, + 70, + 0, + 63, + 4, + "INT" + ] + ], + "groups": [ + { + "title": "1 · Models + turbo LoRA", + "bounding": [ + -20, + 40, + 480, + 904 + ], + "color": "#235d64", + "font_size": 24, + "flags": {} + }, + { + "title": "2 · Canvas + duration", + "bounding": [ + 500, + 40, + 480, + 939 + ], + "color": "#366451", + "font_size": 24, + "flags": {} + }, + { + "title": "3 · Shot + conditioning", + "bounding": [ + 1020, + 40, + 500, + 806 + ], + "color": "#5d527c", + "font_size": 24, + "flags": {} + }, + { + "title": "4 · Seed, sample + decode", + "bounding": [ + 1560, + 40, + 480, + 912 + ], + "color": "#795b38", + "font_size": 24, + "flags": {} + }, + { + "title": "5 · Video + audio output", + "bounding": [ + 2080, + 40, + 660, + 780 + ], + "color": "#345d7c", + "font_size": 24, + "flags": {} + } + ], + "config": {}, + "extra": { + "ds": { + "scale": 0.4737, + "offset": [ + 828.88, + 328.88 + ] + } + }, + "version": 0.4 +} diff --git a/example_workflows/Resolution Master (AusBoss).jpg b/example_workflows/Resolution Master (AusBoss).jpg new file mode 100644 index 0000000..48f2954 Binary files /dev/null and b/example_workflows/Resolution Master (AusBoss).jpg differ diff --git a/example_workflows/Resolution Master (AusBoss).json b/example_workflows/Resolution Master (AusBoss).json new file mode 100644 index 0000000..e412dc9 --- /dev/null +++ b/example_workflows/Resolution Master (AusBoss).json @@ -0,0 +1,346 @@ +{ + "id": "00000000-0000-0000-0000-000000000000", + "revision": 0, + "last_node_id": 54, + "last_link_id": 3, + "nodes": [ + { + "id": 51, + "type": "AUSBOSS_NODES_Resolution", + "pos": [ + 0, + 100 + ], + "size": [ + 340, + 531 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "link": null + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "link": null + }, + { + "localized_name": "latent", + "name": "latent", + "shape": 7, + "type": "COMBO", + "link": null + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "shape": 7, + "type": "INT", + "link": null + } + ], + "outputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "links": [ + 1 + ] + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "links": [ + 2 + ] + }, + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "links": null + } + ], + "properties": { + "cnr_id": "ausboss-nodes", + "ver": "2.0.0", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + }, + "Node name for S&R": "AUSBOSS_NODES_Resolution", + "ausbossResolutionPortrait": false + }, + "widgets_values": [ + 1024, + 1024, + "16ch", + 1, + "" + ], + "widgets_values_named": { + "width": 1024, + "height": 1024, + "latent": "16ch", + "batch_size": 1, + "ausboss_resolution_panel": "" + }, + "color": "#14424d", + "bgcolor": "#161f21" + }, + { + "id": 52, + "type": "EmptyImage", + "pos": [ + 440, + 100 + ], + "size": [ + 300, + 170 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 1 + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 2 + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + }, + { + "localized_name": "color", + "name": "color", + "type": "INT", + "widget": { + "name": "color" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 3 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + }, + "Node name for S&R": "EmptyImage" + }, + "widgets_values": [ + 512, + 512, + 1, + 1458762 + ], + "widgets_values_named": { + "width": 512, + "height": 512, + "batch_size": 1, + "color": 1458762 + } + }, + { + "id": 53, + "type": "PreviewImage", + "pos": [ + 840, + 100 + ], + "size": [ + 460, + 540 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "link": 3 + } + ], + "outputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "links": null + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.34.0", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + }, + "Node name for S&R": "PreviewImage" + } + }, + { + "id": 54, + "type": "AUSBOSS_NODES_WorkflowNote", + "pos": [ + -620, + 100 + ], + "size": [ + 560, + 510 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "note", + "name": "note", + "type": "STRING", + "widget": { + "name": "note" + }, + "link": null + } + ], + "outputs": [], + "properties": { + "cnr_id": "ausboss-nodes", + "ver": "2.0.0", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + }, + "Node name for S&R": "AUSBOSS_NODES_WorkflowNote" + }, + "widgets_values": [ + "{\"v\": 1, \"title\": \"Resolution Master\", \"subtitle\": \"A small canvas test with no models to download.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"Choose portrait or landscape, then a ratio. Scrub the MP field to make a smaller or larger canvas. Queue to preview that size as a solid color.\\n\\nFor image generation, wire the latent output into your sampler instead. Choose the correct latent layout in the gear. For video generation, use the width and height outputs, as in the MiniMax H3 examples.\\n\\nTyped dimensions remain exact; latent dimensions round down to complete cells. Use multiples of the chosen latent layout’s downsample factor when sampling directly.\"}" + ], + "widgets_values_named": { + "note": "{\"v\":1,\"title\":\"Resolution Master\",\"subtitle\":\"A small canvas test with no models to download.\",\"author\":\"AusBoss\",\"layout\":\"card\",\"accent\":\"\",\"body\":\"Choose portrait or landscape, then a ratio. Scrub the MP field to make a smaller or larger canvas. Queue to preview that size as a solid color.\\n\\nFor image generation, wire the latent output into your sampler instead. Choose the correct latent layout in the gear. For video generation, use the width and height outputs, as in the MiniMax H3 examples.\\n\\nTyped dimensions remain exact; latent dimensions round down to complete cells. Use multiples of the chosen latent layout’s downsample factor when sampling directly.\"}", + "ausboss_workflow_note_panel": "" + }, + "color": "#14424d", + "bgcolor": "#161f21" + } + ], + "links": [ + [ + 1, + 51, + 0, + 52, + 0, + "INT" + ], + [ + 2, + 51, + 1, + 52, + 1, + "INT" + ], + [ + 3, + 52, + 0, + 53, + 0, + "IMAGE" + ] + ], + "groups": [ + { + "title": "1 · Choose the canvas", + "bounding": [ + -20, + 40, + 380, + 611 + ], + "color": "#235d64", + "font_size": 24, + "flags": {} + }, + { + "title": "2 · Build a preview", + "bounding": [ + 420, + 40, + 340, + 250 + ], + "color": "#366451", + "font_size": 24, + "flags": {} + }, + { + "title": "3 · Check the result", + "bounding": [ + 820, + 40, + 500, + 620 + ], + "color": "#5d527c", + "font_size": 24, + "flags": {} + } + ], + "config": {}, + "extra": { + "ue_links": [], + "ds": { + "scale": 0.81, + "offset": [ + 758.77, + 179.88 + ] + } + }, + "version": 0.4 +} diff --git a/example_workflows/ausboss_node_tour.jpg b/example_workflows/ausboss_node_tour.jpg new file mode 100644 index 0000000..c2d24c9 Binary files /dev/null and b/example_workflows/ausboss_node_tour.jpg differ diff --git a/example_workflows/ausboss_node_tour.json b/example_workflows/ausboss_node_tour.json index 568ccfc..6d9f9d7 100644 --- a/example_workflows/ausboss_node_tour.json +++ b/example_workflows/ausboss_node_tour.json @@ -1,1253 +1,1325 @@ { - "last_node_id": 20, - "last_link_id": 26, - "nodes": [ - { - "id": 1, - "type": "AUSBOSS_NODES_LoadVideo", - "pos": [ - 40, - 200 - ], - "size": [ - 380, - 620 - ], - "flags": {}, - "order": 0, - "mode": 0, - "inputs": [], - "outputs": [ + "last_node_id": 20, + "last_link_id": 26, + "nodes": [ { - "name": "frames", - "type": "IMAGE", - "links": [ - 1 - ], - "slot_index": 0 + "id": 1, + "type": "AUSBOSS_NODES_LoadVideo", + "pos": [ + 0, + 100 + ], + "size": [ + 380, + 600 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "frames", + "type": "IMAGE", + "links": [ + 1 + ], + "slot_index": 0 + }, + { + "name": "audio", + "type": "AUDIO", + "links": [ + 18 + ], + "slot_index": 1 + }, + { + "name": "frame_count", + "type": "INT", + "links": [], + "slot_index": 2 + }, + { + "name": "fps", + "type": "FLOAT", + "links": [ + 11, + 13 + ], + "slot_index": 3 + }, + { + "name": "width", + "type": "INT", + "links": [], + "slot_index": 4 + }, + { + "name": "height", + "type": "INT", + "links": [], + "slot_index": 5 + }, + { + "name": "duration", + "type": "FLOAT", + "links": [], + "slot_index": 6 + }, + { + "name": "video", + "type": "VIDEO", + "links": [], + "slot_index": 7 + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_LoadVideo", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "ausboss_pier_9x16.mp4", + 0, + 3, + 0, + 0, + 2, + 72, + false + ] }, { - "name": "audio", - "type": "AUDIO", - "links": [ - 18 - ], - "slot_index": 1 + "id": 2, + "type": "AUSBOSS_NODES_SelectEveryNth", + "pos": [ + 0, + 750 + ], + "size": [ + 380, + 125 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 1 + } + ], + "outputs": [ + { + "name": "images", + "type": "IMAGE", + "links": [ + 2 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_SelectEveryNth", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + 1, + 0 + ] }, { - "name": "frame_count", - "type": "INT", - "links": [], - "slot_index": 2 + "id": 3, + "type": "AUSBOSS_NODES_SplitBatch", + "pos": [ + 0, + 925 + ], + "size": [ + 380, + 114 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 2 + } + ], + "outputs": [ + { + "name": "a", + "type": "IMAGE", + "links": [ + 3 + ], + "slot_index": 0 + }, + { + "name": "b", + "type": "IMAGE", + "links": [ + 4 + ], + "slot_index": 1 + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_SplitBatch", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + 8 + ] }, { - "name": "fps", - "type": "FLOAT", - "links": [ - 11, - 13 - ], - "slot_index": 3 + "id": 4, + "type": "AUSBOSS_NODES_MergeBatches", + "pos": [ + 0, + 1089 + ], + "size": [ + 380, + 114 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "a", + "type": "IMAGE", + "link": 3 + }, + { + "name": "b", + "type": "IMAGE", + "link": 4 + } + ], + "outputs": [ + { + "name": "images", + "type": "IMAGE", + "links": [ + 5 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_MergeBatches", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "resize to a" + ] }, { - "name": "width", - "type": "INT", - "links": [], - "slot_index": 4 + "id": 5, + "type": "AUSBOSS_NODES_ImageResize", + "pos": [ + 460, + 100 + ], + "size": [ + 360, + 340 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 5 + }, + { + "name": "mask", + "type": "MASK", + "link": null + } + ], + "outputs": [ + { + "name": "image", + "type": "IMAGE", + "links": [ + 6, + 7 + ], + "slot_index": 0 + }, + { + "name": "mask", + "type": "MASK", + "links": [ + 8 + ], + "slot_index": 1 + }, + { + "name": "width", + "type": "INT", + "links": [], + "slot_index": 2 + }, + { + "name": "height", + "type": "INT", + "links": [], + "slot_index": 3 + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_ImageResize", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "width+height", + 512, + 512, + 512, + 1.0, + 0.5, + "pad", + "#14424d", + 16, + "lanczos" + ] }, { - "name": "height", - "type": "INT", - "links": [], - "slot_index": 5 + "id": 6, + "type": "AUSBOSS_NODES_RefineMask", + "pos": [ + 460, + 490 + ], + "size": [ + 360, + 430 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "mask", + "type": "MASK", + "link": 8 + }, + { + "name": "guide_image", + "type": "IMAGE", + "link": null + } + ], + "outputs": [ + { + "name": "mask", + "type": "MASK", + "links": [ + 9 + ], + "slot_index": 0 + }, + { + "name": "mask_inverted", + "type": "MASK", + "links": [], + "slot_index": 1 + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_RefineMask", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + 0, + 4.0, + false, + 0, + 0.0, + 1.0, + "off" + ] }, { - "name": "duration", - "type": "FLOAT", - "links": [], - "slot_index": 6 + "id": 7, + "type": "AUSBOSS_NODES_Float", + "pos": [ + 460, + 970 + ], + "size": [ + 360, + 94 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "float", + "type": "FLOAT", + "links": [ + 10 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_Float", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + 1.0 + ] }, { - "name": "video", - "type": "VIDEO", - "links": [], - "slot_index": 7 + "id": 8, + "type": "AUSBOSS_NODES_ColorMatch", + "pos": [ + 900, + 100 + ], + "size": [ + 340, + 240 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 6 + }, + { + "name": "reference", + "type": "IMAGE", + "link": null + }, + { + "name": "mask", + "type": "MASK", + "link": 9 + }, + { + "name": "strength", + "type": "FLOAT", + "link": 10, + "widget": { + "name": "strength" + } + } + ], + "outputs": [ + { + "name": "image", + "type": "IMAGE", + "links": [ + 12, + 23, + 24 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_ColorMatch", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + 1.0, + "lab", + true, + "first_frame" + ] + }, + { + "id": 9, + "type": "AUSBOSS_NODES_ImageSize", + "pos": [ + 460, + 1114 + ], + "size": [ + 360, + 130 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 7 + } + ], + "outputs": [ + { + "name": "width", + "type": "INT", + "links": [], + "slot_index": 0 + }, + { + "name": "height", + "type": "INT", + "links": [], + "slot_index": 1 + }, + { + "name": "longest_edge", + "type": "INT", + "links": [], + "slot_index": 2 + }, + { + "name": "shortest_edge", + "type": "INT", + "links": [], + "slot_index": 3 + }, + { + "name": "count", + "type": "INT", + "links": [ + 15 + ], + "slot_index": 4 + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_ImageSize", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [] + }, + { + "id": 10, + "type": "AUSBOSS_NODES_MathExpression", + "pos": [ + 900, + 390 + ], + "size": [ + 340, + 150 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "name": "a", + "type": "FLOAT", + "link": 11, + "widget": { + "name": "a" + } + }, + { + "name": "b", + "type": "FLOAT", + "link": null, + "widget": { + "name": "b" + } + }, + { + "name": "c", + "type": "FLOAT", + "link": null, + "widget": { + "name": "c" + } + } + ], + "outputs": [ + { + "name": "float", + "type": "FLOAT", + "links": [ + 14 + ], + "slot_index": 0 + }, + { + "name": "int", + "type": "INT", + "links": [], + "slot_index": 1 + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_MathExpression", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "a * 2", + 0.0, + 0.0, + 0.0 + ] + }, + { + "id": 11, + "type": "AUSBOSS_NODES_FrameInterpolate", + "pos": [ + 900, + 590 + ], + "size": [ + 340, + 240 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "name": "frames", + "type": "IMAGE", + "link": 12 + }, + { + "name": "source_fps", + "type": "FLOAT", + "link": 13, + "widget": { + "name": "source_fps" + } + }, + { + "name": "target_fps", + "type": "FLOAT", + "link": 14, + "widget": { + "name": "target_fps" + } + }, + { + "name": "batch_size", + "type": "INT", + "link": 15, + "widget": { + "name": "batch_size" + } + } + ], + "outputs": [ + { + "name": "frames", + "type": "IMAGE", + "links": [ + 16 + ], + "slot_index": 0 + }, + { + "name": "fps", + "type": "FLOAT", + "links": [ + 19 + ], + "slot_index": 1 + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_FrameInterpolate", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + 24.0, + 30.0, + "blend", + 0.35, + 8 + ] + }, + { + "id": 12, + "type": "AUSBOSS_NODES_FreeMemory", + "pos": [ + 900, + 880 + ], + "size": [ + 340, + 60 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "name": "value", + "type": "*", + "link": 16 + } + ], + "outputs": [ + { + "name": "value", + "type": "*", + "links": [ + 17 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_FreeMemory", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [] + }, + { + "id": 13, + "type": "AUSBOSS_NODES_Text", + "pos": [ + 1320, + 100 + ], + "size": [ + 500, + 180 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "text", + "type": "STRING", + "links": [ + 20 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_Text", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + "AusBoss/tour" + ] + }, + { + "id": 14, + "type": "AUSBOSS_NODES_Integer", + "pos": [ + 1320, + 330 + ], + "size": [ + 500, + 94 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "int", + "type": "INT", + "links": [ + 21 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_Integer", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + 19 + ] + }, + { + "id": 15, + "type": "AUSBOSS_NODES_SaveVideo", + "pos": [ + 1320, + 474 + ], + "size": [ + 500, + 620 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "name": "frames", + "type": "IMAGE", + "link": 17 + }, + { + "name": "audio", + "type": "AUDIO", + "link": 18 + }, + { + "name": "video", + "type": "VIDEO", + "link": null + }, + { + "name": "fps", + "type": "FLOAT", + "link": 19, + "widget": { + "name": "fps" + } + }, + { + "name": "filename_prefix", + "type": "STRING", + "link": 20, + "widget": { + "name": "filename_prefix" + } + }, + { + "name": "crf", + "type": "INT", + "link": 21, + "widget": { + "name": "crf" + } + } + ], + "outputs": [ + { + "name": "file_path", + "type": "STRING", + "links": [ + 22 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_SaveVideo", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + 16.0, + "AusBoss/video", + 19, + "mp4 h264", + false, + true + ] + }, + { + "id": 16, + "type": "AUSBOSS_NODES_ShowText", + "pos": [ + 1320, + 1144 + ], + "size": [ + 500, + 180 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [ + { + "name": "text", + "type": "STRING", + "link": 22 + } + ], + "outputs": [ + { + "name": "text", + "type": "STRING", + "links": [], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_ShowText", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [] + }, + { + "id": 17, + "type": "AUSBOSS_NODES_SelectFrame", + "pos": [ + 1900, + 100 + ], + "size": [ + 340, + 340 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "name": "frames", + "type": "IMAGE", + "link": 23 + } + ], + "outputs": [ + { + "name": "image", + "type": "IMAGE", + "links": [ + 25 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_SelectFrame", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + 1 + ] + }, + { + "id": 18, + "type": "AUSBOSS_NODES_SelectFrame", + "pos": [ + 1900, + 490 + ], + "size": [ + 340, + 340 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [ + { + "name": "frames", + "type": "IMAGE", + "link": 24 + } + ], + "outputs": [ + { + "name": "image", + "type": "IMAGE", + "links": [ + 26 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_SelectFrame", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [ + -1 + ] + }, + { + "id": 19, + "type": "AUSBOSS_NODES_Compare", + "pos": [ + 2320, + 100 + ], + "size": [ + 500, + 620 + ], + "flags": {}, + "order": 18, + "mode": 0, + "inputs": [ + { + "name": "image_a", + "type": "IMAGE", + "link": 25 + }, + { + "name": "image_b", + "type": "IMAGE", + "link": 26 + } + ], + "outputs": [ + { + "name": "image_a", + "type": "IMAGE", + "links": [], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "AUSBOSS_NODES_Compare", + "cnr_id": "ausboss-nodes", + "ver": "2.0.0" + }, + "widgets_values": [] + }, + { + "id": 20, + "type": "AUSBOSS_NODES_WorkflowNote", + "pos": [ + -620, + 100 + ], + "size": [ + 560, + 950 + ], + "flags": {}, + "order": 19, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": { + "cnr_id": "ausboss-nodes", + "Node name for S&R": "AUSBOSS_NODES_WorkflowNote", + "ver": "2.0.0" + }, + "widgets_values": [ + "{\"v\": 1, \"title\": \"AusBoss node tour\", \"subtitle\": \"\", \"author\": \"AusBoss\", \"layout\": \"card\", \"body\": \"## Setup\\nCopy inputs/ausboss_pier_9x16.mp4 into ComfyUI/input. No models or extra node packs are needed. Queue the graph to produce a three-second 512 × 512 video and compare the first and last frames.\\n\\n## Follow the groups\\n1. Load Video trims and thins the clip; Select Every Nth, Split Batch and Merge Batches show reusable batch operations.\\n2. Image Resize adds a square backdrop. Mask Refine and Color Match restrict the grade to the source region.\\n3. Frame Interpolate doubles the loaded rate (12 → 24 fps for this sample). Free Memory passes the frames onward while releasing caches.\\n4. Save Video preserves audio and writes the file. Show Text reports its path.\\n5. Select Frame uses 1 and -1 to preview the first and last frames; Compare lets you review them.\\n\\nSelect Every Nth changes frame count only; it does not adjust fps. The Load Video thinning control does adjust fps. Keep timing connected through Frame Interpolate as shown.\", \"models\": [], \"packs\": []}" + ] } - ], - "properties": { - "Node name for S&R": "AUSBOSS_NODES_LoadVideo" - }, - "widgets_values": [ - "choose_a_video.mp4", - 0.0, - 3.0, - 0, - 0, - 2, - 72, - false - ] - }, - { - "id": 2, - "type": "AUSBOSS_NODES_SelectEveryNth", - "pos": [ - 500, - 200 - ], - "size": [ - 250, - 102 - ], - "flags": {}, - "order": 1, - "mode": 0, - "inputs": [ - { - "name": "images", - "type": "IMAGE", - "link": 1 - } - ], - "outputs": [ - { - "name": "images", - "type": "IMAGE", - "links": [ - 2 - ], - "slot_index": 0 - } - ], - "properties": { - "Node name for S&R": "AUSBOSS_NODES_SelectEveryNth" - }, - "widgets_values": [ - 1, - 0 - ] - }, - { - "id": 3, - "type": "AUSBOSS_NODES_SplitBatch", - "pos": [ - 810, - 200 - ], - "size": [ - 250, - 78 - ], - "flags": {}, - "order": 2, - "mode": 0, - "inputs": [ - { - "name": "images", - "type": "IMAGE", - "link": 2 - } - ], - "outputs": [ - { - "name": "a", - "type": "IMAGE", - "links": [ - 3 - ], - "slot_index": 0 - }, - { - "name": "b", - "type": "IMAGE", - "links": [ - 4 - ], - "slot_index": 1 - } - ], - "properties": { - "Node name for S&R": "AUSBOSS_NODES_SplitBatch" - }, - "widgets_values": [ - 8 - ] - }, - { - "id": 4, - "type": "AUSBOSS_NODES_MergeBatches", - "pos": [ - 1120, - 200 - ], - "size": [ - 250, - 102 - ], - "flags": {}, - "order": 3, - "mode": 0, - "inputs": [ - { - "name": "a", - "type": "IMAGE", - "link": 3 - }, - { - "name": "b", - "type": "IMAGE", - "link": 4 - } - ], - "outputs": [ - { - "name": "images", - "type": "IMAGE", - "links": [ - 5 - ], - "slot_index": 0 - } - ], - "properties": { - "Node name for S&R": "AUSBOSS_NODES_MergeBatches" - }, - "widgets_values": [ - "resize to a" - ] - }, - { - "id": 5, - "type": "AUSBOSS_NODES_ImageResize", - "pos": [ - 1430, - 200 - ], - "size": [ - 290, - 320 - ], - "flags": {}, - "order": 4, - "mode": 0, - "inputs": [ - { - "name": "image", - "type": "IMAGE", - "link": 5 - }, - { - "name": "mask", - "type": "MASK", - "link": null - } - ], - "outputs": [ - { - "name": "image", - "type": "IMAGE", - "links": [ + ], + "links": [ + [ + 1, + 1, + 0, + 2, + 0, + "IMAGE" + ], + [ + 2, + 2, + 0, + 3, + 0, + "IMAGE" + ], + [ + 3, + 3, + 0, + 4, + 0, + "IMAGE" + ], + [ + 4, + 3, + 1, + 4, + 1, + "IMAGE" + ], + [ + 5, + 4, + 0, + 5, + 0, + "IMAGE" + ], + [ 6, - 7 - ], - "slot_index": 0 - }, - { - "name": "mask", - "type": "MASK", - "links": [ - 8 - ], - "slot_index": 1 - }, - { - "name": "width", - "type": "INT", - "links": [], - "slot_index": 2 - }, - { - "name": "height", - "type": "INT", - "links": [], - "slot_index": 3 - } - ], - "properties": { - "Node name for S&R": "AUSBOSS_NODES_ImageResize" - }, - "widgets_values": [ - "width+height", - 512, - 512, - 512, - 1.0, - 0.5, - "pad", - "#14424d", - 16, - "lanczos" - ] - }, - { - "id": 6, - "type": "AUSBOSS_NODES_RefineMask", - "pos": [ - 1430, - 620 - ], - "size": [ - 290, - 260 - ], - "flags": {}, - "order": 5, - "mode": 0, - "inputs": [ - { - "name": "mask", - "type": "MASK", - "link": 8 - }, - { - "name": "guide_image", - "type": "IMAGE", - "link": null - } - ], - "outputs": [ - { - "name": "mask", - "type": "MASK", - "links": [ - 9 - ], - "slot_index": 0 - }, - { - "name": "mask_inverted", - "type": "MASK", - "links": [], - "slot_index": 1 - } - ], - "properties": { - "Node name for S&R": "AUSBOSS_NODES_RefineMask" - }, - "widgets_values": [ - 0, - 4.0, - false, - 0, - 0.0, - 1.0, - "off" - ] - }, - { - "id": 7, - "type": "AUSBOSS_NODES_Float", - "pos": [ - 1500, - 950 - ], - "size": [ - 210, - 60 - ], - "flags": {}, - "order": 6, - "mode": 0, - "inputs": [], - "outputs": [ - { - "name": "float", - "type": "FLOAT", - "links": [ - 10 - ], - "slot_index": 0 - } - ], - "properties": { - "Node name for S&R": "AUSBOSS_NODES_Float" - }, - "widgets_values": [ - 1.0 - ] - }, - { - "id": 8, - "type": "AUSBOSS_NODES_ColorMatch", - "pos": [ - 1810, - 200 - ], - "size": [ - 290, - 230 - ], - "flags": {}, - "order": 7, - "mode": 0, - "inputs": [ - { - "name": "image", - "type": "IMAGE", - "link": 6 - }, - { - "name": "reference", - "type": "IMAGE", - "link": null - }, - { - "name": "mask", - "type": "MASK", - "link": 9 - }, - { - "name": "strength", - "type": "FLOAT", - "link": 10, - "widget": { - "name": "strength" - } - } - ], - "outputs": [ - { - "name": "image", - "type": "IMAGE", - "links": [ + 5, + 0, + 8, + 0, + "IMAGE" + ], + [ + 7, + 5, + 0, + 9, + 0, + "IMAGE" + ], + [ + 8, + 5, + 1, + 6, + 0, + "MASK" + ], + [ + 9, + 6, + 0, + 8, + 2, + "MASK" + ], + [ + 10, + 7, + 0, + 8, + 3, + "FLOAT" + ], + [ + 11, + 1, + 3, + 10, + 0, + "FLOAT" + ], + [ 12, + 8, + 0, + 11, + 0, + "IMAGE" + ], + [ + 13, + 1, + 3, + 11, + 1, + "FLOAT" + ], + [ + 14, + 10, + 0, + 11, + 2, + "FLOAT" + ], + [ + 15, + 9, + 4, + 11, + 3, + "INT" + ], + [ + 16, + 11, + 0, + 12, + 0, + "IMAGE" + ], + [ + 17, + 12, + 0, + 15, + 0, + "IMAGE" + ], + [ + 18, + 1, + 1, + 15, + 1, + "AUDIO" + ], + [ + 19, + 11, + 1, + 15, + 3, + "FLOAT" + ], + [ + 20, + 13, + 0, + 15, + 4, + "STRING" + ], + [ + 21, + 14, + 0, + 15, + 5, + "INT" + ], + [ + 22, + 15, + 0, + 16, + 0, + "STRING" + ], + [ 23, - 24 - ], - "slot_index": 0 + 8, + 0, + 17, + 0, + "IMAGE" + ], + [ + 24, + 8, + 0, + 18, + 0, + "IMAGE" + ], + [ + 25, + 17, + 0, + 19, + 0, + "IMAGE" + ], + [ + 26, + 18, + 0, + 19, + 1, + "IMAGE" + ] + ], + "groups": [ + { + "title": "1 · Load, thin, split + rejoin", + "bounding": [ + -20, + 40, + 420, + 1183 + ], + "color": "#235d64", + "font_size": 24, + "flags": {} + }, + { + "title": "2 · Resize + region mask", + "bounding": [ + 440, + 40, + 400, + 1224 + ], + "color": "#366451", + "font_size": 24, + "flags": {} + }, + { + "title": "3 · Tone + timing", + "bounding": [ + 880, + 40, + 380, + 920 + ], + "color": "#5d527c", + "font_size": 24, + "flags": {} + }, + { + "title": "4 · Name, save + report", + "bounding": [ + 1300, + 40, + 540, + 1304 + ], + "color": "#795b38", + "font_size": 24, + "flags": {} + }, + { + "title": "5 · First + last frames", + "bounding": [ + 1880, + 40, + 380, + 810 + ], + "color": "#345d7c", + "font_size": 24, + "flags": {} + }, + { + "title": "6 · A/B comparison", + "bounding": [ + 2300, + 40, + 540, + 700 + ], + "color": "#65515e", + "font_size": 24, + "flags": {} + } + ], + "config": {}, + "extra": { + "ds": { + "scale": 0.4602, + "offset": [ + 833.84, + 339.4 + ] } - ], - "properties": { - "Node name for S&R": "AUSBOSS_NODES_ColorMatch" - }, - "widgets_values": [ - 1.0, - "lab", - true, - "first_frame" - ] }, - { - "id": 9, - "type": "AUSBOSS_NODES_ImageSize", - "pos": [ - 1810, - 520 - ], - "size": [ - 250, - 130 - ], - "flags": {}, - "order": 8, - "mode": 0, - "inputs": [ - { - "name": "image", - "type": "IMAGE", - "link": 7 - } - ], - "outputs": [ - { - "name": "width", - "type": "INT", - "links": [], - "slot_index": 0 - }, - { - "name": "height", - "type": "INT", - "links": [], - "slot_index": 1 - }, - { - "name": "longest_edge", - "type": "INT", - "links": [], - "slot_index": 2 - }, - { - "name": "shortest_edge", - "type": "INT", - "links": [], - "slot_index": 3 - }, - { - "name": "count", - "type": "INT", - "links": [ - 15 - ], - "slot_index": 4 - } - ], - "properties": { - "Node name for S&R": "AUSBOSS_NODES_ImageSize" - }, - "widgets_values": [] - }, - { - "id": 10, - "type": "AUSBOSS_NODES_MathExpression", - "pos": [ - 1810, - 750 - ], - "size": [ - 270, - 150 - ], - "flags": {}, - "order": 9, - "mode": 0, - "inputs": [ - { - "name": "a", - "type": "FLOAT", - "link": 11, - "widget": { - "name": "a" - } - }, - { - "name": "b", - "type": "FLOAT", - "link": null, - "widget": { - "name": "b" - } - }, - { - "name": "c", - "type": "FLOAT", - "link": null, - "widget": { - "name": "c" - } - } - ], - "outputs": [ - { - "name": "float", - "type": "FLOAT", - "links": [ - 14 - ], - "slot_index": 0 - }, - { - "name": "int", - "type": "INT", - "links": [], - "slot_index": 1 - } - ], - "properties": { - "Node name for S&R": "AUSBOSS_NODES_MathExpression" - }, - "widgets_values": [ - "a * 2", - 0.0, - 0.0, - 0.0 - ] - }, - { - "id": 11, - "type": "AUSBOSS_NODES_FrameInterpolate", - "pos": [ - 2170, - 200 - ], - "size": [ - 290, - 230 - ], - "flags": {}, - "order": 10, - "mode": 0, - "inputs": [ - { - "name": "frames", - "type": "IMAGE", - "link": 12 - }, - { - "name": "source_fps", - "type": "FLOAT", - "link": 13, - "widget": { - "name": "source_fps" - } - }, - { - "name": "target_fps", - "type": "FLOAT", - "link": 14, - "widget": { - "name": "target_fps" - } - }, - { - "name": "batch_size", - "type": "INT", - "link": 15, - "widget": { - "name": "batch_size" - } - } - ], - "outputs": [ - { - "name": "frames", - "type": "IMAGE", - "links": [ - 16 - ], - "slot_index": 0 - }, - { - "name": "fps", - "type": "FLOAT", - "links": [ - 19 - ], - "slot_index": 1 - } - ], - "properties": { - "Node name for S&R": "AUSBOSS_NODES_FrameInterpolate" - }, - "widgets_values": [ - 24.0, - 30.0, - "blend", - 0.35, - 8 - ] - }, - { - "id": 12, - "type": "AUSBOSS_NODES_FreeMemory", - "pos": [ - 2520, - 200 - ], - "size": [ - 230, - 60 - ], - "flags": {}, - "order": 11, - "mode": 0, - "inputs": [ - { - "name": "value", - "type": "*", - "link": 16 - } - ], - "outputs": [ - { - "name": "value", - "type": "*", - "links": [ - 17 - ], - "slot_index": 0 - } - ], - "properties": { - "Node name for S&R": "AUSBOSS_NODES_FreeMemory" - }, - "widgets_values": [] - }, - { - "id": 13, - "type": "AUSBOSS_NODES_Text", - "pos": [ - 2520, - 620 - ], - "size": [ - 270, - 90 - ], - "flags": {}, - "order": 12, - "mode": 0, - "inputs": [], - "outputs": [ - { - "name": "text", - "type": "STRING", - "links": [ - 20 - ], - "slot_index": 0 - } - ], - "properties": { - "Node name for S&R": "AUSBOSS_NODES_Text" - }, - "widgets_values": [ - "AusBoss/tour" - ] - }, - { - "id": 14, - "type": "AUSBOSS_NODES_Integer", - "pos": [ - 2520, - 780 - ], - "size": [ - 210, - 60 - ], - "flags": {}, - "order": 13, - "mode": 0, - "inputs": [], - "outputs": [ - { - "name": "int", - "type": "INT", - "links": [ - 21 - ], - "slot_index": 0 - } - ], - "properties": { - "Node name for S&R": "AUSBOSS_NODES_Integer" - }, - "widgets_values": [ - 19 - ] - }, - { - "id": 15, - "type": "AUSBOSS_NODES_SaveVideo", - "pos": [ - 2860, - 200 - ], - "size": [ - 340, - 560 - ], - "flags": {}, - "order": 14, - "mode": 0, - "inputs": [ - { - "name": "frames", - "type": "IMAGE", - "link": 17 - }, - { - "name": "audio", - "type": "AUDIO", - "link": 18 - }, - { - "name": "video", - "type": "VIDEO", - "link": null - }, - { - "name": "fps", - "type": "FLOAT", - "link": 19, - "widget": { - "name": "fps" - } - }, - { - "name": "filename_prefix", - "type": "STRING", - "link": 20, - "widget": { - "name": "filename_prefix" - } - }, - { - "name": "crf", - "type": "INT", - "link": 21, - "widget": { - "name": "crf" - } - } - ], - "outputs": [ - { - "name": "file_path", - "type": "STRING", - "links": [ - 22 - ], - "slot_index": 0 - } - ], - "properties": { - "Node name for S&R": "AUSBOSS_NODES_SaveVideo" - }, - "widgets_values": [ - 16.0, - "AusBoss/video", - 19, - "mp4 h264", - false, - true - ] - }, - { - "id": 16, - "type": "AUSBOSS_NODES_ShowText", - "pos": [ - 3280, - 200 - ], - "size": [ - 300, - 170 - ], - "flags": {}, - "order": 15, - "mode": 0, - "inputs": [ - { - "name": "text", - "type": "STRING", - "link": 22 - } - ], - "outputs": [ - { - "name": "text", - "type": "STRING", - "links": [], - "slot_index": 0 - } - ], - "properties": { - "Node name for S&R": "AUSBOSS_NODES_ShowText" - }, - "widgets_values": [] - }, - { - "id": 17, - "type": "AUSBOSS_NODES_SelectFrame", - "pos": [ - 2170, - 950 - ], - "size": [ - 260, - 320 - ], - "flags": {}, - "order": 16, - "mode": 0, - "inputs": [ - { - "name": "frames", - "type": "IMAGE", - "link": 23 - } - ], - "outputs": [ - { - "name": "image", - "type": "IMAGE", - "links": [ - 25 - ], - "slot_index": 0 - } - ], - "properties": { - "Node name for S&R": "AUSBOSS_NODES_SelectFrame" - }, - "widgets_values": [ - 1 - ] - }, - { - "id": 18, - "type": "AUSBOSS_NODES_SelectFrame", - "pos": [ - 2170, - 1330 - ], - "size": [ - 260, - 320 - ], - "flags": {}, - "order": 17, - "mode": 0, - "inputs": [ - { - "name": "frames", - "type": "IMAGE", - "link": 24 - } - ], - "outputs": [ - { - "name": "image", - "type": "IMAGE", - "links": [ - 26 - ], - "slot_index": 0 - } - ], - "properties": { - "Node name for S&R": "AUSBOSS_NODES_SelectFrame" - }, - "widgets_values": [ - -1 - ] - }, - { - "id": 19, - "type": "AUSBOSS_NODES_Compare", - "pos": [ - 2560, - 950 - ], - "size": [ - 430, - 520 - ], - "flags": {}, - "order": 18, - "mode": 0, - "inputs": [ - { - "name": "image_a", - "type": "IMAGE", - "link": 25 - }, - { - "name": "image_b", - "type": "IMAGE", - "link": 26 - } - ], - "outputs": [ - { - "name": "image_a", - "type": "IMAGE", - "links": [], - "slot_index": 0 - } - ], - "properties": { - "Node name for S&R": "AUSBOSS_NODES_Compare" - }, - "widgets_values": [] - }, - { - "id": 20, - "type": "Note", - "pos": [ - 40, - 880 - ], - "size": [ - 380, - 220 - ], - "flags": {}, - "order": 19, - "mode": 0, - "inputs": [], - "outputs": [], - "properties": { - "Node name for S&R": "Note" - }, - "widgets_values": [ - "AusBoss node tour \u2014 every wire is the point.\n\n1) Pick any video in Load Video (this graph needs no models).\n2) Queue. The clip is thinned, split and rejoined, padded to a 512 square with the pad bars masked, de-flickered against its first frame everywhere except those bars, retimed back to double rate, and saved \u2014 with the fps, frame budget, filename, and CRF all arriving over wires from Math, Image Size, Text, and Integer nodes.\n3) Read the saved path in Show Text; flick first vs last frame in the A/B panel." - ] - } - ], - "links": [ - [ - 1, - 1, - 0, - 2, - 0, - "IMAGE" - ], - [ - 2, - 2, - 0, - 3, - 0, - "IMAGE" - ], - [ - 3, - 3, - 0, - 4, - 0, - "IMAGE" - ], - [ - 4, - 3, - 1, - 4, - 1, - "IMAGE" - ], - [ - 5, - 4, - 0, - 5, - 0, - "IMAGE" - ], - [ - 6, - 5, - 0, - 8, - 0, - "IMAGE" - ], - [ - 7, - 5, - 0, - 9, - 0, - "IMAGE" - ], - [ - 8, - 5, - 1, - 6, - 0, - "MASK" - ], - [ - 9, - 6, - 0, - 8, - 2, - "MASK" - ], - [ - 10, - 7, - 0, - 8, - 3, - "FLOAT" - ], - [ - 11, - 1, - 3, - 10, - 0, - "FLOAT" - ], - [ - 12, - 8, - 0, - 11, - 0, - "IMAGE" - ], - [ - 13, - 1, - 3, - 11, - 1, - "FLOAT" - ], - [ - 14, - 10, - 0, - 11, - 2, - "FLOAT" - ], - [ - 15, - 9, - 4, - 11, - 3, - "INT" - ], - [ - 16, - 11, - 0, - 12, - 0, - "IMAGE" - ], - [ - 17, - 12, - 0, - 15, - 0, - "IMAGE" - ], - [ - 18, - 1, - 1, - 15, - 1, - "AUDIO" - ], - [ - 19, - 11, - 1, - 15, - 3, - "FLOAT" - ], - [ - 20, - 13, - 0, - 15, - 4, - "STRING" - ], - [ - 21, - 14, - 0, - 15, - 5, - "INT" - ], - [ - 22, - 15, - 0, - 16, - 0, - "STRING" - ], - [ - 23, - 8, - 0, - 17, - 0, - "IMAGE" - ], - [ - 24, - 8, - 0, - 18, - 0, - "IMAGE" - ], - [ - 25, - 17, - 0, - 19, - 0, - "IMAGE" - ], - [ - 26, - 18, - 0, - 19, - 1, - "IMAGE" - ] - ], - "groups": [ - { - "title": "1 \u00b7 Load, thin, split & rejoin", - "bounding": [ - 20, - 120, - 1360, - 760 - ], - "color": "#14424d", - "font_size": 24, - "flags": {} - }, - { - "title": "2 \u00b7 Resize, pad-mask, de-flicker", - "bounding": [ - 1400, - 120, - 680, - 940 - ], - "color": "#1f2c38", - "font_size": 24, - "flags": {} - }, - { - "title": "3 \u00b7 Retime, free VRAM, save, report", - "bounding": [ - 2140, - 120, - 1470, - 760 - ], - "color": "#23331c", - "font_size": 24, - "flags": {} - }, - { - "title": "4 \u00b7 First vs last, A/B", - "bounding": [ - 2140, - 890, - 880, - 790 - ], - "color": "#2e1f38", - "font_size": 24, - "flags": {} - } - ], - "config": {}, - "extra": {}, - "version": 0.4 -} \ No newline at end of file + "version": 0.4 +} diff --git a/example_workflows/inputs/ausboss_pier_9x16.mp4 b/example_workflows/inputs/ausboss_pier_9x16.mp4 new file mode 100644 index 0000000..e83b062 Binary files /dev/null and b/example_workflows/inputs/ausboss_pier_9x16.mp4 differ diff --git a/example_workflows/inputs/ausboss_pier_rowboat.png b/example_workflows/inputs/ausboss_pier_rowboat.png new file mode 100644 index 0000000..a71a8fe Binary files /dev/null and b/example_workflows/inputs/ausboss_pier_rowboat.png differ diff --git a/example_workflows/inputs/ausboss_pier_sunrise.png b/example_workflows/inputs/ausboss_pier_sunrise.png new file mode 100644 index 0000000..f28dadb Binary files /dev/null and b/example_workflows/inputs/ausboss_pier_sunrise.png differ diff --git a/example_workflows/simple_video_watermark_remover.jpg b/example_workflows/simple_video_watermark_remover.jpg new file mode 100644 index 0000000..c3ab0ae Binary files /dev/null and b/example_workflows/simple_video_watermark_remover.jpg differ diff --git a/example_workflows/simple_video_watermark_remover.json b/example_workflows/simple_video_watermark_remover.json index 2632e54..c71855f 100644 --- a/example_workflows/simple_video_watermark_remover.json +++ b/example_workflows/simple_video_watermark_remover.json @@ -1,94 +1,23 @@ { "id": "8ff23428-65fa-4d32-a991-e3ca923d33ae", "revision": 0, - "last_node_id": 103, + "last_node_id": 102, "last_link_id": 139, "nodes": [ - { - "id": 78, - "type": "MarkdownNote", - "pos": [ - -814.2448994696199, - -1623.8657448262422 - ], - "size": [ - 291.6770995521895, - 185.07581204956387 - ], - "flags": {}, - "order": 0, - "mode": 0, - "inputs": [], - "outputs": [], - "title": "Workflow Modes", - "properties": { - "ue_properties": { - "widget_ue_connectable": {}, - "version": "7.7", - "input_ue_unconnectable": {} - } - }, - "widgets_values": [ - "## 🔀 Workflow Modes\n\nUse these toggles to choose which mode to run:\n\n- **Main Workflow = ON** → runs the full video workflow\n- **Single-Frame Test = ON** → runs the one-frame test (recommended first)\n\n👉 Turn **off** the main workflow to test a single frame. \n👉 Turn **off** the test workflow to run the full video." - ], - "widgets_values_named": { - "text": "## 🔀 Workflow Modes\n\nUse these toggles to choose which mode to run:\n\n- **Main Workflow = ON** → runs the full video workflow\n- **Single-Frame Test = ON** → runs the one-frame test (recommended first)\n\n👉 Turn **off** the main workflow to test a single frame. \n👉 Turn **off** the test workflow to run the full video." - }, - "color": "#222", - "bgcolor": "#000" - }, - { - "id": 87, - "type": "Label (rgthree)", - "pos": [ - 444.4268370814372, - -1330.7170380157074 - ], - "size": [ - 95.05000305175781, - 18 - ], - "flags": { - "allow_interaction": true - }, - "order": 1, - "mode": 0, - "inputs": [], - "outputs": [], - "title": "by AusBoss", - "properties": { - "fontSize": 18, - "fontFamily": "Arial", - "fontColor": "#ffffff", - "textAlign": "left", - "backgroundColor": "transparent", - "padding": 0, - "borderRadius": 0, - "angle": 0, - "ue_properties": { - "version": "7.7", - "widget_ue_connectable": {}, - "input_ue_unconnectable": {} - } - }, - "color": "#fff0", - "bgcolor": "#fff0", - "shape": 1 - }, { "id": 93, "type": "AUSBOSS_NODES_LaMaInpaint", "pos": [ - 367.156249716417, - -1212.6956718906404 + 1000, + 100 ], "size": [ - 270, - 434 + 560, + 400 ], "flags": {}, "order": 13, - "mode": 4, + "mode": 0, "inputs": [ { "name": "image", @@ -112,7 +41,7 @@ ], "properties": { "cnr_id": "ausboss-nodes", - "ver": "6d5d790ddccf265d068cdc4f73352f19e10df97f", + "ver": "2.0.0", "Node name for S&R": "AUSBOSS_NODES_LaMaInpaint" }, "widgets_values": [ @@ -129,16 +58,16 @@ "id": 100, "type": "AUSBOSS_NODES_SaveVideo", "pos": [ - 661.1806211676204, - -1216.1332800128846 + 1000, + 550 ], "size": [ - 368.59648626154, - 689.3350677440123 + 560, + 560 ], "flags": {}, "order": 15, - "mode": 4, + "mode": 0, "inputs": [ { "name": "frames", @@ -170,7 +99,7 @@ "outputs": [], "properties": { "cnr_id": "ausboss-nodes", - "ver": "6d5d790ddccf265d068cdc4f73352f19e10df97f", + "ver": "2.0.0", "Node name for S&R": "AUSBOSS_NODES_SaveVideo", "ausboss_save_video_loop": false }, @@ -199,12 +128,12 @@ "id": 101, "type": "AUSBOSS_NODES_SelectFrame", "pos": [ - -440.6612647359069, - -318.2938247317346 + 1640, + 100 ], "size": [ - 276.6395948334766, - 282 + 400, + 340 ], "flags": {}, "order": 9, @@ -229,11 +158,12 @@ ], "properties": { "cnr_id": "ausboss-nodes", - "ver": "6d5d790ddccf265d068cdc4f73352f19e10df97f", + "ver": "2.0.0", "Node name for S&R": "AUSBOSS_NODES_SelectFrame" }, "widgets_values": [ - 16 + 1, + true ], "widgets_values_named": { "frame_number": 16 @@ -242,98 +172,16 @@ "bgcolor": "#081413", "title": "Select Frame" }, - { - "id": 82, - "type": "easy showAnything", - "pos": [ - -440.6612647359069, - 10.79901508675971 - ], - "size": [ - 210, - 88 - ], - "flags": {}, - "order": 10, - "mode": 0, - "inputs": [ - { - "name": "anything", - "shape": 7, - "type": "*", - "link": 119 - } - ], - "outputs": [ - { - "name": "output", - "type": "*", - "links": null - } - ], - "title": "Total Frames", - "properties": { - "cnr_id": "comfyui-workflow-encrypt", - "ver": "1.0.0", - "Node name for S&R": "easy showAnything", - "ue_properties": { - "widget_ue_connectable": {}, - "input_ue_unconnectable": {}, - "version": "7.7" - } - }, - "widgets_values": [ - "113" - ], - "widgets_values_named": { - "text": "113" - }, - "color": "#113E3D", - "bgcolor": "#2A5756" - }, - { - "id": 81, - "type": "MarkdownNote", - "pos": [ - -440.6612647359069, - 153.2812074081707 - ], - "size": [ - 286.0422558627471, - 133.95131537433764 - ], - "flags": {}, - "order": 2, - "mode": 0, - "inputs": [], - "outputs": [], - "title": "Single-Frame Test", - "properties": { - "ue_properties": { - "widget_ue_connectable": {}, - "version": "7.7", - "input_ue_unconnectable": {} - } - }, - "widgets_values": [ - "## 🧪 Single-Frame Test\n\nOptional, but recommended.\n\nThis lets you test the workflow on **one frame first** before processing the full video, so you can tune the mask without waiting on a whole clip.\n\nPick any valid frame number between **1** and the **total frame count** shown above." - ], - "widgets_values_named": { - "text": "## 🧪 Single-Frame Test\n\nOptional, but recommended.\n\nThis lets you test the workflow on **one frame first** before processing the full video, so you can tune the mask without waiting on a whole clip.\n\nPick any valid frame number between **1** and the **total frame count** shown above." - }, - "color": "#222", - "bgcolor": "#000" - }, { "id": 96, "type": "AUSBOSS_NODES_Compare", "pos": [ - 831.1806211676202, - -318.2938247317346 + 2120, + 550 ], "size": [ - 324.0000000000001, - 396.2200232691035 + 520, + 600 ], "flags": {}, "order": 17, @@ -354,12 +202,12 @@ { "name": "image_a", "type": "IMAGE", - "links": null + "links": [] } ], "properties": { "cnr_id": "ausboss-nodes", - "ver": "6d5d790ddccf265d068cdc4f73352f19e10df97f", + "ver": "2.0.0", "Node name for S&R": "AUSBOSS_NODES_Compare", "ausboss_compare_mode": "slide" }, @@ -377,12 +225,12 @@ "id": 94, "type": "AUSBOSS_NODES_LaMaInpaint", "pos": [ - 547.2655934550037, - -309.2009849132403 + 2120, + 100 ], "size": [ - 270, - 290 + 520, + 400 ], "flags": {}, "order": 16, @@ -410,7 +258,7 @@ ], "properties": { "cnr_id": "ausboss-nodes", - "ver": "6d5d790ddccf265d068cdc4f73352f19e10df97f", + "ver": "2.0.0", "Node name for S&R": "AUSBOSS_NODES_LaMaInpaint" }, "widgets_values": [ @@ -427,16 +275,16 @@ "id": 50, "type": "SAM3Segment", "pos": [ - -418, - -1212.6956718906404 + 520, + 100 ], "size": [ - 340, - 394 + 400, + 410 ], "flags": {}, "order": 8, - "mode": 4, + "mode": 0, "inputs": [ { "name": "image", @@ -486,7 +334,7 @@ 0, "Auto", false, - false, + true, "Alpha", "#222222" ], @@ -511,12 +359,12 @@ "id": 102, "type": "AUSBOSS_NODES_RefineMask", "pos": [ - 232.156249716417, - -309.2009849132403 + 1640, + 950 ], "size": [ - 270, - 434 + 400, + 470 ], "flags": {}, "order": 14, @@ -545,12 +393,12 @@ { "name": "mask_inverted", "type": "MASK", - "links": null + "links": [] } ], "properties": { "cnr_id": "ausboss-nodes", - "ver": "0e4749db6b03864b7322867c8c81937f21ea2430", + "ver": "2.0.0", "Node name for S&R": "AUSBOSS_NODES_RefineMask" }, "widgets_values": [ @@ -579,12 +427,12 @@ "id": 66, "type": "SAM3Segment", "pos": [ - -140.50112924413511, - -318.2938247317346 + 1640, + 490 ], "size": [ - 340, - 394 + 400, + 410 ], "flags": {}, "order": 12, @@ -636,7 +484,7 @@ 0, "Auto", false, - false, + true, "Alpha", "#222222" ], @@ -657,141 +505,29 @@ "color": "#222e40", "bgcolor": "#364254" }, - { - "id": 83, - "type": "MarkdownNote", - "pos": [ - 581.8578922010117, - -1456.4611981253577 - ], - "size": [ - 258.0391209610526, - 149.61746021334727 - ], - "flags": {}, - "order": 3, - "mode": 0, - "inputs": [], - "outputs": [], - "title": "Connect", - "properties": { - "ue_properties": { - "widget_ue_connectable": {}, - "version": "7.7", - "input_ue_unconnectable": {} - } - }, - "widgets_values": [ - "## 🌐 Connect with me\n\n- 🐦 X: [Zanzibased](https://x.com/Zanzibased)\n- 💻 GitHub: [ausboss](https://github.com/ausboss)\n- 🎨 Civitai: [AusBoss](https://civitai.com/user/AusBoss)\n- 🎥 YouTube: *coming soon*\n- 💬 Discord: *coming soon*\n\n✨ More workflows and experiments coming soon" - ], - "widgets_values_named": { - "text": "## 🌐 Connect with me\n\n- 🐦 X: [Zanzibased](https://x.com/Zanzibased)\n- 💻 GitHub: [ausboss](https://github.com/ausboss)\n- 🎨 Civitai: [AusBoss](https://civitai.com/user/AusBoss)\n- 🎥 YouTube: *coming soon*\n- 💬 Discord: *coming soon*\n\n✨ More workflows and experiments coming soon" - }, - "color": "#222", - "bgcolor": "#000" - }, - { - "id": 75, - "type": "Fast Groups Bypasser (rgthree)", - "pos": [ - -813.5041675551382, - -1392.1849589949354 - ], - "size": [ - 290.675968308818, - 98.69588446447938 - ], - "flags": {}, - "order": 4, - "mode": 0, - "inputs": [], - "outputs": [ - { - "name": "OPT_CONNECTION", - "type": "*", - "links": null - } - ], - "title": "Workflow Modes", - "properties": { - "matchColors": "", - "matchTitle": "", - "showNav": true, - "showAllGraphs": true, - "sort": "position", - "customSortAlphabet": "", - "toggleRestriction": "default", - "ue_properties": { - "widget_ue_connectable": {}, - "input_ue_unconnectable": {}, - "version": "7.7" - } - } - }, - { - "id": 86, - "type": "Label (rgthree)", - "pos": [ - -418, - -1455.7899327766784 - ], - "size": [ - 981.75, - 64 - ], - "flags": { - "allow_interaction": true - }, - "order": 5, - "mode": 0, - "inputs": [], - "outputs": [], - "title": "Simple Video Watermark Remover", - "properties": { - "fontSize": 64, - "fontFamily": "Arial", - "fontColor": "#ffffff", - "textAlign": "left", - "backgroundColor": "transparent", - "padding": 0, - "borderRadius": 0, - "angle": 0, - "ue_properties": { - "version": "7.7", - "widget_ue_connectable": {}, - "input_ue_unconnectable": {} - } - }, - "color": "#fff0", - "bgcolor": "#fff0", - "shape": 1 - }, { "id": 62, - "type": "MarkdownNote", + "type": "AUSBOSS_NODES_WorkflowNote", "pos": [ - -1515.7408387883006, - -1220.520365103764 + -620, + 100 ], "size": [ - 439.92987728524963, - 416.3277402120399 + 560, + 1100 ], "flags": {}, "order": 6, "mode": 0, "inputs": [], "outputs": [], - "title": "Overview", "properties": { - "ue_properties": { - "widget_ue_connectable": {}, - "version": "7.7", - "input_ue_unconnectable": {} - } + "cnr_id": "ausboss-nodes", + "Node name for S&R": "AUSBOSS_NODES_WorkflowNote", + "ver": "2.0.0" }, "widgets_values": [ - "# 🎬✨ Video Watermark Removal\n\nRemove annoying watermarks from your video automatically, frame by frame. Especially useful for Sora-style watermarks. It's not perfect and can leave behind some artifacts, but it's fast and gets the job done.\n\n## 🧠 How it works\n\n1. 🎥 **Load Video** \n Splits your video into frames and keeps the original audio.\n\n2. 🔍 **Detect Watermark (SAM3)** \n Uses `SAM3Segment` with the prompt **\"watermark\"** to find the watermark in each frame and generate a mask.\n\n3. 🫧 **Mask Refine** \n The mask is grown and feathered so coverage is solid and edges blend smoothly. Press **AUTO** on the node to size both for your video.\n\n4. 🧼 **Remove Watermark** \n The refined mask is passed into `LaMa Inpaint 🆎` using `big-lama.pt`.\n\n5. 🎞️ **Rebuild Video** \n Frames are stitched back together with the original audio and FPS preserved.\n\n## 📦 Model setup\n\nPlace `big-lama.pt` at `ComfyUI/models/lama/big-lama.pt`, then restart ComfyUI. The AusBoss node never downloads models automatically.\n\n## 💡 Notes\n\n- ✅ Works best with consistent/static watermarks\n- 🪶 Feathering helps avoid ugly edges\n- 📦 Outputs H.264 MP4 by default; Save Video also writes h265, webm, ProRes, lossless and gif/webp\n\n## 📦 Requires\n\n- 🧩 **ComfyUI-AusBoss** — Load Video, Select Frame, Mask Refine, LaMa Inpaint, Image Compare A/B, Save Video\n- 🧩 **ComfyUI-RMBG** — SAM3 segmentation\n- 🧩 **rgthree-comfy** — labels and the group toggles\n- 🧩 **ComfyUI-Easy-Use** — the Total Frames readout\n- 📥 **Model**: a TorchScript `big-lama.pt` in `ComfyUI/models/lama/`" + "{\"v\": 1, \"title\": \"Simple Video Watermark Remover\", \"subtitle\": \"\", \"author\": \"AusBoss\", \"layout\": \"card\", \"body\": \"## Setup\\nInstall ComfyUI-RMBG for its SAM3 Segment node and configure its SAM3 model. Put big-lama.pt in models/lama. This is the one example that requires another custom node pack.\\n\\nCopy the included ausboss_pier_9x16.mp4 to ComfyUI/input or upload your own clip. The supplied clip has no watermark: it is a safe setup check, and an empty detection mask should leave it unchanged.\\n\\n## First check\\nQueue the short one-second window. The single-frame branch shows the mask and an A/B comparison. Change the detection prompt when the overlay has a more specific description.\\n\\n## Full clip\\nBoth branches are enabled; the upper branch writes the cleaned video with its original audio. Once the mask is right, increase the trim window and frame limit. To work only on the still, select the full-video group and use ComfyUI’s Mute command.\\n\\nSAM3 detects each frame separately and unloads its model after each branch to leave room for LaMa. LaMa processes frames independently; challenging backgrounds can flicker. Review the output before extending the run.\", \"models\": [{\"name\": \"big-lama.pt\", \"dir\": \"lama\", \"url\": \"https://github.com/Sanster/models/releases/download/add_big_lama/big-lama.pt\"}], \"packs\": [{\"name\": \"ComfyUI-RMBG\", \"node\": \"SAM3Segment\", \"url\": \"https://github.com/1038lab/ComfyUI-RMBG\"}]}" ], "widgets_values_named": { "text": "# 🎬✨ Video Watermark Removal\n\nRemove annoying watermarks from your video automatically, frame by frame. Especially useful for Sora-style watermarks. It's not perfect and can leave behind some artifacts, but it's fast and gets the job done.\n\n## 🧠 How it works\n\n1. 🎥 **Load Video** \n Splits your video into frames and keeps the original audio.\n\n2. 🔍 **Detect Watermark (SAM3)** \n Uses `SAM3Segment` with the prompt **\"watermark\"** to find the watermark in each frame and generate a mask.\n\n3. 🫧 **Mask Refine** \n The mask is grown and feathered so coverage is solid and edges blend smoothly. Press **AUTO** on the node to size both for your video.\n\n4. 🧼 **Remove Watermark** \n The refined mask is passed into `LaMa Inpaint 🆎` using `big-lama.pt`.\n\n5. 🎞️ **Rebuild Video** \n Frames are stitched back together with the original audio and FPS preserved.\n\n## 📦 Model setup\n\nPlace `big-lama.pt` at `ComfyUI/models/lama/big-lama.pt`, then restart ComfyUI. The AusBoss node never downloads models automatically.\n\n## 💡 Notes\n\n- ✅ Works best with consistent/static watermarks\n- 🪶 Feathering helps avoid ugly edges\n- 📦 Outputs H.264 MP4 by default; Save Video also writes h265, webm, ProRes, lossless and gif/webp\n\n## 📦 Requires\n\n- 🧩 **ComfyUI-AusBoss** — Load Video, Select Frame, Mask Refine, LaMa Inpaint, Image Compare A/B, Save Video\n- 🧩 **ComfyUI-RMBG** — SAM3 segmentation\n- 🧩 **rgthree-comfy** — labels and the group toggles\n- 🧩 **ComfyUI-Easy-Use** — the Total Frames readout\n- 📥 **Model**: a TorchScript `big-lama.pt` in `ComfyUI/models/lama/`" @@ -803,12 +539,12 @@ "id": 95, "type": "AUSBOSS_NODES_LoadVideo", "pos": [ - -901.5738127350896, - -1234.6626307352851 + 0, + 100 ], "size": [ - 413.86843580322613, - 827.0256602894457 + 440, + 700 ], "flags": {}, "order": 7, @@ -834,9 +570,7 @@ { "name": "frame_count", "type": "INT", - "links": [ - 119 - ] + "links": [] }, { "name": "fps", @@ -848,38 +582,38 @@ { "name": "width", "type": "INT", - "links": null + "links": [] }, { "name": "height", "type": "INT", - "links": null + "links": [] }, { "name": "duration", "type": "FLOAT", - "links": null + "links": [] }, { "name": "video", "type": "VIDEO", - "links": null + "links": [] } ], "properties": { "cnr_id": "ausboss-nodes", - "ver": "6d5d790ddccf265d068cdc4f73352f19e10df97f", + "ver": "2.0.0", "Node name for S&R": "AUSBOSS_NODES_LoadVideo", "ausboss_load_video_loop": true }, "widgets_values": { - "video": "v09044g40000c66m6j3c77ubl0cip87g.mp4", - "start_seconds": 5.26, - "end_seconds": 9.01, + "video": "ausboss_pier_9x16.mp4", + "start_seconds": 0, + "end_seconds": 1, "custom_width": 0, "custom_height": 0, "every_nth": 1, - "max_frames": 0 + "max_frames": 24 }, "widgets_values_named": { "video": "v09044g40000c66m6j3c77ubl0cip87g.mp4", @@ -900,16 +634,16 @@ "id": 99, "type": "AUSBOSS_NODES_RefineMask", "pos": [ - -9.912221052486675, - -1212.6956718906404 + 520, + 560 ], "size": [ - 313.6306248649747, - 434 + 400, + 470 ], "flags": {}, "order": 11, - "mode": 4, + "mode": 0, "inputs": [ { "name": "mask", @@ -934,12 +668,12 @@ { "name": "mask_inverted", "type": "MASK", - "links": null + "links": [] } ], "properties": { "cnr_id": "ausboss-nodes", - "ver": "6d5d790ddccf265d068cdc4f73352f19e10df97f", + "ver": "2.0.0", "Node name for S&R": "AUSBOSS_NODES_RefineMask" }, "widgets_values": [ @@ -982,14 +716,6 @@ 0, "IMAGE" ], - [ - 119, - 95, - 2, - 82, - 0, - "INT" - ], [ 122, 94, @@ -1097,27 +823,63 @@ ], "groups": [ { - "id": 4, - "title": "Main Workflow", + "title": "1 · Source + trim", "bounding": [ - -456.4026375674729, - -1286.6295895799299, - 1497.5832587350933, - 878.9926191340904 + -20, + 40, + 480, + 980 ], - "color": "#3f789e", + "color": "#235d64", + "font_size": 24, "flags": {} }, { - "id": 5, - "title": "Single-Frame Test", + "title": "2 · Detect + refine · full video", "bounding": [ - -452.6851075783738, - -384.3187925918293, - 1663.742946064448, - 694.3583428512577 + 500, + 40, + 440, + 1010 ], - "color": "#255725", + "color": "#366451", + "font_size": 24, + "flags": {} + }, + { + "title": "3 · Inpaint + save · full video", + "bounding": [ + 980, + 40, + 600, + 1090 + ], + "color": "#5d527c", + "font_size": 24, + "flags": {} + }, + { + "title": "4 · Single-frame check", + "bounding": [ + 1620, + 40, + 440, + 1400 + ], + "color": "#795b38", + "font_size": 24, + "flags": {} + }, + { + "title": "5 · Inpaint + compare · single frame", + "bounding": [ + 2100, + 40, + 560, + 1130 + ], + "color": "#345d7c", + "font_size": 24, "flags": {} } ], @@ -1125,10 +887,10 @@ "extra": { "ue_links": [], "ds": { - "scale": 0.7972024500000143, + "scale": 0.485, "offset": [ - 1095.5012243991416, - 1470.9339670330596 + 824.95, + 320.52 ] }, "links_added_by_ue": [], diff --git a/js/align_image/index.js b/js/align_image/index.js deleted file mode 100644 index e4b7d8c..0000000 --- a/js/align_image/index.js +++ /dev/null @@ -1,60 +0,0 @@ -import { app } from "/scripts/app.js"; -import { chainCallback } from "../shared/index.mjs"; -import { setWidgetVisible } from "../shared/widget_visibility.mjs"; - -const NODE_CLASS = "AUSBOSS_NODES_AlignImage"; - -// Each anchor/fill widget only means something in its own mode, so the node -// face stays as compact as before the options existed: -// crop_position -> mode == "crop" -// pad_position, pad_fill -> mode == "pad" -// pad_color -> mode == "pad" AND pad_fill == "color" -function syncOptionWidgets(node) { - const widget = (name) => node.widgets?.find((item) => item.name === name); - const mode = widget("mode"); - const padFill = widget("pad_fill"); - if (!mode) return; - const wants = { - crop_position: mode.value === "crop", - pad_position: mode.value === "pad", - pad_fill: mode.value === "pad", - pad_color: mode.value === "pad" && padFill?.value === "color", - }; - let changed = false; - for (const [name, visible] of Object.entries(wants)) { - const target = widget(name); - if (target && setWidgetVisible(target, visible)) changed = true; - } - if (!changed) return; - const width = node.size?.[0] ?? 0; - node.setSize([Math.max(width, node.computeSize()[0]), node.computeSize()[1]]); - node.graph?.setDirtyCanvas?.(true, true); -} - -function watchWidget(node, name) { - const widget = node.widgets?.find((item) => item.name === name); - if (!widget) return; - const previous = widget.callback; - widget.callback = function (...args) { - const result = previous?.apply(this, args); - syncOptionWidgets(node); - return result; - }; -} - -app.registerExtension({ - name: "AusBoss.AlignImage", - beforeRegisterNodeDef(nodeType, nodeData) { - if (nodeData?.name !== NODE_CLASS) return; - chainCallback(nodeType.prototype, "onNodeCreated", function () { - watchWidget(this, "mode"); - watchWidget(this, "pad_fill"); - syncOptionWidgets(this); - }); - chainCallback(nodeType.prototype, "onConfigure", function () { - // Restored widget values land after creation; re-sync to the loaded - // mode without disturbing the size the workflow saved. - queueMicrotask(() => syncOptionWidgets(this)); - }); - }, -}); diff --git a/js/appearance/index.js b/js/appearance/index.js index d2a60e8..de84714 100644 --- a/js/appearance/index.js +++ b/js/appearance/index.js @@ -234,6 +234,10 @@ app.registerExtension({ // card built from the node's own DESCRIPTION and tooltips, so the docs on // screen are exactly the docs in the source. function installHelpBadge(node) { + // A title-less node (Run Timer) has no bar to hang the badge on; its + // help stays reachable through the node's context menu. + const noTitle = node.constructor?.title_mode === (globalThis.LiteGraph?.NO_TITLE ?? 1); + if (noTitle) return; chainCallback(node, "onDrawForeground", function (ctx) { if (!showBadge(this.size?.[0] ?? 0, this.flags?.collapsed)) return; const [x, y] = badgeCenter(this.size[0]); diff --git a/js/docs/AUSBOSS_NODES_AlignImage.md b/js/docs/AUSBOSS_NODES_AlignImage.md index e0e3f47..c85274c 100644 --- a/js/docs/AUSBOSS_NODES_AlignImage.md +++ b/js/docs/AUSBOSS_NODES_AlignImage.md @@ -39,9 +39,7 @@ replicate-padded evenly). - **width** / **height**: The new size as INTs — wire them into an Empty Latent, a resize, or conditioning nodes so the numbers can never drift from the actual image. -- **offset_x** / **offset_y**: Where the original's left/top edge sits in - the output: positive after padding, negative after cropping, `0` after a - resize — wire into a crop to un-align after sampling. + ## Notes diff --git a/js/docs/AUSBOSS_NODES_ImageCropRotatePad.md b/js/docs/AUSBOSS_NODES_ImageCropRotatePad.md index 59f1d62..042fe6c 100644 --- a/js/docs/AUSBOSS_NODES_ImageCropRotatePad.md +++ b/js/docs/AUSBOSS_NODES_ImageCropRotatePad.md @@ -4,7 +4,9 @@ Loads an image and applies one reusable **rotate → crop → pad** transform. C ## Controls -- **image**: Image picker/upload from ComfyUI's input folder. +- **Image source**: Pick an existing input image or click **Upload** in the compact + source card. Dropping an image onto the node still works. The original `image` + widget remains the saved/API value; the old picker and upload rows are hidden. - **rotation_degrees**: Clockwise rotation before crop and padding. - **crop_aspect_ratio**: Free crop, source ratio, or a fixed ratio. - **crop_x / crop_y / crop_width / crop_height**: Crop in rotated-image pixels. Width and height `0` mean the full available dimension. @@ -16,10 +18,14 @@ Loads an image and applies one reusable **rotate → crop → pad** transform. C ## Quick row (on the node) -Under the compact canvas: **Reset** zeroes rotation, crop, and padding in one click (resize settings are output options and survive); **Feather** toggles feathering on and off, remembering the amount it turned off; **Resize** enables the megapixel budget and reveals its number box. The full method/steps controls live in the editor's **Resize output** section, and the status panel there names the exact resized size. +Right under the compact canvas, the format chips (16:9, 9:16, 1:1, 4:3 ...) pad the whole image to that aspect with centered fill bands in one tap - the editor's **Pad to aspect** without opening it; tap the lit chip to clear the bands. Below the Open editor button: **Reset** zeroes rotation, crop, and padding in one click (resize settings are output options and survive); **Feather** toggles feathering on and off, remembering the amount it turned off; **Resize** enables the megapixel budget and reveals its number box. The full method/steps controls live in the editor's **Resize output** section, and the status panel there names the exact resized size. The output pixel size is drawn centered just below the image — outside the pixels being judged — and shows the resize target when one is active (`576 x 1024 → 768 x 1344`). +Feather and Resize stay synchronized with the editor. Changing the resize budget +updates the size readout immediately. Restoring a workflow or undoing a change +refreshes the source card and preview without resetting the saved framing. + ## Outputs - **image**: BHWC float image batch. Animated image frames receive the identical transform. @@ -27,7 +33,12 @@ The output pixel size is drawn centered just below the image — outside the pix ## Editor gestures +Choose **Target aspect**, then **Crop to aspect** to trim the largest centered crop, +or **Pad to aspect** to keep the full rotated source and add centered fill-color bands. +Both replace existing crop and padding. The target selection alone does not change +the transform. Padding unlocks the inner crop; its target choice stays in the editor. +Canvas multiple and resize steps can slightly change the fitted aspect. + Drag cyan squares to resize the crop, drag inside to move it, orange diamonds to add padding, and the green handle to rotate. Hold `Shift` while rotating to snap to 15 degrees. Use the wheel to zoom and middle mouse or `Alt`-drag to pan. The same handles work directly on the node's compact preview (fit-only there — the wheel keeps zooming the graph); zoom and pan are editor-only. The node performs no network requests and writes no files beyond a normal user-initiated ComfyUI upload. - diff --git a/js/docs/AUSBOSS_NODES_ImageResize.md b/js/docs/AUSBOSS_NODES_ImageResize.md index 86dcb6f..9c9985f 100644 --- a/js/docs/AUSBOSS_NODES_ImageResize.md +++ b/js/docs/AUSBOSS_NODES_ImageResize.md @@ -10,7 +10,9 @@ output width/height come back as INTs ready to wire onward. Each mode reads only its own widget(s); the others are ignored. -- **width+height**: The two size widgets. `0` keeps that source dimension, +- **width+height**: The two size fields. Their input sockets sit with the + node's inputs, under `image` and `mask`; a linked one greys its field out. + `0` keeps that source dimension, and with only one set the other follows the source aspect. - **longest_edge** / **shortest_edge**: Scales until that edge equals `edge_length` exactly; the other edge keeps the aspect. `0` keeps the diff --git a/js/docs/AUSBOSS_NODES_Krea2OutpaintModelPatch.md b/js/docs/AUSBOSS_NODES_Krea2OutpaintModelPatch.md index 84fc6e5..21b1eb7 100644 --- a/js/docs/AUSBOSS_NODES_Krea2OutpaintModelPatch.md +++ b/js/docs/AUSBOSS_NODES_Krea2OutpaintModelPatch.md @@ -12,6 +12,10 @@ Place it **after** any LoRA loader and **before** the sampler. ## Controls +The node's card shows **Reference** (`source rect` / `whole canvas`) and +**KV cache** (`every step` / `once per run`). Both accept links and retain +their saved values when the workflow is reloaded. + - **model**: A Krea 2 model. Patch last, so a LoRA loaded afterwards does not replace the patched forward pass. - **stitcher**: From Load Image + Pad 🆎. Supplies the source rectangle. A @@ -21,30 +25,56 @@ Place it **after** any LoRA loader and **before** the sampler. - **kv_cache**: Compute the reference's keys and values once per run instead of once per step. The reference does not change while sampling, so this is free speed. Turn it off only to rule the cache out when debugging. +- **placement**: Where the reference tokens go. `source rectangle` (default) + pins them to the rectangle the stitcher reports. `whole canvas` spreads + them over the full frame. Which one is right depends on the LoRA — see + below. ## Output - **MODEL**: The model with reference tokens registered into the canvas grid. -## One axis at a time +## Two LoRAs, two placements -**This is the constraint that decides whether an outpaint works.** The model -places the source spanning one whole canvas axis — pad left/right and the -source must span the full height, pad top/bottom and it must span the full -width. Padding *both* axes puts the placement outside what the weights were -trained on, and the new region breaks up: hard cuts at the old edge, mangled -anatomy, a flat band where content should continue. +The base Krea 2 weights were never trained on registered references; what +makes this patch work is a LoRA trained for it, and the two that exist want +different placements. -The node says so in the console when it sees it. To fix: +**Every side at once — `whole canvas` + AnyPaint.** yijunwang2's +[Krea 2 AnyPaint](https://huggingface.co/yijunwang2/krea2-anypaint) +(`krea2_anypaint_rank32.safetensors`) was trained with the grey-padded canvas +itself as the reference, spread over the whole frame, and the known pixels +held in place by the sampler. Wire it like the *Krea 2 Outpaint* example: -1. Pad one axis, run it, save the result. -2. Load that result and pad the other axis. Run again. +- Load Image + Pad 🆎 with any padding you like — left, right, top and + bottom together are fine. Flat colour fill. +- Krea 2 Encode 🆎 with the pad node's **image** output (the padded canvas, + not the unpadded *reference* output) as the reference, and `vlm_reference` + **on**. +- This patch on `whole canvas`. +- VAE Encode the padded canvas, Set Latent Noise Mask with the pad mask, and + sample from that latent at full denoise. The mask is what keeps the source + pixels; the reference is what tells the model what they are. -**A rounding sliver counts.** If you pad only the bottom but `canvas_multiple` -rounds the width up by 11px, the source no longer spans the width and the run -degrades exactly the same way. Either lower the multiple or pick one that -already divides that dimension — 720 wide is fine at 16, not at 32. The -warning reports the spare pixels on each axis so you can see which it is. +Any white mask pixel is *generate*, so the same wiring inpaints: paint over +something in the mask editor and pad at the same time. + +**One axis per pass — `source rectangle` + Registered Outpaint.** The same +author's [Registered Outpaint](https://huggingface.co/yijunwang2/krea2-outpaint) +(`krea2_outpaint_rank32.safetensors`) is the LoRA this placement was built +for: the *unpadded* source goes in as the reference (the pad node's +`reference` output, `vlm_reference` off) and is pinned to its rectangle. It +was trained on a source spanning one whole canvas axis — pad left/right and +the source must span the full height, pad top/bottom and it must span the +full width. Padding both axes in one pass is outside its training and the +new region can break up; the node says so in the console when it sees it, +and the cure is two passes: pad one axis, run, load the result, pad the +other. **A rounding sliver counts**: if `canvas_multiple` rounds the other +axis up by 11 px the source no longer spans it. The warning reports the +spare pixels on each axis. + +Without either LoRA the patch still places the reference, but the bare +Turbo model treats it loosely and results are hit and miss. ## Notes and limitations diff --git a/js/docs/AUSBOSS_NODES_LaMaInpaint.md b/js/docs/AUSBOSS_NODES_LaMaInpaint.md index 6e67849..739c869 100644 --- a/js/docs/AUSBOSS_NODES_LaMaInpaint.md +++ b/js/docs/AUSBOSS_NODES_LaMaInpaint.md @@ -40,3 +40,7 @@ underneath the node. The node performs no network requests. It writes one preview PNG per run into ComfyUI's temp folder, alongside what `PreviewImage` writes. + +The small **preview** switch at the right of the bar above the picture turns +it off: the picture's box disappears, the node is that much shorter, and no +preview file is written. The switch is the node's optional `preview` input. diff --git a/js/docs/AUSBOSS_NODES_LmStudioChat.md b/js/docs/AUSBOSS_NODES_LmStudioChat.md deleted file mode 100644 index 69565b8..0000000 --- a/js/docs/AUSBOSS_NODES_LmStudioChat.md +++ /dev/null @@ -1,88 +0,0 @@ -# LM Studio Chat - -Sends a prompt — and optionally an image — to a local LM Studio server and -returns the reply as text. Anything that speaks the OpenAI-compatible chat -API works too: Ollama's compatibility mode, llama.cpp's server, a remote -box. Typical jobs: expanding a rough idea into a full prompt, captioning an -image for img2img, or rewriting text mid-workflow. Chained through the -`history` output, several nodes hold a multi-turn conversation. - -## Controls - -- **prompt**: What to ask. Required unless an image is connected — an image - with an empty prompt asks for a plain description. -- **system_prompt** (optional): Role and rules, e.g. *"You write - single-paragraph Stable Diffusion prompts. No preamble, no quotes."* - Empty sends no system message. -- **endpoint**: The server's base URL — LM Studio shows it on the Developer - tab, default `http://127.0.0.1:1234/v1`. A bare `host:port` or a full - `/v1` path both work. The toolbar under this field has a **Test** button - (the dot goes green when the server answers) and a **Models** button that - lists the server's models and picks one into the model field. -- **model**: Model identifier as LM Studio lists it. **Empty uses whatever - the server has loaded**, which is the everyday case — no fetched dropdown - to go stale when LM Studio was not running at ComfyUI startup. -- **temperature**: 0 is near-deterministic, 0.7 balanced. Captioning likes - 0.2–0.5. -- **max_tokens**: Longest allowed reply; `-1` leaves it to the server. - Reasoning models spend tokens thinking before answering — give them room. -- **seed**: Sent to the server, and either way a changed seed re-runs the - node instead of replaying the cached reply. This is the re-roll knob. -- **timeout_seconds**: How long to wait. Cold-loading a big model or a long - think can need minutes. -- **image_max_edge**: Downscale a connected image so its longest edge fits - this before sending (`0` = full size). Vision models resize internally - anyway, and a smaller upload prefills much faster. -- **image** (optional): For vision models. Only the **first frame** of a - batch is sent — pick one with Select Frame 🆎 for video. A - text-only model will refuse an image server-side. -- **unload_llm**: Unload the language model right after the reply (LM - Studio JIT TTL) so its VRAM is free for the diffusion models and text - encoders that run next. Overrides the gear menu's idle-unload timer. -- **history** (optional): Prior turns from another LM Studio Chat 🆎 - node's `history` output; they replay before this prompt so the model - remembers the conversation. Images are not carried between turns. -- **json_schema** (optional): Paste a JSON Schema to force the reply into - that exact structure (LM Studio structured output). `{"type": "object"}` - allows any JSON; empty replies as plain text. - -## The gear menu - -The gear on the endpoint toolbar holds the advanced settings. Each maps to -a hidden widget of the same name, so values save with the workflow and -reach the API like any widget; the gear also stores them as the defaults -new nodes start from. Every sampler is an override: at its neutral value -nothing is sent and the server's own setting stands. - -- **Sampling**: `top_p`, `top_k`, `min_p`, `repeat_penalty`, - `presence_penalty`. -- **Thinking**: `thinking_mode` forces a hybrid reasoning model to think - or answer directly (Qwen-style templates honor it; others ignore it); - `reasoning_open_tag` / `reasoning_close_tag` name the tags whose - contents move to the `thinking` output (default ``…``). -- **Memory**: `idle_unload_seconds` is LM Studio's JIT TTL — unload the - model after idling this long (`0` keeps it loaded; the node's - `unload_llm` switch overrides it with 1 s). `free_comfy_vram` unloads - ComfyUI's cached models before the request, so a big LLM fits alongside - a big diffusion model on one GPU. - -## Outputs - -- **text**: The reply with any `` blocks removed — safe to wire - straight into a text encoder. -- **thinking**: What a reasoning model thought before answering; empty for - ordinary models. Handy in a preview node while tuning prompts. -- **history**: The conversation including this exchange — wire into another - chat node's `history` input to continue the thread. - -## Behavior - -- Runs off the executor thread, so the UI stays responsive during a long - generation; the timeout is the backstop. -- Caches like any node: identical inputs replay the saved reply instantly. - Change the seed to force a fresh generation. -- Errors are actionable: a refused connection says how to start the server, - an HTTP error carries the server's own message (e.g. *"Model not - found"*), and a non-JSON reply points at the endpoint setting. - -Nothing is sent anywhere except the endpoint you configured. diff --git a/js/docs/AUSBOSS_NODES_LoadImagePad.md b/js/docs/AUSBOSS_NODES_LoadImagePad.md index 7bfaef8..27796e6 100644 --- a/js/docs/AUSBOSS_NODES_LoadImagePad.md +++ b/js/docs/AUSBOSS_NODES_LoadImagePad.md @@ -1,5 +1,13 @@ # Load Image + Pad +The widget card holds Source and Upload, fill mode, color or backdrop blur, +Feather, Multiple, and Budget. **Exact padding** opens a separate scrub row +for each side. Every row can take a link; linked controls dim, and their +values and links save with the workflow. The canvas remains available below +the card for dragging the borders. +Linked sides are controlled upstream; dragging their borders leaves the saved +padding value unchanged. + Loads an image and builds an outpaint canvas around it in one node. The canvas drawn on the node is the control: **drag any edge of the dashed final rect** to grow that side's padding — the whole edge is the handle, diff --git a/js/docs/AUSBOSS_NODES_LoraLoader.md b/js/docs/AUSBOSS_NODES_LoraLoader.md index 71ac2d3..ad4d0a7 100644 --- a/js/docs/AUSBOSS_NODES_LoraLoader.md +++ b/js/docs/AUSBOSS_NODES_LoraLoader.md @@ -36,9 +36,12 @@ Every named row paints a center-zero bar behind its name: teal grows right of center for positive model strength, muted red grows left for negative, a 1px tick marks zero and a brighter 2px cap marks the value's edge. One shared scale across the stack: the field edges represent the largest -absolute strength among the rows, floored at 1.0 — so an everyday stack of -0.5s reads absolutely as half-bars, and one row at 2.0 rescales every bar -rather than clipping. The scale in play is named in each row's tooltip. +absolute strength among the **enabled** rows, floored at 1.0 — so an +everyday stack of 0.5s reads absolutely as half-bars, one row at 2.0 +rescales every bar rather than clipping, and switching that row off hands +the scale back to the strongest row still in play (the dimmed row's own +bar clamps at the edge meanwhile). The scale in play is named in each +row's tooltip. Gear menu → "Strength bars" turns them off; "Scrub strength on the name" turns the name-drag off. @@ -99,9 +102,18 @@ data. The run side matches: a stale saved path resolves by name at load time (exact → unique case-insensitive path → unique basename, with a one-line -console note when it remaps), and a LoRA that is genuinely missing **warns -once and skips its row** instead of failing the whole run. Validation -never blocks the queue over a missing file — only over a malformed stack. +console note when it remaps). A LoRA that is genuinely missing **stops the +run before anything is sampled**, and the error names the file — a picture +that rendered without its LoRA is worse than no picture, above all for a +script or an agent that would happily judge it. The error also says where +the switch is. + +Gear menu → **Stop on missing LoRA** (on by default) turns that off for this +node: a missing row then warns once in the console and is skipped while the +rest of the stack applies, which suits a person at the canvas who wants to +see the rest of the picture. In API graphs the same switch is the +`on_missing` input (`"error"`, the default, or `"skip"`); graphs saved before +the input existed get the default. ## Preview thumbnails diff --git a/js/docs/AUSBOSS_NODES_MathExpression.md b/js/docs/AUSBOSS_NODES_MathExpression.md index 91ee9a5..402bb06 100644 --- a/js/docs/AUSBOSS_NODES_MathExpression.md +++ b/js/docs/AUSBOSS_NODES_MathExpression.md @@ -11,9 +11,10 @@ strength ramps, frame counts. the functions `min`, `max`, `abs`, `round`, `floor`, `ceil`, `sqrt`. Examples: `a * 2`, `floor(a / 64) * 64`, `min(a, b) + 0.5`, `sqrt(a*a + b*b)`. -- **a**, **b**, **c**: The values the expression reads. Type them or convert - them to inputs and wire numbers in; an unused one costs nothing and an - unwired one reads as its widget value. +- **a**, **b**, **c**: The values the expression reads, as three sockets on + the node's left edge that take a `FLOAT` or an `INT`. An unwired one reads + as `0`, and an unused one costs nothing; a constant belongs in the + expression itself (`a * 2`, `floor(a / 64) * 64`). ## Outputs diff --git a/js/docs/AUSBOSS_NODES_RefineMask.md b/js/docs/AUSBOSS_NODES_RefineMask.md index 43fb8ff..06ad828 100644 --- a/js/docs/AUSBOSS_NODES_RefineMask.md +++ b/js/docs/AUSBOSS_NODES_RefineMask.md @@ -7,7 +7,10 @@ refined mask and its inverse are returned, so no separate invert node is needed. The panel shows the refined mask itself once the node has run, so the effect -of a setting is visible without wiring a preview node beside it. +of a setting is visible without wiring a preview node beside it. The small +**preview** switch at the right of the bar above the picture turns it off: +the picture's box disappears, the node is that much shorter, and no preview +file is written. The switch is the node's optional `preview` input. ## The panel buttons diff --git a/js/docs/AUSBOSS_NODES_Resolution.md b/js/docs/AUSBOSS_NODES_Resolution.md new file mode 100644 index 0000000..ec93c8c --- /dev/null +++ b/js/docs/AUSBOSS_NODES_Resolution.md @@ -0,0 +1,22 @@ +# Resolution Master 🆎 + +Choose the width and height of a canvas visually, then wire those integers or the empty latent into your workflow. + +- **Shape:** the left button selects landscape or portrait. Ratio chips follow that choice; square retains it for the next selection. Clicking a chip uses its generated size near one megapixel. Enable **Chips keep current budget** in the gear to retain the current area. +- **Canvas:** drag a side to change one dimension; drag the corner to scale both. Shift frees the corner ratio, Alt removes snapping, and Escape cancels the drag. The bright readout shows dimensions and megapixels. Arrows nudge the focused canvas. +- **Size & budget:** scrub or type W, H, or MP. Typed dimensions are preserved exactly. The swap button exchanges dimensions. Size chips offer larger and smaller versions of the current ratio. +- **Gear:** choose snapping, edit the ratio list, configure the latent layout and batch size, and hide the grid or MP curves. + +## Outputs and links + +Outputs are **width**, **height**, and **latent**, in that order. The width and height inputs have separate sockets above the panel. Linking either locks the controls that would change both dimensions; the other dimension remains editable. Linked values are resolved during execution; the preview shows the locally stored dimensions. + +The latent layout is **16ch / 8× downsample**, **4ch / 8×**, or **128ch / 16×**. Select the layout required by your model. This is an empty *image* latent; video models should take the width and height outputs and construct their own audio/video latent. + +Typed sizes need not be divisible by the latent downsample factor. The integer outputs retain the typed size, while the latent spatial dimensions round down to complete cells, matching the core empty-latent nodes. Use aligned dimensions when wiring the latent directly. Large dimensions and batches allocate correspondingly large tensors. + +## Limits + +Dimensions range from 64 to 8192 pixels. Extreme custom ratios may not have an exact solution inside that range at the chosen snap; they use a bounded approximation. Fine custom ratios can likewise only approximate the ratio during a snapped drag. The panel stays compact at a fixed width and height; longer custom ratio lists scroll within the shape area. + +All execution inputs are ordinary widgets, so API runs do not depend on this panel. Orientation and snap preferences are saved with the node. This node has no model downloads, network requests, or additional pip dependencies. diff --git a/js/docs/AUSBOSS_NODES_RunTimer.md b/js/docs/AUSBOSS_NODES_RunTimer.md new file mode 100644 index 0000000..e6d40f5 --- /dev/null +++ b/js/docs/AUSBOSS_NODES_RunTimer.md @@ -0,0 +1,28 @@ +# Run Timer + +A stopwatch for the whole run, drawn as one black readout with no title bar +and no wires: it listens to the queue. + +- The clock starts when a queued prompt begins executing and ticks while it + runs — amber dot. +- When the run finishes the total holds — teal dot. A failed or interrupted + run shows its time in red and is not kept as a reference. +- The last completed run is saved with the workflow, so a shared graph + reopens showing its author's time; hover the box for the few before it. + Right-click → *Reset Run Timer history* clears them. + +The node is exactly the box, painted by the node itself on the classic +canvas: drag it from anywhere, resize it by the corner (the digits scale +with it), and it wears no title bar and no pack badge. Right-click lists the +last runs. + +## Notes and limitations + +- It measures the *queue's* execution, from `execution_start` to the run's + end, on the browser's clock. Model loading counts; time spent waiting in + the queue behind another prompt does not. +- Several Run Timer nodes on one canvas all show the same run. +- Per-node times are a separate feature: **Settings → 🆎 AusBoss → Chrome → + Node runtime badges** stamps every node with its own seconds. +- Want a sound when it stops? **Settings → 🆎 AusBoss → Notifications → + Completion sound** chimes when the queue empties. diff --git a/js/docs/AUSBOSS_NODES_SaveImage.md b/js/docs/AUSBOSS_NODES_SaveImage.md index da11dcd..80f4485 100644 --- a/js/docs/AUSBOSS_NODES_SaveImage.md +++ b/js/docs/AUSBOSS_NODES_SaveImage.md @@ -1,40 +1,53 @@ # Save Image -Saves an `IMAGE` batch as **PNG** or **lossless JPEG XL**, with the workflow -embedded or deliberately left out — and, when you need it, under an **exact -filename with no counter suffix**, so a caption or edit pass can keep the -source file's name. +Saves an `IMAGE` batch as **PNG**, **lossless WebP** or **lossless JPEG XL**, +with the workflow embedded or deliberately left out. The name is either +composed on the node from a prefix plus the tags you switch on, or supplied +exactly by the `filename` input, so a caption or edit pass keeps the source +file's name. A caption on the `caption_text` input writes the paired `.txt`. -## Two naming modes +## The card -- **Classic** (default): `filename_prefix` plus a counter — - `AusBoss/image_00001_.png` — in ComfyUI's output folder. Never overwrites, - subfolders in the prefix work as usual. -- **Exact**: set `exact_name` and the file saves as exactly that name. An - image extension on the value is replaced by the chosen format's own, so - `photo123.jpg` saves as `photo123.png` and pairs with `photo123.txt`. - Subfolders are allowed (`set1/photo123`); rooted paths and `..` are - rejected. A batch cannot share one name, so its frames become - `name_001`, `name_002`, … +- **Folder**: empty saves to ComfyUI's output folder; a relative path is a + subfolder of it; an absolute path saves anywhere you can write. **Browse** + walks the subfolders of the output folder. +- **Filename**: the local name, subfolders allowed (`sets/shot`). While the + `filename` input is linked the field reads `{{filename}}` and the tags fold + away - an exact name is never decorated. A workflow saved with the old + `exact_name` shows that name tagged *exact*; clear it to compose locally. +- **Path preview**: the name this save will produce, in the order the tags + are appended: `prefix[_YYYY-MM-DD][_HH-mm-ss][_WIDTHxHEIGHT][_#####][_b###]`. + After a run it shows the first file actually written. +- **Tags**: **Counter** (on by default) appends the next free five-digit + number for that stem in that folder, so a save never collides. **Date** + and **Time** stamp the local clock. **Size** appends `1024x1536`. + **Batch #** appends `b001, b002...` across an image batch, which then + shares one counter; without it each frame takes the next number. With the + counter off a single image reuses the same path and replaces the file - the + dataset replacement case - and a batch still numbers its frames. +- **Format**: PNG saves everywhere and keeps alpha. WebP lossless is usually + smaller than PNG, keeps alpha, and carries the workflow in EXIF. JPEG XL + lossless is the most compact; it needs the optional `pillow-jxl-plugin` in + ComfyUI's python (also the pack's `jxl` extra) and few browsers preview it. +- **Embed workflow**: on stores the prompt and workflow in the file (PNG text + chunks, EXIF in webp and jxl) so it drags back into ComfyUI; off writes a + clean file for sharing or datasets. `--disable-metadata` on the server wins. -## Controls +## Inputs -- **format**: `png` everywhere; `jxl lossless` keeps every pixel - bit-identical in a smaller file. JPEG XL needs the optional - `pillow-jxl-plugin` in ComfyUI's python (`pip install pillow-jxl-plugin`, - also listed as the pack's `jxl` extra) and the node says so when missing. -- **save_metadata**: on embeds the prompt and workflow (PNG text chunks, - EXIF in jxl) so the file drags back into ComfyUI; off writes a clean file - for sharing without shipping the recipe. -- **on_existing** (exact mode): `overwrite` replaces the file — the usual - reason to want an exact name — `skip` leaves it, `error` stops the run. -- **output_dir**: empty saves to ComfyUI's output folder; a relative path is - a subfolder of it; an absolute path saves anywhere you can write, e.g. - straight into a dataset folder. The on-node preview only appears for - files inside the output folder — outside saves are reported by path. -- **caption**: when not empty, writes the text as a UTF-8 `.txt` sidecar - with the same basename as each saved image — the image/caption pair - training tools expect. +- **filename** (STRING, link only): the exact name from upstream - a Load + Image name, a caption tool's id. Its image extension is swapped for the + chosen format's (`photo123.jpg` -> `photo123.png`); a batch appends + `_001`, `_002`... A linked but empty value stops the run instead of saving + under a made-up name. +- **caption_text** (STRING, link only): saved as a UTF-8 `.txt` beside every + image with the same name (`portrait.png` -> `portrait.txt`). Empty writes + no sidecar. The old `caption` box still works underneath; a linked caption + wins. + +Rooted paths and `..` in names are rejected before the run. `on_existing` +from older workflows is still honored wherever a save can land on an +existing file (counter off, exact or linked names). ## Outputs diff --git a/js/docs/AUSBOSS_NODES_Seed.md b/js/docs/AUSBOSS_NODES_Seed.md new file mode 100644 index 0000000..e2b15fb --- /dev/null +++ b/js/docs/AUSBOSS_NODES_Seed.md @@ -0,0 +1,39 @@ +# Seed + +One seed on its own `INT` wire, so every sampler that should share it does, +and a shared workflow can pin the exact seed that made its example image. +The card is the whole face: the number, the mode, and the buttons a run +needs. + +## The card + +- **The number** — click to type a seed; **⧉** copies it. In Random mode + the tag reads *next*: that is the seed the next queue will use. +- **Random / Fixed / Step** — what happens after each queue. Random rolls + a new seed, Fixed keeps it, Step increments it (click Step again to + switch to decrement). This is the sampler's own *control after generate*, + just readable. +- **New seed** — rolls a fresh seed and pins it (Fixed), for when a random + run landed somewhere good and you want to stay near it. +- **Use last run** — puts the seed of the most recent run back in the box + and pins it. Random mode replaces the seed the moment a run is queued, so + the number in the box is already the *next* one while the image is still + rendering; this button is how you get the one that made it. +- **▾** — the last eight seeds this node ran with, newest first; pick any to + restore it the same way. +- The line underneath shows the last run's seed. The history saves with the + workflow. + +## Output + +- **seed**: The seed, unchanged. + +## Notes and limitations + +- The last-run seed comes from the backend's own run, so it is right even + when the control mode is *before* rather than *after* generate, and even + when the node's output came from the cache. +- Sampler seeds go up to 2⁶⁴−1; the box accepts that range. Seeds rolled by + the buttons stay below 2⁵⁰, the same ceiling ComfyUI's own randomize + uses, so the number stays exact. +- Convert **seed** to an input and the box goes read-only: the wire owns it. diff --git a/js/docs/AUSBOSS_NODES_SelectFrame.md b/js/docs/AUSBOSS_NODES_SelectFrame.md index 1dc4061..c9b6230 100644 --- a/js/docs/AUSBOSS_NODES_SelectFrame.md +++ b/js/docs/AUSBOSS_NODES_SelectFrame.md @@ -27,3 +27,7 @@ only exist on the server. The node performs no network requests. It writes one preview PNG per run into ComfyUI's temp folder, the same place `PreviewImage` writes, which is cleared with the rest of the session's scratch files. + +The small **preview** switch at the right of the bar above the picture turns +it off: the picture's box disappears, the node is that much shorter, and no +preview file is written. The switch is the node's optional `preview` input. diff --git a/js/docs/AUSBOSS_NODES_StitchInpaint.md b/js/docs/AUSBOSS_NODES_StitchInpaint.md index a0124a8..1ebe644 100644 --- a/js/docs/AUSBOSS_NODES_StitchInpaint.md +++ b/js/docs/AUSBOSS_NODES_StitchInpaint.md @@ -13,6 +13,11 @@ stitcher, and the original frame is sliced back out. never how far the paste reaches. - Feeding the crop back unchanged reproduces the original image exactly (with `fix_edge_halo` off; the fix rewrites the feathered band on purpose). +- A stitcher built from one image broadcasts over any number of inpainted + frames. A stitcher built from **more** frames than came back is trimmed to + the leading ones with a console note - video models keep 8n+1 (LTX) or + 4n+1 (Wan) frames and drop the tail - so a long run is never thrown away + over the count. More inpainted frames than source frames is still an error. ## Controls @@ -25,6 +30,40 @@ stitcher, and the original frame is sliced back out. feathered seam before pasting, so half-transparent edge pixels stop blending their background in a second time. It costs real time per frame — read "What it costs" below before turning it on for a whole batch. +- **color_match**: `0` (off) to `1`. Shifts the pasted region's tone onto + the original's before blending. For an outpaint the shift is read across + each seam — the model's new pixels just outside the source against the + true pixels just inside it — so it measures the model's own drift, not + the mixed band. Read "Matching the tone" below. + +## Matching the tone + +An outpaint comes back a few percent off the picture it extends — Krea 2 +a touch darker, Klein a touch lighter — and the padding colour makes no +difference to that. The feathered sampler mask then smears the step into +the band inside the source, so without a match the seam shows as a faded +band even though the content continues perfectly. + +`color_match` reads the drift **across each padded side's seam, line by +line**: for a left or right side, every row compares the 24 generated +pixels just outside the seam with the 24 original pixels just inside it; +a top or bottom side does the same per column. Sky and water on the same +seam drift by different amounts, and one number for the whole picture +leaves one of them showing. Each line's reading is clamped to a few LAB +units around its side's average — a pier post leaving the frame is +content, not drift — and smoothed so no single line prints a stripe. +Where two padded sides meet, their curves blend by distance, so the +correction turns the corner without a crease. + +The shift is subtracted from the pasted region before the blend. In the +padding that is the whole shift; inside the feathered band the paste +alpha scales it, which is exactly the share of each band pixel the +sampler painted. Source pixels stay bit-identical. A crop stitcher, which +has no source rectangle, gets a single global shift measured in its +blend band instead. + +It corrects tone, not content — a dark wall at the picture's edge next to +a lit continuation is the model's choice, not a colour drift. ## Fixing an edge halo diff --git a/js/docs/AUSBOSS_NODES_Text.md b/js/docs/AUSBOSS_NODES_Text.md index 70706cc..a287d0c 100644 --- a/js/docs/AUSBOSS_NODES_Text.md +++ b/js/docs/AUSBOSS_NODES_Text.md @@ -1,6 +1,6 @@ # Text -A multiline text box on its own `STRING` wire. Type a prompt, a caption, or +A growing multiline widget card on its own `STRING` wire. Type a prompt, a caption, or any other text once and feed it to every node that needs it — changing it in one place updates them all. diff --git a/js/docs/AUSBOSS_NODES_VideoCropRotatePad.md b/js/docs/AUSBOSS_NODES_VideoCropRotatePad.md index 086c002..ff72dae 100644 --- a/js/docs/AUSBOSS_NODES_VideoCropRotatePad.md +++ b/js/docs/AUSBOSS_NODES_VideoCropRotatePad.md @@ -7,7 +7,8 @@ Loads one exact video frame and applies the same **rotate → crop → pad** tra ## Source and frame -- **video**: Video inside ComfyUI's input folder. Use **Upload video** on the node to add one. +- **Uploads**: Pick an input video, click **Upload** in the compact source card, or + drop a video file onto the node. - **source_mode**: `input folder` or `local path`. Local path mode avoids copying large files. - **local_path**: Absolute path used only in local path mode. Queued workflows always read it; the editor's live preview of paths outside ComfyUI's own folders requires @@ -21,5 +22,13 @@ The editor includes first/last, ±1, ±25, ±50, ±100, play/pause, keyboard arr Rotation, crop, padding, feathering, fill, canvas multiple, gestures, `image`, and `mask` match the image node. The output batch contains one frame. -Video metadata and preview routes validate extensions and return only the information needed by the editor. The node performs no remote network requests and does not rewrite the source video. +The crop, padding and rotation handles work on the node preview as well as in the +editor, and the format chips right under the preview (16:9, 9:16, 1:1, 4:3 ...) pad +the whole frame to that aspect with centered fill bands in one tap; tap the lit chip +to clear the bands again. Choose a **Target aspect**, then **Crop to aspect** to trim a centered crop, +or **Pad to aspect** to preserve the full source and add centered fill-color bands. +Both actions replace existing crop/padding and retain rotation. The target selection +alone does not alter the transform. Padding unlocks the inner crop so it keeps every +source pixel; canvas/resize rounding may slightly change the final aspect. +Video metadata and preview routes validate extensions and return only the information needed by the editor. The node performs no remote network requests and does not rewrite the source video. diff --git a/js/docs/AUSBOSS_NODES_VideoCropRotatePadClip.md b/js/docs/AUSBOSS_NODES_VideoCropRotatePadClip.md new file mode 100644 index 0000000..33f90af --- /dev/null +++ b/js/docs/AUSBOSS_NODES_VideoCropRotatePadClip.md @@ -0,0 +1,78 @@ +# Video Crop + Rotate + Pad → Clip + +**Outputs the whole clip.** One **rotate → crop → pad** transform is applied to +every frame of the trim window; the editor's timeline only picks the frame you +adjust it on. + +Use it to cut a border, a logo corner, or a tilt out of a video, or to grow +fill-color bands around it for a **video outpaint**: the `mask` output marks the +bands (and rotation corners), ready for Crop For Inpaint or an in-context video +model that paints black regions. + +## Source and window + +- **Uploads**: Pick an existing input video, click **Upload**, or drop a video file + onto the node. The old source widgets remain compatible with saved workflows, but + are now driven by this compact card. +- **Local path**: Read a file on the ComfyUI server in place without copying it. + Queued workflows always read it; the editor's live preview of paths outside ComfyUI's + own folders requires starting ComfyUI with `AUSBOSS_TRANSFORM_LOCAL_PREVIEW=1`. +- **IN / OUT**: Drag the trim handles on the node or in the editor, or scrub/type exact + seconds below them. **Full clip** resets the window. These drive `start_seconds` / + `end_seconds`; an end of 0 means the end of the source. While a handle moves, the + stage peeks at the frame under it and returns to the playhead on release; the scrub + bar and the saved preview position never move with a trim. +- **every_nth / max_frames**: Thin the batch or cap it. The `fps` output divides to match every_nth, so the clip keeps real-time downstream. +- **Snap** (`frame_snap`): Drop trailing frames so the count is one a video model keeps: + **8n+1** for LTX (49, 97, 121), **4n+1** for Wan. Free keeps every frame in the window. + With it on, `frame_count`, `duration`, the audio window and the stitcher all match the + clip the sampler hands back, so wiring `frame_count` into the empty latent's length + never leaves the stitch with more source frames than generated ones. +- **frame_index / frame_time**: The editor's separate preview position, not trim points. + Preview scrubbing does not invalidate the queued clip; the position is still saved in the workflow. + +Crop squares, padding diamonds and the rotation handle also work directly on the +node preview. The graph still owns wheel zoom and middle-button pan. The format chips +right under the preview (16:9, 9:16, 1:1, 4:3 ...) pad the whole clip to that aspect +with centered fill bands in one tap - the editor's **Pad to aspect** without opening +it; tap the lit chip to clear the bands again. + +Choose **Target aspect**, then **Crop to aspect** to center the largest crop inside +the rotated source, or **Pad to aspect** to keep the entire source and add centered +fill-color bands. Both replace the old crop and padding, keeping rotation and resize +settings. Changing the target alone does nothing. Padding unlocks the inner crop +(`crop_aspect_ratio = free`); the target choice is retained separately in the editor. +Pixel rounding can differ by one pixel between opposite bands. Canvas multiple and +resize steps can slightly change the final aspect ratio. + +## Transform and outputs + +Rotation, crop, padding, feathering, fill, canvas multiple, and the editor's handles +match the image node. **Resize output** in the editor scales the finished frames to a +megapixel budget with a resolution step (32 keeps LTX and Wan sizes), the same trio as +the core Scale Image to Total Pixels node. + +Outputs: `frames` (BHWC), `mask` (BHW, one per frame), `audio` for the same window +(silent when the source has none), `frame_count`, `fps`, `width`, `height`, `duration`, +and a `stitcher`. + +## Inpaint & Stitch + +The `stitcher` output goes straight into **Stitch Inpaint 🆎**: after a video model has +painted the padded bands (or rotation corners), Stitch puts the source frames back +bit-for-bit and takes the generation only inside the generated area, so no separate +Crop For Inpaint node is needed. The editor's right sidebar holds the settings: + +- **Blend**: the ramp, in output pixels, where generated pixels fade over the source. + It is separate from the padding **Feather**, which shapes the mask the model sees - + a black-band outpaint wants feather 0 and a blend of a few dozen pixels. +- **Show blend** tints the stage with the paste mask itself: the generated area (padding, rotation corners), the transform feather, then grow and blend applied in output pixels through any resize - the backend's mask math run at preview resolution. +- **Advanced → Grow paste** moves the paste boundary first: a few positive pixels let + the generation repaint the source edge when a seam still shows. + +The stitcher is built from the final resized frames, so it lines up with what the +sampler returns at this node's `width` × `height`. +Frames are transformed a few at a time so long clips do not double in memory, and the +queue's progress bar and cancel work throughout. + +The node performs no remote network requests and does not rewrite the source video. diff --git a/js/docs/AUSBOSS_NODES_WorkflowNote.md b/js/docs/AUSBOSS_NODES_WorkflowNote.md new file mode 100644 index 0000000..dc59934 --- /dev/null +++ b/js/docs/AUSBOSS_NODES_WorkflowNote.md @@ -0,0 +1,70 @@ +# Workflow Note + +The card that makes a shared workflow self-explanatory: a big title, a +Markdown how-to, the models it needs with **Download** buttons and the folder +each one goes in, the node packs it depends on, and the author's links. Every +model file and node pack on the card is checked against **this** install and +marked installed or missing, so whoever opens the workflow sees exactly what +is left to set up before the first run. + +Nothing executes. The node has no outputs and is never part of the prompt. + +## Editing + +- Click the **✎** in the card's corner (or right-click the node → *Edit + Workflow Note*). The dialog has a form; **JSON** switches to the raw card + text for copy/paste between workflows. `Ctrl+Enter` saves, `Esc` cancels. +- **Title / Subtitle / Author** make the banner. **Layout → Banner** shows + just the title, so the same node doubles as a label for a stage or column. +- **Accent color** tints the banner bar, dots and buttons; empty keeps the + pack teal. +- **How-to** is Markdown-lite: `#`/`##`/`###` headings, `**bold**`, + `*italic*`, `` `code` ``, `[links](https://…)`, `-` and `1.` lists, `---` + rules, fenced code. Only `http(s)` links become clickable. + +## Models + +Each row is a file, the model folder it belongs in, an optional size, and a +download URL. Rows group under a `📂 ComfyUI/models/` heading so the +card reads like the install tree — the "where do I put this" question is +answered on the same line as the button. + +- The dot beside a row is the check: **teal** found, **red** missing, **grey** + not checked (no folder set, or this install has no such folder). +- A found file swaps its Download button for an **✓ installed** pill; hovering + the pill shows where it was found. A file that lives in a subfolder still + counts, because the loaders find it there too. +- **↻ check files** re-runs the check after you move files in. ComfyUI's own + **R** refresh triggers it as well. +- Folder `input` is for a sample image or clip shipped with the workflow: it + is looked for in `ComfyUI/input`. +- Clicking a file name copies it — handy for searching a loader's picker. + +The Download button is a plain link to the file's page or direct URL in a new +tab; the browser saves it wherever downloads go, and the folder line says +where to move it. + +## Node packs + +A row names a pack, links to it, and names one **probe node class** from that +pack (any node it registers, e.g. `AUSBOSS_NODES_LoraLoader`). If that class +is registered on this install the pack shows **✓ installed**; otherwise it is +marked missing with the link to get it. **Detect from this workflow** in the +editor lists every custom pack the open graph uses, so the list is never out +of date with the graph. + +## Links + +Chips for your pages — GitHub, Civitai, YouTube, Discord — whatever you want +the person who downloaded the workflow to find. + +## Notes and limitations + +- The card is stored as JSON in the node's one `note` widget, so save/load, + undo, copy/paste and the API format all carry it. A card pasted from + another workflow arrives intact. +- Every string on a downloaded card is someone else's text: it is rendered as + text, never as HTML, and only `http(s)` URLs become buttons or links. +- The file check is a lookup against ComfyUI's model lists, not a hash: a + same-named file of a different version passes. +- Sizes are what the author typed; the node does not fetch them. diff --git a/js/image_crop_rotate_pad/index.js b/js/image_crop_rotate_pad/index.js index 0ecbe1d..2146755 100644 --- a/js/image_crop_rotate_pad/index.js +++ b/js/image_crop_rotate_pad/index.js @@ -4,9 +4,9 @@ import { fillNodeHeight } from "../shared/panel_layout.mjs"; import { stageHeightForWidth } from "../shared/transform_geometry.mjs"; const PANEL_MIN_WIDTH = 330; -// Two action rows (open editor + the reset/feather/resize quick row), -// column gaps, and panel padding around the stage canvas. -const PANEL_CHROME = 92; +// Source card, the format chips under the stage, two action rows (open +// editor and reset/feather/resize), column gaps, and panel padding. +const PANEL_CHROME = 210; const PANEL_MIN_HEIGHT = stageHeightForWidth(0) + PANEL_CHROME; // Guard rule the DOM-panel audit (tests/panel_guards.test.mjs) checks next @@ -33,7 +33,7 @@ function mountTransformPanel(node, panel) { fillNodeHeight(widget, { minWidth: PANEL_MIN_WIDTH, minHeight: PANEL_MIN_HEIGHT, - minNodeSize: [PANEL_MIN_WIDTH, 320], + minNodeSize: [PANEL_MIN_WIDTH, 470], }); return widget; } diff --git a/js/input_preview/index.js b/js/input_preview/index.js index c2de7a8..d50f2c2 100644 --- a/js/input_preview/index.js +++ b/js/input_preview/index.js @@ -11,11 +11,11 @@ import { api } from "/scripts/api.js"; import { app } from "/scripts/app.js"; -import { BRAND, chainCallback, keepDomWidgetWidthAuto } from "../shared/index.mjs"; -import { fillNodeHeight } from "../shared/panel_layout.mjs"; +import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs"; +import { ensureNodeMinHeight, fillNodeHeight } from "../shared/panel_layout.mjs"; import { suppressCoreImagePreview } from "../shared/core_preview.mjs"; import { autoMaskValues } from "../shared/mask_auto.mjs"; -import { setWidgetVisible } from "../shared/widget_visibility.mjs"; +import { hideInputsInDef, hideWidget, setWidgetVisible } from "../shared/widget_visibility.mjs"; import { describeNodePreview, placeholderText, @@ -25,10 +25,16 @@ import { const CSS_ID = "ausboss-input-preview-css"; const WIDGET_NAME = "ausboss_input_preview"; -// The panel's floor, and the node's: narrower than this and the AUTO/MORE -// buttons have nowhere to sit. -const PANEL_HEIGHT = 140; +// The stage's floor, and the node's: narrower than this and the AUTO +// button and the switch have nowhere to sit. +const STAGE_HEIGHT = 140; const PANEL_MIN_WIDTH = 200; +// The bar above the stage: the node's tools on the left, the preview +// switch on the right. It is the whole panel while the preview is off, so +// the switch is always in the same place and the stage simply goes away. +const BAR_HEIGHT = 20; +const PANEL_HEIGHT = STAGE_HEIGHT + BAR_HEIGHT + 4 + 16; +const OFF_HEIGHT = BAR_HEIGHT + 16; const INPUT_SIDE = 1; // LiteGraph.INPUT // Mask Refine opens on expand and blur alone. The other five are real @@ -64,18 +70,25 @@ function ensureCss() { const style = document.createElement("style"); style.id = CSS_ID; style.textContent = ` -.ausboss-input-preview{box-sizing:border-box;width:100%;height:100%;padding:2px 6px 6px;pointer-events:none;overflow:hidden;} -.ausboss-input-preview-stage{position:relative;display:flex;align-items:center;justify-content:center;width:100%;height:100%;overflow:hidden;border:1px solid rgba(0,180,170,.27);border-radius:6px;background:rgba(0,0,0,.28);} +.ausboss-input-preview{box-sizing:border-box;display:flex;flex-direction:column;gap:4px;width:100%;height:100%;padding:0 6px 6px;pointer-events:none;overflow:hidden;} +.ausboss-input-preview-bar{box-sizing:border-box;flex:none;display:flex;align-items:center;gap:6px;height:${BAR_HEIGHT}px;padding:0 2px;pointer-events:none;} +.ausboss-input-preview-stage{position:relative;flex:1 1 auto;min-height:0;display:flex;align-items:center;justify-content:center;width:100%;overflow:hidden;border:1px solid rgba(0,180,170,.27);border-radius:6px;background:rgba(0,0,0,.28);} +.ausboss-input-preview.preview-off .ausboss-input-preview-stage{display:none;} .ausboss-input-preview-stage img,.ausboss-input-preview-stage video{display:none;max-width:100%;max-height:100%;object-fit:contain;} .ausboss-input-preview-stage.show-image img{display:block;} .ausboss-input-preview-stage.show-video video{display:block;} .ausboss-input-preview-hint{display:none;max-width:86%;color:#78908e;font:11px/1.35 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;text-align:center;} .ausboss-input-preview-stage:not(.show-image):not(.show-video) .ausboss-input-preview-hint{display:block;} -.ausboss-input-preview-tools{position:absolute;right:5px;top:5px;z-index:4;display:flex;gap:4px;pointer-events:auto;opacity:.86;} -.ausboss-input-preview-tools:hover{opacity:1;} -.ausboss-input-preview-tool{box-sizing:border-box;height:20px;min-width:24px;padding:0 6px;border:1px solid rgba(0,180,170,.52);border-radius:4px;background:rgba(0,0,0,.7);color:#c8dddd;font:700 9px/18px "Segoe UI",sans-serif;cursor:pointer;} +.ausboss-input-preview-tools{display:flex;gap:4px;pointer-events:auto;} +.ausboss-input-preview-tool{box-sizing:border-box;height:${BAR_HEIGHT}px;min-width:24px;padding:0 6px;border:1px solid rgba(0,180,170,.52);border-radius:4px;background:rgba(0,0,0,.7);color:#c8dddd;font:700 9px/18px "Segoe UI",sans-serif;cursor:pointer;} .ausboss-input-preview-tool:hover{border-color:${BRAND};color:#fff;background:rgba(0,79,75,.78);} .ausboss-input-preview-tool.active{border-color:${BRAND};color:${BRAND};} +.ausboss-input-preview-toggle{display:flex;align-items:center;gap:6px;margin-left:auto;pointer-events:auto;cursor:pointer;user-select:none;} +.ausboss-input-preview-toggle span{color:#5f7674;font:10px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;letter-spacing:.08em;text-transform:uppercase;} +.ausboss-input-preview-switch{box-sizing:border-box;width:24px;height:13px;border-radius:7px;border:none;background:#3a4047;position:relative;cursor:pointer;padding:0;flex:none;pointer-events:auto} +.ausboss-input-preview-switch::after{content:"";position:absolute;top:2px;left:2px;width:9px;height:9px;border-radius:50%;background:#9ba2aa;transition:left .12s} +.ausboss-input-preview-switch.on{background:${BRAND}} +.ausboss-input-preview-switch.on::after{left:13px;background:#fff} .ausboss-input-preview-toast{position:absolute;left:50%;bottom:6px;z-index:5;max-width:88%;padding:3px 7px;border-radius:4px;background:rgba(0,0,0,.78);color:#b8d3d1;font:10px/1.3 "Segoe UI",sans-serif;text-align:center;transform:translateX(-50%);pointer-events:none;} `; document.head.appendChild(style); @@ -147,11 +160,21 @@ function syncAdvanced(state) { if (!names?.length) return; const shown = advancedShown(state.node); let changed = false; - for (const name of names) { - const widget = widgetByName(state.node, name); - if (widget && setWidgetVisible(widget, shown)) changed = true; + const card = state.node.__ausbossCard; + if (card) { + // The widget card owns these rows now (js/widget_cards); it reads the + // same node property, so the button only has to ask it to re-read. + card.refresh?.(); + } else { + for (const name of names) { + const widget = widgetByName(state.node, name); + if (widget && setWidgetVisible(widget, shown)) changed = true; + } } const button = state.toolButtons?.MORE; + // The widget card (js/widget_cards) mounts right after this panel in the + // same creation pass; once it is there its own disclosure is the switch. + if (button) queueMicrotask(() => { if (state.node.__ausbossCard) button.style.display = "none"; }); if (button) { button.textContent = shown ? "LESS" : "MORE"; button.title = shown ? "Hide the advanced mask controls" : "Show the advanced mask controls"; @@ -170,6 +193,73 @@ function toggleAdvanced(state) { syncAdvanced(state); } +// The preview switch: one small pill at the right of the bar, driving the +// node's hidden `preview` widget so the backend knows not to write a temp +// file when nobody is looking. Off, the stage below the bar is gone and the +// node is just that much shorter. +function previewEnabled(state) { + return state.previewWidget ? state.previewWidget.value !== false : true; +} + +function makePreviewToggle(state, signal) { + const toggle = document.createElement("div"); + toggle.className = "ausboss-input-preview-toggle"; + toggle.title = "Show this node's result here. Off also skips writing the preview file to the temp folder."; + const caption = document.createElement("span"); + caption.textContent = "preview"; + const button = document.createElement("button"); + button.type = "button"; + button.className = "ausboss-input-preview-switch"; + button.setAttribute("role", "switch"); + toggle.append(caption, button); + toggle.addEventListener("pointerdown", (event) => event.stopPropagation(), { signal }); + toggle.addEventListener("click", (event) => { + event.preventDefault(); event.stopPropagation(); + const next = !previewEnabled(state); + state.previewWidget.value = next; + state.previewWidget.callback?.(next); + syncPreviewMode(state, true); + notifyAusbossChange(); + }, { signal }); + state.switchButton = button; + return toggle; +} + +function installPreviewSwitch(state, signal) { + const widget = state.previewWidget; + if (!widget) return; + hideWidget(widget); + state.bar.append(makePreviewToggle(state, signal)); + chainCallback(state.node, "onConfigure", () => queueMicrotask(() => { + if (!state.alive) return; + hideWidget(widget); + syncPreviewMode(state, false); + ensureNodeMinHeight(state.node); + })); + syncPreviewMode(state, false); +} + +function syncPreviewMode(state, resize) { + const enabled = previewEnabled(state); + state.root.classList.toggle("preview-off", !enabled); + if (state.switchButton) { + state.switchButton.classList.toggle("on", enabled); + state.switchButton.setAttribute("aria-checked", String(enabled)); + } + if (!enabled) { + // Nothing to show and nothing to fetch: drop the picture so a stale + // frame cannot flash back when the panel returns. + state.img.removeAttribute("src"); + } + if (resize) { + // Off: the node shrinks to what is left. On: it grows back to the + // stage's floor; anything taller it had is the user's to drag again. + const node = state.node; + node.setSize?.([node.size?.[0] ?? PANEL_MIN_WIDTH, node.computeSize?.()[1] ?? PANEL_HEIGHT]); + node.setDirtyCanvas?.(true, true); + } +} + function buildTools(state, tools, signal) { const bar = document.createElement("div"); bar.className = "ausboss-input-preview-tools"; @@ -321,22 +411,26 @@ function buildPanel(node, config) { toastEl.className = "ausboss-input-preview-toast"; toastEl.style.display = "none"; stage.append(img, video, hint, toastEl); - root.append(stage); + const bar = document.createElement("div"); + bar.className = "ausboss-input-preview-bar"; + root.append(bar, stage); + const previewWidget = widgetByName(node, "preview"); + const enabled = () => (previewWidget ? previewWidget.value !== false : true); const widget = node.addDOMWidget(WIDGET_NAME, "ausboss_input_preview", root, { serialize: false, hideOnZoom: false, - getMinHeight: () => PANEL_HEIGHT, + getMinHeight: () => (enabled() ? PANEL_HEIGHT : OFF_HEIGHT), }); keepDomWidgetWidthAuto(widget); // A floor, not a fixed height. This was a constant-height strip back when it // showed a thumbnail of the node's input; now that it shows the result, it is // a viewport onto a picture, and pinning it left dead space under every node - // dragged taller. + // dragged taller. With the preview off the floor is the bar alone. fillNodeHeight(widget, { minWidth: PANEL_MIN_WIDTH, - minHeight: PANEL_HEIGHT, - minNodeSize: [PANEL_MIN_WIDTH, 200], + minHeight: () => (enabled() ? PANEL_HEIGHT : OFF_HEIGHT), + minNodeSize: [PANEL_MIN_WIDTH, 90], }); const abort = new AbortController(); @@ -345,18 +439,19 @@ function buildPanel(node, config) { inputName: config.inputName, noun: config.noun, advanced: config.advanced, - root, stage, img, video, hint, widget, abort, + root, bar, stage, img, video, hint, widget, abort, toast: toastEl, toastTimer: 0, toolButtons: null, + switchButton: null, watched: null, timer: 0, alive: true, + previewWidget, }; - if (config.tools?.length) { - stage.append(buildTools(state, config.tools, abort.signal)); - } + if (config.tools?.length) bar.append(buildTools(state, config.tools, abort.signal)); syncAdvanced(state); + installPreviewSwitch(state, abort.signal); img.addEventListener("load", () => showMedia(state, "image")); img.addEventListener("error", () => { @@ -378,6 +473,7 @@ app.registerExtension({ beforeRegisterNodeDef(nodeType, nodeData) { const config = NODE_CONFIG[nodeData?.name]; if (!config) return; + hideInputsInDef(nodeData, ["preview"]); chainCallback(nodeType.prototype, "onNodeCreated", function () { buildPanel(this, config); }); diff --git a/js/lmstudio_chat/index.js b/js/lmstudio_chat/index.js deleted file mode 100644 index 964c5b1..0000000 --- a/js/lmstudio_chat/index.js +++ /dev/null @@ -1,353 +0,0 @@ -import { api } from "/scripts/api.js"; -import { app } from "/scripts/app.js"; -import { chainCallback, keepDomWidgetWidthAuto, BRAND } from "../shared/index.mjs"; -import { hideWidget } from "../shared/widget_visibility.mjs"; -import { - gearIconSvg, - loadSettings, - openSettingsMenu, -} from "../shared/settings_menu.mjs"; - -const NODE_CLASS = "AUSBOSS_NODES_LmStudioChat"; -const SETTINGS_SCOPE = "lmstudio_chat"; -const TOOLBAR_MIN_WIDTH = 200; - -// The endpoint toolbar is a DOM panel like any other, so it carries the same -// containment guards the panel audit enforces pack-wide. -function installStyles() { - if (document.getElementById("ausboss-chat-styles")) return; - const style = document.createElement("style"); - style.id = "ausboss-chat-styles"; - style.textContent = ` - .ausboss-chat-toolbar{box-sizing:border-box;overflow:hidden;width:100%; - display:flex;align-items:center;gap:6px;padding:2px;font:12px system-ui;color:#9ba2aa;} - .ausboss-chat-toolbar *{box-sizing:border-box;} - .ausboss-chat-dot{width:9px;height:9px;border-radius:50%;flex:none; - background:#3a4047;transition:background .15s;} - .ausboss-chat-dot.ok{background:#27c93f;box-shadow:0 0 5px rgba(39,201,63,.6);} - .ausboss-chat-dot.fail{background:#ff5f56;box-shadow:0 0 5px rgba(255,95,86,.6);} - .ausboss-chat-btn{height:22px;line-height:20px;border:1px solid #3a4047;border-radius:5px; - background:#23272c;color:#d7dde2;cursor:pointer;padding:0 10px;font:11px system-ui;flex:none;} - .ausboss-chat-btn:hover{border-color:${BRAND};color:${BRAND};} - .ausboss-chat-btn:disabled{opacity:.45;cursor:default;} - .ausboss-chat-gap{flex:1 1 auto;min-width:0;} - .ausboss-chat-gearbtn{width:24px;height:24px;border:1px solid #3a4047;border-radius:5px; - background:#23272c;color:#9ba2aa;cursor:pointer;display:grid;place-items:center; - flex:none;padding:0;} - .ausboss-chat-gearbtn:hover{border-color:${BRAND};color:${BRAND};} - .ausboss-chat-pop{position:fixed;z-index:10000;background:#1c1f23;border:1px solid #3a4047; - border-radius:7px;box-shadow:0 8px 28px rgba(0,0,0,.5);font:12px system-ui;color:#d7dde2; - display:flex;flex-direction:column;min-width:220px;max-width:380px;} - .ausboss-chat-list{overflow-y:auto;max-height:44vh;padding:4px;} - .ausboss-chat-option{padding:5px 8px;border-radius:4px;cursor:pointer;white-space:nowrap; - overflow:hidden;text-overflow:ellipsis;} - .ausboss-chat-option:hover{background:#2c3238;} - .ausboss-chat-option.current{color:${BRAND};} - .ausboss-chat-note{padding:9px 10px;color:#9ba2aa;font-style:italic;} - `; - document.head.append(style); -} - -// Every entry maps 1:1 onto a hidden standard widget of the same name, so -// values save with the workflow and reach the API like any widget; the gear -// also stores them as the defaults new nodes start from. -const SETTINGS_SCHEMA = [ - // Each sampler is an override: unticked sends nothing and the server's own - // setting stands. `neutral` is that off value - and it is also each - // sampler's no-op value, so the payload is identical either way and old - // workflows keep loading unchanged. `active` mirrors LM Studio's defaults. - { section: "Sampling" }, - { - key: "top_p", label: "Top-p", type: "number", default: 1, min: 0, max: 1, - neutral: 1, active: 0.95, slider: true, step: 0.01, - hint: "Nucleus cap: consider only the most likely tokens summing to this.", - }, - { - key: "top_k", label: "Top-k", type: "number", default: 0, min: 0, max: 1000, - neutral: 0, active: 40, - hint: "Consider only this many top tokens.", - }, - { - key: "min_p", label: "Min-p", type: "number", default: 0, min: 0, max: 1, - neutral: 0, active: 0.05, slider: true, step: 0.01, - hint: "Drop tokens below this share of the top token's probability.", - }, - { - key: "repeat_penalty", label: "Repeat penalty", type: "number", - default: 1, min: 0, max: 4, neutral: 1, active: 1.1, - hint: "Above 1 discourages repeating what it already said.", - }, - { - key: "presence_penalty", label: "Presence penalty", type: "number", - default: 0, min: -2, max: 2, neutral: 0, active: 0.5, - hint: "Above 0 pushes toward new topics.", - }, - { section: "Thinking" }, - { - key: "thinking_mode", label: "Thinking", type: "choice", - default: "model default", options: ["model default", "on", "off"], - hint: "Force hybrid reasoning models to think or answer directly.", - }, - { - key: "reasoning_open_tag", label: "Reasoning open tag", type: "text", - default: "", - hint: "Text between the tags moves to the thinking output.", - }, - { - key: "reasoning_close_tag", label: "Reasoning close tag", type: "text", - default: "", - }, - { section: "Memory" }, - { - key: "idle_unload_seconds", label: "Idle unload (seconds)", type: "number", - default: 0, min: 0, max: 86400, - hint: "LM Studio unloads the model after idling this long. 0 keeps it " - + "loaded. The node's unload_llm switch overrides this with 1s.", - }, - { - key: "free_comfy_vram", label: "Free ComfyUI VRAM first", type: "toggle", - default: false, - hint: "Unload cached diffusion models before the request.", - }, -]; - -const SETTING_KEYS = SETTINGS_SCHEMA.filter((entry) => entry.key).map((entry) => entry.key); - -function advancedWidgets(node) { - const map = {}; - for (const key of SETTING_KEYS) { - const widget = node.widgets?.find((item) => item.name === key); - if (widget) map[key] = widget; - } - return map; -} - -function currentValues(widgets) { - const values = {}; - for (const [key, widget] of Object.entries(widgets)) values[key] = widget.value; - return values; -} - -function toast(severity, summary, detail) { - try { - app.extensionManager?.toast?.add?.({ severity, summary, detail, life: 4500 }); - } catch (_error) { - // No toast store on this frontend: the status dot still tells the story. - } -} - -async function fetchModels(endpoint) { - const response = await api.fetchApi("/ausboss/lmstudio/models", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ endpoint }), - }); - const data = await response.json(); - if (!data.ok) throw new Error(data.error || "endpoint test failed"); - return data.models ?? []; -} - -// One floating popup at a time, torn down through one AbortController. -let activePicker = null; - -function closePicker() { - if (!activePicker) return; - activePicker.abort.abort(); - activePicker.element.remove(); - activePicker = null; -} - -function openModelPicker(anchorRect, models, current, onPick) { - closePicker(); - const pop = document.createElement("div"); - pop.className = "ausboss-chat-pop"; - const list = document.createElement("div"); - list.className = "ausboss-chat-list"; - if (!models.length) { - const note = document.createElement("div"); - note.className = "ausboss-chat-note"; - note.textContent = "The server reports no models."; - pop.append(note); - } - for (const name of models) { - const option = document.createElement("div"); - option.className = `ausboss-chat-option${name === current ? " current" : ""}`; - option.textContent = name; - option.title = name; - option.addEventListener("click", () => { closePicker(); onPick(name); }); - list.append(option); - } - pop.append(list); - document.body.append(pop); - const place = () => { - const rect = pop.getBoundingClientRect(); - let left = Math.min(anchorRect.left, window.innerWidth - rect.width - 8); - let top = anchorRect.bottom + 4; - if (top + rect.height > window.innerHeight - 8) { - top = Math.max(8, anchorRect.top - rect.height - 4); - } - pop.style.left = `${Math.max(8, left)}px`; - pop.style.top = `${top}px`; - }; - place(); - requestAnimationFrame(place); - const abort = new AbortController(); - document.addEventListener( - "pointerdown", - (event) => { if (!pop.contains(event.target)) closePicker(); }, - { capture: true, signal: abort.signal }, - ); - window.addEventListener( - "keydown", - (event) => { if (event.key === "Escape") { event.stopPropagation(); closePicker(); } }, - { capture: true, signal: abort.signal }, - ); - activePicker = { element: pop, abort }; -} - -function buildToolbar(node, widgets) { - const endpointWidget = node.widgets?.find((item) => item.name === "endpoint"); - const modelWidget = node.widgets?.find((item) => item.name === "model"); - - const row = document.createElement("div"); - row.className = "ausboss-chat-toolbar"; - const dot = document.createElement("span"); - dot.className = "ausboss-chat-dot"; - dot.title = "Endpoint status: gray untested, green reachable, red failed"; - - const test = document.createElement("button"); - test.type = "button"; - test.className = "ausboss-chat-btn"; - test.textContent = "Test"; - test.title = "Check the endpoint by asking it for its model list"; - test.addEventListener("click", async () => { - test.disabled = true; - dot.className = "ausboss-chat-dot"; - try { - const models = await fetchModels(endpointWidget?.value ?? ""); - dot.className = "ausboss-chat-dot ok"; - toast("success", "LM Studio endpoint OK", - models.length ? `${models.length} model(s) available.` : "Reachable; no models loaded."); - } catch (error) { - dot.className = "ausboss-chat-dot fail"; - toast("warn", "LM Studio endpoint failed", String(error?.message ?? error)); - } finally { - test.disabled = false; - } - }); - - const load = document.createElement("button"); - load.type = "button"; - load.className = "ausboss-chat-btn"; - load.textContent = "Models"; - load.title = "List the server's models and pick one into the model field"; - load.addEventListener("click", async () => { - load.disabled = true; - try { - const models = await fetchModels(endpointWidget?.value ?? ""); - dot.className = "ausboss-chat-dot ok"; - openModelPicker(load.getBoundingClientRect(), models, modelWidget?.value, (name) => { - if (modelWidget) { - modelWidget.value = name; - modelWidget.callback?.(name); - } - node.graph?.setDirtyCanvas?.(true, true); - }); - } catch (error) { - dot.className = "ausboss-chat-dot fail"; - toast("warn", "LM Studio endpoint failed", String(error?.message ?? error)); - } finally { - load.disabled = false; - } - }); - - const gap = document.createElement("span"); - gap.className = "ausboss-chat-gap"; - - const gear = document.createElement("button"); - gear.type = "button"; - gear.className = "ausboss-chat-gearbtn"; - gear.title = "LM Studio Chat settings"; - gear.innerHTML = gearIconSvg(); - gear.addEventListener("click", () => { - openSettingsMenu({ - scope: SETTINGS_SCOPE, - schema: SETTINGS_SCHEMA, - anchor: gear.getBoundingClientRect(), - title: "LM Studio Chat settings", - initial: currentValues(widgets), - onChange: (values, key) => { - const keys = key ? [key] : SETTING_KEYS; - for (const name of keys) { - if (widgets[name]) widgets[name].value = values[name]; - } - node.graph?.setDirtyCanvas?.(true, true); - }, - }); - }); - - row.append(dot, test, load, gap, gear); - return row; -} - -function installChatNode(node) { - installStyles(); - const widgets = advancedWidgets(node); - if (!Object.keys(widgets).length) return; - - // New nodes start from the stored gear defaults; a workflow restore then - // overwrites these seeds with the saved values, which is the right order. - const defaults = loadSettings(SETTINGS_SCOPE, SETTINGS_SCHEMA); - for (const [key, widget] of Object.entries(widgets)) { - widget.value = defaults[key]; - hideWidget(widget); - } - - const row = buildToolbar(node, widgets); - // The frontend keeps ~16px of a DOM widget's declared height as wrapper - // margins, so the 22px buttons need 48 declared to get a 32px box — - // measured live against the deployed frontend, not assumed. - const TOOLBAR_DECLARED_HEIGHT = 48; - const toolbarWidget = node.addDOMWidget("ausboss_chat_toolbar", "ausboss_chat_toolbar", row, { - serialize: false, - hideOnZoom: false, - getMinHeight: () => TOOLBAR_DECLARED_HEIGHT, - }); - keepDomWidgetWidthAuto(toolbarWidget); - toolbarWidget.computeSize = (width) => [ - Math.max(TOOLBAR_MIN_WIDTH, Number(width || node.size?.[0] || TOOLBAR_MIN_WIDTH)), - TOOLBAR_DECLARED_HEIGHT, - ]; - toolbarWidget.computeLayoutSize = () => ({ - minWidth: TOOLBAR_MIN_WIDTH, - minHeight: TOOLBAR_DECLARED_HEIGHT, - }); - toolbarWidget.options.minNodeSize = [TOOLBAR_MIN_WIDTH, 60]; - - // The toolbar belongs directly under the endpoint field, not at the node's - // tail where addDOMWidget appends it. - const list = node.widgets ?? []; - const from = list.indexOf(toolbarWidget); - const endpointAt = list.findIndex((item) => item.name === "endpoint"); - if (from >= 0 && endpointAt >= 0 && from !== endpointAt + 1) { - list.splice(from, 1); - list.splice(endpointAt + 1, 0, toolbarWidget); - } - - chainCallback(node, "onRemoved", () => closePicker()); - - node.setSize?.([ - Math.max(node.size?.[0] ?? 0, 300), - node.computeSize?.()[1] ?? node.size?.[1], - ]); -} - -app.registerExtension({ - name: "AusBoss.LmStudioChat", - beforeRegisterNodeDef(nodeType, nodeData) { - if (nodeData?.name !== NODE_CLASS) return; - chainCallback(nodeType.prototype, "onNodeCreated", function () { - installChatNode(this); - }); - }, -}); diff --git a/js/load_image_pad/index.js b/js/load_image_pad/index.js index dcd7e95..d6ee81d 100644 --- a/js/load_image_pad/index.js +++ b/js/load_image_pad/index.js @@ -2,7 +2,7 @@ import { api } from "/scripts/api.js"; import { app } from "/scripts/app.js"; import { chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs"; import { fillNodeHeight } from "../shared/panel_layout.mjs"; -import { hideWidget } from "../shared/widget_visibility.mjs"; +import { hideInputsInDef, hideWidget } from "../shared/widget_visibility.mjs"; import { canvasHeightForWidth, parseImageReference } from "../shared/pad_canvas.mjs"; import { createPadStage } from "../shared/pad_panel.mjs"; @@ -132,6 +132,8 @@ function buildPanel(node) { target_megapixels: numberValue(node, "target_megapixels", 0), }), writePad: (side, value) => { + const input = node.inputs?.find((entry) => entry.name === `pad_${side}`); + if (input?.link != null) return; const target = findWidget(node, `pad_${side}`); if (!target) return; target.value = value; @@ -195,6 +197,7 @@ app.registerExtension({ name: "ausboss.load_image_pad.panel", beforeRegisterNodeDef(nodeType, nodeData) { if (nodeData?.name !== NODE_NAME) return; + hideInputsInDef(nodeData, ["pad_left", "pad_top", "pad_right", "pad_bottom"]); chainCallback(nodeType.prototype, "onNodeCreated", function () { buildPanel(this); }); diff --git a/js/load_video/trim_preview.mjs b/js/load_video/trim_preview.mjs index 737e819..bf23ac0 100644 --- a/js/load_video/trim_preview.mjs +++ b/js/load_video/trim_preview.mjs @@ -99,6 +99,17 @@ export function formatFps(value) { // instead, mirroring the backend's own arithmetic (decode_video_range): // ceil(window x fps) frames in the trim, one kept in every_nth, capped by // max_frames; single-frame mode always loads exactly one. +// Mirror of the clip node's snap_frame_count: the largest step*n + 1 not +// above count for a rule like "8n+1"; "free" keeps count. +export function snapFrameCount(count, rule = "free") { + const frames = Math.max(0, Math.floor(finiteNumber(count, 0))); + const text = String(rule ?? "free").trim().toLowerCase(); + if (!text.endsWith("n+1")) return frames; + const step = Number(text.slice(0, -"n+1".length)); + if (!Number.isInteger(step) || step <= 0 || frames <= 1) return frames; + return Math.floor((frames - 1) / step) * step + 1; +} + export function loadSummary( durationValue, bounds, @@ -106,6 +117,7 @@ export function loadSummary( everyNthValue = 1, maxFramesValue = 0, singleFrame = false, + frameSnap = "free", ) { if (singleFrame) return "1 frame"; const duration = Math.max(0, finiteNumber(durationValue, 0)); @@ -119,6 +131,7 @@ export function loadSummary( const cap = Math.max(0, Math.floor(finiteNumber(maxFramesValue, 0))); let frames = Math.ceil(Math.ceil((end - start) * fps) / nth); if (cap > 0) frames = Math.min(frames, cap); + frames = snapFrameCount(frames, frameSnap); if (frames <= 0) return ""; if (frames === 1) return "1 frame"; return `${frames} frames @ ${formatFps(fps / nth)} fps`; diff --git a/js/lora_loader/index.js b/js/lora_loader/index.js index 13db3fd..482c168 100644 --- a/js/lora_loader/index.js +++ b/js/lora_loader/index.js @@ -2,7 +2,7 @@ import { api } from "/scripts/api.js"; import { app } from "/scripts/app.js"; import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs"; import { WIDGET_FRAME, fillNodeHeight } from "../shared/panel_layout.mjs"; -import { hideWidget as collapseWidget } from "../shared/widget_visibility.mjs"; +import { hideInputsInDef, hideWidget as collapseWidget } from "../shared/widget_visibility.mjs"; import { closeSettingsMenu, gearIconSvg, @@ -106,6 +106,13 @@ const SETTINGS_SCHEMA = [ default: ", ", placeholder: '", "', hint: "Joins the triggers output. This node now, new nodes later.", }, + { + key: "strict_missing", label: "Stop on missing LoRA", type: "toggle", + default: true, + hint: "On (the default): fail the run before sampling when an enabled row's " + + "file cannot be found, with an error naming it. Off: skip that row with " + + "a console warning and render without it. This node now, new nodes later.", + }, { key: "hide_extension", label: "Hide file extension", type: "toggle", default: true, @@ -1102,7 +1109,13 @@ function decorateRows(state) { if (status?.status === "remapped") { notes.push(`Not at its saved path; the run will use "${status.name}".`); } else if (status?.status === "missing") { - notes.push("No matching file in models/loras - this row is skipped at run time."); + notes.push( + state.onMissingWidget?.value === "skip" + ? "No matching file in models/loras - this row is skipped at run time " + + "(Stop on missing LoRA is off)." + : "No matching file in models/loras - the run stops before sampling. Fix " + + "the row, or turn Stop on missing LoRA off in the gear menu to skip it.", + ); } else if (status?.status === "ambiguous") { notes.push("Several files match this name - pick one to settle it."); } @@ -1384,6 +1397,9 @@ function openSettings(state, anchor) { ...state.settings, separate_strengths: !linked(state), ...(state.separatorWidget ? { separator: state.separatorWidget.value } : {}), + ...(state.onMissingWidget + ? { strict_missing: state.onMissingWidget.value === "error" } + : {}), }, onChange: (values, key) => { state.settings = values; @@ -1401,6 +1417,9 @@ function openSettings(state, anchor) { if ((key === "separator" || key === null) && state.separatorWidget) { state.separatorWidget.value = values.separator; } + if ((key === "strict_missing" || key === null) && state.onMissingWidget) { + state.onMissingWidget.value = values.strict_missing ? "error" : "skip"; + } renderRows(state); fitNode(state); }, @@ -1642,6 +1661,14 @@ function installLoraNode(node) { separatorWidget.value = settings.separator; state.separatorWidget = separatorWidget; } + // Same arrangement for the missing-file policy: a hidden standard combo + // carries "skip" | "error" through save/load and the API format. + const onMissingWidget = node.widgets?.find((item) => item.name === "on_missing"); + if (onMissingWidget) { + hideWidget(onMissingWidget); + onMissingWidget.value = settings.strict_missing ? "error" : "skip"; + state.onMissingWidget = onMissingWidget; + } const domWidget = node.addDOMWidget("ausboss_lora_rows", "ausboss_lora_rows", panel, { serialize: false, @@ -1709,6 +1736,7 @@ app.registerExtension({ name: "AusBoss.LoraLoader", beforeRegisterNodeDef(nodeType, nodeData) { if (nodeData?.name !== NODE_CLASS) return; + hideInputsInDef(nodeData, ["loras", "trigger_separator", "on_missing"]); chainCallback(nodeType.prototype, "onNodeCreated", function () { installLoraNode(this); }); diff --git a/js/resolution/index.js b/js/resolution/index.js new file mode 100644 index 0000000..39a1e8a --- /dev/null +++ b/js/resolution/index.js @@ -0,0 +1,984 @@ +// Resolution Master 🆎 — SHAPE × BUDGET picker, styled to the designer's pass. +// +// The ratio grid picks the shape, the stage shows the actual rectangle +// (stretchable by its edges and corner), and the per-ratio quick list +// offers generated on-grid sizes across the useful megapixel range. +// Width/height are always the derived, visible result, living in two +// ordinary hidden INT widgets (widgets are the single source of truth — +// no state blob, no queue-time injection). +// +// Layout: SHAPE (a flip container beside a 3-column ratio-chip container), +// the stage, SIZE & BUDGET (grip-scrub W/H fields, swap, typeable snap +// with ladder arrows, gear), and SIZES — quick picks for the CURRENT +// ratio, regenerated whenever the ratio or snap changes. The node is +// fixed-width (Pixaroma-style) so everything spaces evenly; height stays +// draggable and the stage absorbs it. +// +// Interaction grammar: EDGES change shape (one axis, snapped), the CORNER +// changes size at the exact locked ratio (Shift frees it), Alt drops the +// snap to 1px, Escape mid-drag restores the grab-time size. Chips reshape +// at the selected pixel budget and follow the explicit orientation toggle. Snap governs gestures only — typed +// values are never re-snapped. + +import { app } from "/scripts/app.js"; +import { + chainCallback, + keepDomWidgetWidthAuto, + notifyAusbossChange, +} from "../shared/index.mjs"; +import { WIDGET_FRAME, fillNodeHeight } from "../shared/panel_layout.mjs"; +import { hideInputsInDef, hideWidget } from "../shared/widget_visibility.mjs"; +import { makeScrubInput } from "../shared/scrub_input.mjs"; +import { + gearIconSvg, + loadSettings, + openSettingsMenu, +} from "../shared/settings_menu.mjs"; +import { + DEFAULT_RAIL, + DEFAULT_SNAP, + DIM_MAX, + DIM_MIN, + SNAP_STEPS, + applyRatioAtBudget, + chipMatch, + defaultSizeForRatio, + clampDim, + dragResize, + fitRectInBox, + megapixels, + offGrid, + orientRatio, + parseRail, + quickSizes, + ratioLabel, + reduceRatio, + scrubDim, + sizeForMegapixels, + stageScale, +} from "../shared/resolution_math.mjs"; + +const NODE_CLASS = "AUSBOSS_NODES_Resolution"; +const FIXED_WIDTH = 340; // one fixed width that spaces evenly; W, H, and MP fields share the size row +const SECTION_HEAD = 19; // header line + its 6px bottom margin +const SECTION_PAD_Y = 14; // 6 top + 8 bottom +const CHIP_H = 24; +const QUICK_H = 22; +const CONTROL_H = 28; +const STAGE_MIN = 140; +const PANEL_PADDING = 6; +const SECTION_GAP = 6; +const REFIT_MS = 160; + +const MONO = "ui-monospace, 'Cascadia Mono', Consolas, monospace"; + +const SETTINGS_SCOPE = "resolution"; +const SETTINGS_SCHEMA = [ + { + key: "default_snap", label: "Snap step", type: "choice", + options: ["8", "16", "32", "64"], default: "32", + hint: "Grid for stage gestures and the size ladder. Typed values are never snapped.", + }, + { + key: "chips_keep_budget", label: "Chips keep current budget", type: "toggle", + default: false, + hint: "Ratio chips reshape at your current megapixels instead of jumping " + + "to that ratio's ~1MP default size.", + }, + { + key: "rail", label: "Ratio rail", type: "text", + default: DEFAULT_RAIL, placeholder: DEFAULT_RAIL, + hint: "Comma-separated ratios shown as chips, e.g. \"1:1, 21:9\".", + }, + { section: "Latent output" }, + { + key: "latent", label: "Latent family", type: "choice", + options: ["16ch", "4ch", "128ch"], default: "16ch", persist: false, + hint: "16ch: SD3, Flux 1, Krea 2, Qwen, Z-Image. 4ch: SD 1.5, SDXL. " + + "128ch: Flux 2 Klein. Stored on this node.", + }, + { + key: "batch", label: "Batch size", type: "number", scrub: true, default: 1, min: 1, max: 64, + persist: false, hint: "Latents in the batch. Stored on this node.", + }, + { section: "Panel" }, + { + key: "show_mp_rings", label: "MP cost curves", type: "toggle", + default: true, + hint: "Equal-cost curves on the stage at 0.5 / 1 / 2 MP.", + }, + { + key: "show_grid", label: "Dot grid", type: "toggle", + default: true, + hint: "The stage's dotted texture.", + }, +]; + +function installStyles() { + if (document.getElementById("ausboss-res-styles")) return; + const style = document.createElement("style"); + style.id = "ausboss-res-styles"; + style.textContent = ` + .ausboss-res-panel, .ausboss-res-panel * { box-sizing: border-box; } + .ausboss-res-panel { display: flex; flex-direction: column; gap: ${SECTION_GAP}px; + width: 100%; height: 100%; padding: ${PANEL_PADDING}px; overflow: hidden; + font: 400 11px ${MONO}; color: #d7dde2; } + .ausboss-res-sec { flex: none; background: #141515; border: 1px solid #252727; + border-radius: 8px; padding: 6px 8px 8px; } + .ausboss-res-sechead { display: flex; align-items: baseline; + justify-content: space-between; margin-bottom: 6px; gap: 8px; } + .ausboss-res-sechead .label { font: 500 9px ${MONO}; letter-spacing: .14em; + text-transform: uppercase; color: #6a7070; white-space: nowrap; } + .ausboss-res-sechead .hint { font: 400 9px ${MONO}; color: #4e5454; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .ausboss-res-shaperow { display: flex; gap: 6px; align-items: stretch; } + .ausboss-res-subbox { background: #101111; border: 1px solid #252727; + border-radius: 6px; padding: 4px; } + .ausboss-res-flipbox { flex: none; display: grid; place-items: center; } + .ausboss-res-chipbox { flex: 1 1 auto; min-width: 0; display: grid; + place-content: start center; max-height: 65px; overflow-y: auto; } + .ausboss-res-chipgrid { display: grid; justify-content: center; + grid-template-columns: repeat(3, minmax(60px, max-content)); gap: 5px; } + .ausboss-res-chip { display: flex; align-items: center; justify-content: center; + gap: 5px; height: ${CHIP_H}px; padding: 0 3px; border-radius: 5px; cursor: pointer; + font: 500 10.5px ${MONO}; background: #1e2020; border: 1px solid #313434; + color: #9aa0a0; transition: border-color .12s, color .12s; white-space: nowrap; + overflow: hidden; } + .ausboss-res-panel button:disabled { opacity: .4; cursor: default; } + .ausboss-res-chip:hover { border-color: #00b4aa; color: #00b4aa; } + .ausboss-res-chip.near { border: 1px dashed #3c6663; color: #9fc4c1; } + .ausboss-res-chip.exact { background: rgba(0,180,170,.16); border: 1px solid #00b4aa; + color: #7fe9e2; } + .ausboss-res-chip .glyph { display: inline-block; border: 1.4px solid currentColor; + border-radius: 1.5px; opacity: .75; flex: none; } + .ausboss-res-flip { width: 32px; height: 100%; min-height: ${CHIP_H * 2 + 5}px; + display: grid; place-items: center; background: #1e2020; border: 1px solid #313434; + border-radius: 5px; cursor: pointer; color: #c8cccc; padding: 0; } + .ausboss-res-flip:hover { border-color: #00b4aa; color: #00b4aa; } + .ausboss-res-flip .glyph { display: block; border: 1.5px solid currentColor; + border-radius: 2px; } + .ausboss-res-stage { flex: 1 1 auto; min-height: 0; position: relative; + overflow: hidden; background: #0e0f0f; border: 1px solid #252727; border-radius: 8px; + outline: none; } + .ausboss-res-stage:focus-visible { border-color: #00b4aa; } + .ausboss-res-dots { position: absolute; inset: 0; pointer-events: none; + background-image: radial-gradient(#242727 1px, transparent 1px); + background-size: 22px 22px; background-position: center; opacity: .55; } + .ausboss-res-rings { position: absolute; inset: 0; pointer-events: none; } + .ausboss-res-rect { position: absolute; inset: 0; margin: auto; + border: 1.5px solid #00b4aa; background: rgba(0,180,170,.05); + display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 2px; } + .ausboss-res-rect.easing { transition: width ${REFIT_MS}ms ease, height ${REFIT_MS}ms ease; } + .ausboss-res-ghost { position: absolute; inset: 0; margin: auto; + border: 1px dashed #4d5555; pointer-events: none; display: none; } + .ausboss-res-readout { position: absolute; top: 6px; left: 9px; right: 9px; z-index: 2; + display: flex; align-items: center; justify-content: space-between; gap: 8px; + pointer-events: none; white-space: nowrap; text-shadow: 0 1px 2px rgba(0,0,0,.7); } + .ausboss-res-readout .dims { font: 600 13px ${MONO}; color: #f0f3f3; letter-spacing: -.01em; } + .ausboss-res-readout .meta { display: flex; align-items: center; gap: 5px; + font: 400 10.5px ${MONO}; color: #8d9494; } + .ausboss-res-readout .mp { font-weight: 600; color: #e5eeee; } + .ausboss-res-ringlabel { font: 400 8.5px ${MONO}; fill: #4f5656; } + .ausboss-res-offgrid { width: 6px; height: 6px; border-radius: 50%; + background: #f0a11e; } + .ausboss-res-handle { position: absolute; width: 18px; height: 18px; + display: grid; place-items: center; border: none; background: transparent; padding: 0; } + .ausboss-res-handle::after { content: ""; width: 9px; height: 9px; + background: #2ee0e8; } + .ausboss-res-handle.e { right: -9px; top: 50%; margin-top: -9px; cursor: ew-resize; } + .ausboss-res-handle.s { bottom: -9px; left: 50%; margin-left: -9px; cursor: ns-resize; } + .ausboss-res-handle.se { right: -9px; bottom: -9px; cursor: nwse-resize; } + .ausboss-res-handle.linked { opacity: .3; pointer-events: none; } + .ausboss-res-controls { display: flex; align-items: center; gap: 6px; + flex-wrap: nowrap; } + .ausboss-res-field { display: flex; align-items: center; gap: 4px; flex: 1 1 80px; + min-width: 0; } + .ausboss-res-field .ausboss-scrub { width: auto; flex: 1 1 auto; min-width: 0; + height: ${CONTROL_H}px; background: #1c1e1e; border-color: #313434; font: 500 13px ${MONO}; } + .ausboss-res-field .ausboss-scrub > .ausboss-scrub-input { text-align: left; + padding-left: 8px; color: #eef1f1; } + .ausboss-res-field.linked { opacity: .5; pointer-events: none; } + .ausboss-res-axis { flex: none; font: 400 9px ${MONO}; color: #565c5c; } + .ausboss-res-btn { width: ${CONTROL_H}px; height: ${CONTROL_H}px; flex: none; + display: grid; place-items: center; background: #1c1e1e; border: 1px solid #313434; + border-radius: 5px; color: #9aa0a0; cursor: pointer; font-size: 12px; padding: 0; } + .ausboss-res-btn:hover { border-color: #00b4aa; color: #00b4aa; } + .ausboss-res-quick { display: grid; grid-template-columns: repeat(4, 1fr); gap: 5px; + min-height: ${QUICK_H * 2 + 5}px; align-content: start; } + .ausboss-res-chip.customchip { opacity: .45; cursor: default; + pointer-events: none; border-style: dashed; grid-column: span 2; } + .ausboss-res-quick .ausboss-res-chip { height: ${QUICK_H}px; font-size: 9.5px; + padding: 0 1px; } + .ausboss-res-noquick { font: 400 10px ${MONO}; color: #4a5050; padding: 4px 0; + grid-column: 1 / -1; } + `; + document.head.append(style); +} + +function el(tag, className, text) { + const element = document.createElement(tag); + if (className) element.className = className; + if (text !== undefined) element.textContent = text; + return element; +} + +function svgEl(tag) { + return document.createElementNS("http://www.w3.org/2000/svg", tag); +} + +// ---------- state plumbing ---------- + +function getSize(state) { + return { + width: clampDim(state.widthWidget.value), + height: clampDim(state.heightWidget.value), + }; +} + +function commitSize(state, width, height, { gesture = false } = {}) { + if (!axisLinked(state, "width")) state.widthWidget.value = clampDim(width); + if (!axisLinked(state, "height")) state.heightWidget.value = clampDim(height); + repaint(state); + state.node.graph?.setDirtyCanvas(true, true); + if (!gesture) notifyAusbossChange(); +} + +// Orientation is a saved two-mode choice, independent of canvas resizing. +// Older workflows infer their initial mode from dimensions once. +function orientation(state) { + const properties = state.node.properties ??= {}; + if (typeof properties.ausbossResolutionPortrait !== "boolean") { + const { width, height } = getSize(state); + properties.ausbossResolutionPortrait = height > width; + } + return properties.ausbossResolutionPortrait; +} + +function flipOrientation(state) { + if (axisLinked(state, "width") || axisLinked(state, "height")) return; + const portrait = !orientation(state); + state.node.properties.ausbossResolutionPortrait = portrait; + const { width, height } = getSize(state); + commitSize(state, portrait ? Math.min(width, height) : Math.max(width, height), + portrait ? Math.max(width, height) : Math.min(width, height)); + easeRefit(state); +} + +// Core Primitive nodes propagate through widget-row metadata. Our compact +// size bar lifts sockets into the input column, so mirror a connected +// Primitive's edits into the ordinary backing widgets at edit time. +// No prompt-export hook or custom serialization is involved. +function syncPrimitiveLinks(state) { + const target = state.node; + state.primitiveSources ??= new WeakSet(); + for (const input of target.inputs ?? []) { + const link = target.graph?.links?.[input.link]; + const source = link && target.graph.getNodeById(link.origin_id); + if (source?.type !== "PrimitiveNode" || !source.widgets?.[0]) continue; + const reference = new WeakRef(target); + const mirror = () => { + const node = reference.deref(); + if (!node?.graph) return; + for (const slot of node.inputs ?? []) { + const edge = node.graph.links?.[slot.link]; + if (!edge || String(edge.origin_id) !== String(source.id)) continue; + const widget = node.widgets?.find((item) => item.name === slot.name); + if (widget && widget.value !== source.widgets[0].value) { + widget.value = source.widgets[0].value; + widget.callback?.(widget.value); + } + } + }; + if (!state.primitiveSources.has(source)) { + state.primitiveSources.add(source); + chainCallback(source.widgets[0], "callback", mirror); + } + mirror(); + } +} + +function axisLinked(state, name) { + return !!state.node.inputs?.some( + (input) => input?.name === name && input.link !== null && input.link !== undefined, + ); +} + +// Any positive step is legal (the snap box is typeable); the ladder +// 8/16/32/64 rides the arrows. +function currentSnap(state) { + const stored = Number(state.node.properties?.ausbossResolutionSnap); + if (Number.isInteger(stored) && stored >= 1 && stored <= 1024) return stored; + const preferred = Number(state.settings?.default_snap); + return SNAP_STEPS.includes(preferred) ? preferred : DEFAULT_SNAP; +} + +function setSnap(state, step) { + const value = Math.round(Number(step)); + if (!Number.isFinite(value) || value < 1 || value > 1024) return; + state.node.properties.ausbossResolutionSnap = value; + repaint(state); +} + +// ---------- painting ---------- + +function stageBox(state) { + return { + w: Math.max(40, state.stage.clientWidth), + h: Math.max(40, state.stage.clientHeight), + }; +} + +// Equal-cost curves, log-sampled across the plausible width range so they +// sweep the whole quadrant (the design's rendering). +function paintRings(state, scale) { + const svg = state.rings; + svg.replaceChildren(); + const show = state.settings?.show_mp_rings !== false; + if (!show) return; + const box = stageBox(state); + svg.setAttribute("viewBox", `0 0 ${box.w} ${box.h}`); + const cx = box.w / 2; + const cy = box.h / 2; + for (const mp of [0.5, 1, 2]) { + const points = []; + for (let i = 0; i <= 60; i += 1) { + const w = 128 * Math.pow(20000 / 128, i / 60); + const x = (w * scale) / 2; + const y = ((mp * 1_000_000) / w) * (scale / 2); + if (x < cx * 1.4 && y < cy * 1.6) points.push(`${x.toFixed(1)},${y.toFixed(1)}`); + } + if (points.length < 2) continue; + // Name the curve where it leaves the stage on the right, so the + // legend lives on the line itself instead of in a footer. + const labelX = box.w - 8; + const labelW = ((labelX - cx) * 2) / scale; + const labelY = cy - ((mp * 1_000_000) / labelW) * (scale / 2); + if (labelW > 0 && labelY > 26 && labelY < box.h - 6) { + const text = svgEl("text"); + text.setAttribute("x", String(labelX)); + text.setAttribute("y", String(labelY - 3)); + text.setAttribute("text-anchor", "end"); + text.setAttribute("class", "ausboss-res-ringlabel"); + text.textContent = `${mp} MP`; + svg.append(text); + } + for (const [sx, sy] of [[1, 1], [-1, 1], [1, -1], [-1, -1]]) { + const path = svgEl("polyline"); + path.setAttribute("points", points.join(" ")); + path.setAttribute("fill", "none"); + path.setAttribute("stroke", "#3a4040"); + path.setAttribute("stroke-width", "1"); + path.setAttribute("transform", `translate(${cx} ${cy}) scale(${sx} ${sy})`); + svg.append(path); + } + } +} + +// The quick list regenerates when the ratio or snap changes; repaint only +// re-highlights it otherwise. +function rebuildQuick(state, width, height, snap, custom) { + const wrap = state.quickWrap; + wrap.replaceChildren(); + state.quickButtons = []; + if (custom) { + // A shape off the rail has no ladder; one quiet chip says so instead + // of a list that churns with every drag step. + const chip = el("button", "ausboss-res-chip customchip", "custom"); + chip.type = "button"; + chip.disabled = true; + wrap.append(chip); + return; + } + const list = quickSizes(width, height, snap); + if (!list.length) { + wrap.append(el("span", "ausboss-res-noquick", + "No on-grid sizes for this ratio at this snap — try a finer snap.")); + return; + } + for (const size of list) { + const chip = el("button", "ausboss-res-chip", `${size.width}×${size.height}`); + chip.type = "button"; + chip.title = `Apply ${size.width}×${size.height} exactly.`; + chip.addEventListener("click", () => { + commitSize(state, size.width, size.height); + easeRefit(state); + }); + chip.addEventListener("pointerenter", () => showGhost(state, size.width, size.height)); + chip.addEventListener("pointerleave", () => hideGhost(state)); + wrap.append(chip); + state.quickButtons.push({ button: chip, size }); + } +} + +function repaint(state) { + const { width, height } = getSize(state); + const snap = currentSnap(state); + const box = stageBox(state); + const scale = state.frozenScale ?? stageScale(width, height, box.w, box.h); + const portrait = orientation(state); + const ratio = reduceRatio(width, height); + + state.rect.style.width = `${Math.max(8, width * scale)}px`; + state.rect.style.height = `${Math.max(8, height * scale)}px`; + state.dimLabel.textContent = `${width} × ${height}`; + state.subMp.textContent = `${megapixels(width, height).toFixed(2)} MP`; + state.subRatio.textContent = ratioLabel(width, height); + state.offGridDot.style.display = offGrid(width, height, snap) ? "" : "none"; + state.dots.style.display = state.settings?.show_grid === false ? "none" : ""; + + paintRings(state, scale); + + // Section labels. + state.orientLabel.textContent = width === height + ? `square · chips ${portrait ? "portrait" : "landscape"}` + : portrait ? "portrait" : "landscape"; + const flipFit = portrait ? { w: 11, h: 15 } : { w: 15, h: 11 }; + state.flipGlyph.style.width = `${flipFit.w}px`; + state.flipGlyph.style.height = `${flipFit.h}px`; + state.flipButton.title = width === height + ? `Square canvas: the ratio chips land ${portrait ? "portrait" : "landscape"}. Click to switch.` + : `Flip to ${portrait ? "landscape" : "portrait"}`; + + state.flipButton.setAttribute("aria-pressed", String(portrait)); + + // Ratio chips render in the selected orientation; filled = exact, dashed = near. + state.chipButtons.forEach(({ button, ratio: chipRatio, glyph, label }) => { + const oriented = orientRatio(chipRatio, portrait); + const fitted = fitRectInBox(oriented.x, oriented.y, 14, 14); + glyph.style.width = `${fitted.w.toFixed(1)}px`; + glyph.style.height = `${fitted.h.toFixed(1)}px`; + label.textContent = oriented.label; + button.title = `${oriented.label}: reshape to this ratio. The flip button on the left sets the orientation.`; + const candidate = chipMatch(width, height, oriented); + const match = candidate?.flipped ? null : candidate; + button.classList.toggle("exact", match?.state === "exact"); + button.classList.toggle("near", match?.state === "near"); + }); + + // Fields and handles honor converted-to-input axes. + const widthLinked = axisLinked(state, "width"); + const heightLinked = axisLinked(state, "height"); + const linked = widthLinked || heightLinked; + state.flipButton.disabled = linked; + state.swapButton.disabled = linked; + state.budgetControl.root.style.pointerEvents = linked ? "none" : ""; + state.budgetControl.root.style.opacity = linked ? ".5" : ""; + for (const { button } of state.chipButtons) button.disabled = linked; + state.widthControl.set(width); + state.heightControl.set(height); + state.budgetControl?.set(megapixels(width, height)); + state.widthField.classList.toggle("linked", widthLinked); + state.heightField.classList.toggle("linked", heightLinked); + state.widthField.title = widthLinked ? "Width is driven by its input link." : ""; + state.heightField.title = heightLinked ? "Height is driven by its input link." : ""; + state.handles.e.classList.toggle("linked", widthLinked); + state.handles.s.classList.toggle("linked", heightLinked); + state.handles.se.classList.toggle("linked", widthLinked || heightLinked); + + // Quick sizes follow the CURRENT ratio - the Pixaroma behavior, but the + // list is generated on-ratio/on-grid instead of hand-curated. Frozen + // while a gesture is live (a drag sweeps ratios; regenerating per move + // read as glitching), and a shape off the rail shows one "custom" chip. + if (!state.dragging) { + const onRail = state.railRatios?.some( + (railRatio) => chipMatch(width, height, railRatio)?.state === "exact", + ); + const quickKey = onRail ? `${ratio.x}:${ratio.y}/${snap}` : `custom/${snap}`; + if (state.quickKey !== quickKey) { + state.quickKey = quickKey; + rebuildQuick(state, width, height, snap, !onRail); + state.quickHint.textContent = onRail + ? `for ${ratioLabel(width, height)} · /${snap}` + : `custom · /${snap}`; + } + } + state.quickButtons.forEach(({ button, size }) => { + button.disabled = widthLinked || heightLinked; + button.classList.toggle("exact", size.width === width && size.height === height); + }); +} + +function showGhost(state, width, height) { + const box = stageBox(state); + const { width: w, height: h } = getSize(state); + const scale = stageScale(w, h, box.w, box.h); + state.ghost.style.width = `${width * scale}px`; + state.ghost.style.height = `${height * scale}px`; + state.ghost.style.display = ""; +} + +function hideGhost(state) { + state.ghost.style.display = "none"; +} + +function easeRefit(state) { + state.frozenScale = null; + state.rect.classList.add("easing"); + repaint(state); + clearTimeout(state.easeTimer); + state.easeTimer = setTimeout(() => state.rect.classList.remove("easing"), REFIT_MS + 30); +} + +// ---------- gestures ---------- + +function beginHandleDrag(state, handle, event) { + if (event.button !== 0 || state.disposed) return; + if ((handle !== "s" && axisLinked(state, "width")) || (handle !== "e" && axisLinked(state, "height"))) return; + event.preventDefault(); + event.stopPropagation(); + const start = getSize(state); + const box = stageBox(state); + // Frozen at pointerdown: the rect may overflow the stage mid-gesture and + // re-fits with a short ease on release - auto-fit never squirms mid-drag. + state.frozenScale = stageScale(start.width, start.height, box.w, box.h); + state.dragging = true; + const origin = { x: event.clientX, y: event.clientY }; + state.dragAbort?.abort(); + const abort = state.dragAbort = new AbortController(); + const zoom = state.stage.getBoundingClientRect().width / state.stage.offsetWidth || 1; + const move = (moveEvent) => { + const next = dragResize({ + handle, + startWidth: start.width, + startHeight: start.height, + dx: (moveEvent.clientX - origin.x) / zoom, + dy: (moveEvent.clientY - origin.y) / zoom, + scale: state.frozenScale, + snap: currentSnap(state), + snapOn: !moveEvent.altKey, + // The corner scales at the exact locked ratio; Shift frees both axes. + lockRatio: handle === "se" ? !moveEvent.shiftKey : false, + }); + commitSize(state, next.width, next.height, { gesture: true }); + }; + const finish = (commit) => { + abort.abort(); + state.dragging = false; + if (!commit) commitSize(state, start.width, start.height, { gesture: true }); + easeRefit(state); + if (commit) notifyAusbossChange(); + }; + // Window + capture phase, never element capture: the proven house drag + // pattern (see the LoRA loader's grip). + window.addEventListener("pointermove", move, { capture: true, signal: abort.signal }); + window.addEventListener("pointerup", () => finish(true), { capture: true, signal: abort.signal }); + window.addEventListener("pointercancel", () => finish(false), { capture: true, signal: abort.signal }); + window.addEventListener( + "keydown", + (keyEvent) => { + if (keyEvent.key === "Escape") { + keyEvent.preventDefault(); + keyEvent.stopPropagation(); + finish(false); // restore the pointer-down snapshot + } + }, + { capture: true, signal: abort.signal }, + ); +} + +// Adobe-style, the house norm: the value itself is the control - drag it to +// scrub by the snap step (Shift = 8px), click it to type, arrows to step. +// The cursor says which, so no instructions ride the header. Typed values +// are sacred: committed exactly, never re-snapped. +function sizeField(state, key) { + const field = el("div", "ausboss-res-field"); + const control = makeScrubInput({ + value: getSize(state)[key], + min: DIM_MIN, + max: DIM_MAX, + step: currentSnap(state), + fineStep: 8, + decimals: 0, + title: `${key === "width" ? "Width" : "Height"} in pixels.`, + onChange: (value) => { + const size = getSize(state); + state.dragging = true; // freezes the size ladder until the gesture settles + commitSize( + state, + key === "width" ? value : size.width, + key === "height" ? value : size.height, + { gesture: true }, + ); + }, + onSettle: () => { + state.dragging = false; + easeRefit(state); + notifyAusbossChange(); + }, + }); + const axis = el("span", "ausboss-res-axis", key === "width" ? "W" : "H"); + field.append(control.root, axis); + return { field, control }; +} + +function sectionHead(label, hint) { + const head = el("div", "ausboss-res-sechead"); + head.append(el("span", "label", label)); + const hintEl = el("span", "hint", hint ?? ""); + head.append(hintEl); + return { head, hintEl }; +} + +// ---------- build ---------- + +function buildPanel(state) { + state.dragAbort?.abort(); + state.dragging = false; + state.frozenScale = null; + const panel = state.panel; + panel.textContent = ""; + state.quickKey = null; // force the quick list to regenerate + + // (1) SHAPE - two distinct containers: orientation | ratio grid. + const shapeSec = el("div", "ausboss-res-sec"); + const shapeHead = sectionHead("Shape", ""); + shapeSec.append(shapeHead.head); + state.orientLabel = shapeHead.hintEl; + const shapeRow = el("div", "ausboss-res-shaperow"); + const flipBox = el("div", "ausboss-res-subbox ausboss-res-flipbox"); + const flip = el("button", "ausboss-res-flip"); + flip.type = "button"; + flip.setAttribute("aria-label", "Toggle landscape or portrait"); + const flipGlyph = el("span", "glyph"); + flip.append(flipGlyph); + flip.addEventListener("click", () => flipOrientation(state)); + flipBox.append(flip); + const chipBox = el("div", "ausboss-res-subbox ausboss-res-chipbox"); + const chipGrid = el("div", "ausboss-res-chipgrid"); + state.flipButton = flip; + state.flipGlyph = flipGlyph; + state.chipButtons = []; + state.railRatios = parseRail(state.settings?.rail); + for (const ratio of state.railRatios) { + const chip = el("button", "ausboss-res-chip"); + chip.type = "button"; + const glyph = el("span", "glyph"); + const label = el("span"); + chip.append(glyph, label); + // A chip click lands on that ratio's canonical ~1MP default (the + // Pixaroma behavior, derived instead of table-driven); the gear's + // "keep current budget" toggle restores the budget-preserving mode. + // Chips only pick the shape; orientation belongs to the flip button. + const chipTarget = () => { + const size = getSize(state); + const portrait = orientation(state); + if (state.settings?.chips_keep_budget) { + return applyRatioAtBudget(size.width, size.height, ratio, currentSnap(state), portrait); + } + return defaultSizeForRatio(orientRatio(ratio, portrait), currentSnap(state)); + }; + chip.addEventListener("click", () => { + const next = chipTarget(); + commitSize(state, next.width, next.height); + easeRefit(state); + }); + chip.addEventListener("pointerenter", () => { + const next = chipTarget(); + showGhost(state, next.width, next.height); + }); + chip.addEventListener("pointerleave", () => hideGhost(state)); + chipGrid.append(chip); + state.chipButtons.push({ button: chip, ratio, glyph, label }); + } + chipBox.append(chipGrid); + shapeRow.append(flipBox, chipBox); + shapeSec.append(shapeRow); + panel.append(shapeSec); + + // (2) STAGE + const stage = el("div", "ausboss-res-stage"); + stage.tabIndex = 0; + const dots = el("div", "ausboss-res-dots"); + const rings = svgEl("svg"); + rings.classList.add("ausboss-res-rings"); + const rect = el("div", "ausboss-res-rect"); + const ghost = el("div", "ausboss-res-ghost"); + // The readout rides the stage's top edge, never the rectangle: a tall + // narrow canvas has no room inside it for its own numbers. + const readout = el("div", "ausboss-res-readout"); + const dimLabel = el("span", "dims"); + const meta = el("span", "meta"); + const subMp = el("span", "mp"); + const subRatio = el("span"); + const offGridDot = el("span", "ausboss-res-offgrid"); + offGridDot.title = "Off the snap grid - gestures snap, typed values stay exact."; + meta.append(subMp, el("span", "", "·"), subRatio, offGridDot); + readout.append(dimLabel, meta); + state.handles = {}; + for (const handle of ["e", "s", "se"]) { + const grip = el("button", `ausboss-res-handle ${handle}`); + grip.type = "button"; + grip.title = handle === "se" + ? "Drag both (ratio locked) - Shift frees, Alt unsnaps, Esc cancels" + : handle === "e" + ? "Drag width - Alt unsnaps, Esc cancels" + : "Drag height - Alt unsnaps, Esc cancels"; + grip.addEventListener("pointerdown", (event) => beginHandleDrag(state, handle, event)); + rect.append(grip); + state.handles[handle] = grip; + } + stage.append(dots, rings, ghost, rect, readout); + stage.addEventListener("keydown", (event) => { + const keys = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, 1], ArrowDown: [0, -1] }; + const move = keys[event.key]; + if (!move) return; + event.preventDefault(); + event.stopPropagation(); + const step = event.shiftKey ? 8 : currentSnap(state); + const size = getSize(state); + commitSize( + state, + clampDim(size.width + move[0] * step), + clampDim(size.height + move[1] * step), // up = taller + ); + easeRefit(state); + }); + panel.append(stage); + + // (3) SIZE & BUDGET + const sizeSec = el("div", "ausboss-res-sec"); + sizeSec.append(sectionHead("Size & budget", "").head); + const controls = el("div", "ausboss-res-controls"); + const widthField = sizeField(state, "width"); + const heightField = sizeField(state, "height"); + const swap = el("button", "ausboss-res-btn", "⇄"); + swap.type = "button"; + state.swapButton = swap; + swap.title = "Swap width and height"; + swap.addEventListener("click", () => { + const size = getSize(state); + state.node.properties.ausbossResolutionPortrait = size.width > size.height; + commitSize(state, size.height, size.width); + easeRefit(state); + }); + // The budget is typeable: scrub or type megapixels and the canvas keeps + // its ratio, landing on the grid nearest that cost. + const budgetField = el("div", "ausboss-res-field ausboss-res-budget"); + const budget = makeScrubInput({ + value: megapixels(getSize(state).width, getSize(state).height), + min: 0.05, + max: 16, + step: 0.1, + fineStep: 0.01, + decimals: 2, + title: "Megapixels. Scrub or type a budget; the canvas keeps its ratio.", + onChange: (mp) => { + const size = getSize(state); + state.dragging = true; + const next = sizeForMegapixels(size.width, size.height, mp, currentSnap(state)); + commitSize(state, next.width, next.height, { gesture: true }); + }, + onSettle: () => { + state.dragging = false; + easeRefit(state); + notifyAusbossChange(); + }, + }); + budgetField.append(budget.root, el("span", "ausboss-res-axis", "MP")); + state.budgetControl = budget; + const gear = el("button", "ausboss-res-btn"); + gear.type = "button"; + gear.title = "Resolution settings"; + gear.innerHTML = gearIconSvg(); + gear.addEventListener("click", () => { + openSettingsMenu({ + scope: SETTINGS_SCOPE, + schema: SETTINGS_SCHEMA.map((entry) => { + const input = entry.key === "latent" ? "latent" : entry.key === "batch" ? "batch_size" : null; + return input && axisLinked(state, input) ? { section: `${entry.label} is linked` } : entry; + }), + anchor: gear.getBoundingClientRect(), + title: "Resolution settings", + initial: { + ...state.settings, + latent: state.latentWidget?.value ?? "16ch", + batch: Number(state.batchWidget?.value ?? 1), + }, + onChange: (values, key) => { + state.settings = values; + if (key === "latent" && state.latentWidget) { + state.latentWidget.value = values.latent; + state.latentWidget.callback?.(values.latent); + notifyAusbossChange(); + return; + } + if (key === "batch" && state.batchWidget) { + state.batchWidget.value = Math.round(values.batch); + state.batchWidget.callback?.(state.batchWidget.value); + notifyAusbossChange(); + return; + } + if (key === "default_snap" || key === null) { + // The gear owns the snap now; changes apply to this node too. + setSnap(state, values.default_snap); + } + buildPanel(state); + fitNode(state); + }, + }); + }); + controls.append(widthField.field, swap, heightField.field, budgetField, gear); + sizeSec.append(controls); + panel.append(sizeSec); + + // (4) SIZES - quick picks for the CURRENT ratio, regenerated on change. + const quickSec = el("div", "ausboss-res-sec"); + const quickHead = sectionHead("Sizes", ""); + quickSec.append(quickHead.head); + const quickWrap = el("div", "ausboss-res-quick"); + quickSec.append(quickWrap); + panel.append(quickSec); + state.quickHint = quickHead.hintEl; + state.quickWrap = quickWrap; + state.quickButtons = []; + + state.stage = stage; + state.dots = dots; + state.rings = rings; + state.rect = rect; + state.ghost = ghost; + state.dimLabel = dimLabel; + state.subMp = subMp; + state.subRatio = subRatio; + state.offGridDot = offGridDot; + state.widthField = widthField.field; + state.widthControl = widthField.control; + state.heightField = heightField.field; + state.heightControl = heightField.control; + + state.resizeObserver?.disconnect(); + state.resizeObserver = new ResizeObserver(() => { + if (!state.frozenScale) repaint(state); + }); + state.resizeObserver.observe(stage); + + repaint(state); +} + +function panelHeight(state) { + const section = (content) => SECTION_PAD_Y + SECTION_HEAD + content + 2; + const shape = section(CHIP_H * 2 + 5 + 8); // 2 chip rows + gap inside the subbox + const size = section(CONTROL_H); // one line: W, swap, H, gear + const quick = section(QUICK_H * 2 + 5); // 4x2 quick grid + return PANEL_PADDING * 2 + shape + SECTION_GAP + STAGE_MIN + SECTION_GAP + + size + SECTION_GAP + quick; +} + +function fitNode(state) { + const height = state.node.computeSize + ? state.node.computeSize()[1] + : panelHeight(state) + WIDGET_FRAME + 60; + state.node.setSize?.([FIXED_WIDTH, Math.max(height, state.node.size?.[1] || 0)]); + state.node.graph?.setDirtyCanvas(true, true); +} + +// ---------- install ---------- + +function installResolutionNode(node) { + installStyles(); + const widthWidget = node.widgets?.find((widget) => widget.name === "width"); + const heightWidget = node.widgets?.find((widget) => widget.name === "height"); + if (!widthWidget || !heightWidget) return; + hideWidget(widthWidget); + hideWidget(heightWidget); + // The latent family and batch size ride hidden widgets the gear sets, so + // the API format carries them like any other value. + const latentWidget = node.widgets?.find((widget) => widget.name === "latent"); + const batchWidget = node.widgets?.find((widget) => widget.name === "batch_size"); + if (latentWidget) hideWidget(latentWidget); + if (batchWidget) hideWidget(batchWidget); + + // W/H share the compact size bar: their sockets belong in the slot + // column, never stacked on that bar. The gear widgets live there too. + const liftSockets = () => { + for (const input of node.inputs ?? []) { + if (["width", "height", "latent", "batch_size"].includes(input.widget?.name)) { + delete input.widget; + input._widget = undefined; + } + } + }; + liftSockets(); + const panel = el("div", "ausboss-res-panel"); + const state = { + node, + widthWidget, + heightWidget, + latentWidget, + batchWidget, + panel, + settings: loadSettings(SETTINGS_SCOPE, SETTINGS_SCHEMA), + frozenScale: null, + quickKey: null, + }; + node.__ausbossResolution = state; + + const domWidget = node.addDOMWidget("ausboss_resolution_panel", "ausboss_resolution", panel, { + serialize: false, + hideOnZoom: false, + getMinHeight: () => panelHeight(state) + WIDGET_FRAME, + }); + keepDomWidgetWidthAuto(domWidget); + fillNodeHeight(domWidget, { + minWidth: FIXED_WIDTH, + minHeight: () => panelHeight(state) + WIDGET_FRAME, + minNodeSize: [FIXED_WIDTH, 400], + }); + + buildPanel(state); + for (const widget of [widthWidget, heightWidget, latentWidget, batchWidget]) { + if (widget) chainCallback(widget, "callback", () => { + requestAnimationFrame(() => { if (!state.disposed) { syncPrimitiveLinks(state); repaint(state); } }); + }); + } + // Fully fixed footprint, the Pixaroma lesson: resizable=false removes + // the corner affordance (no resize cursor at all), and the onResize + // clamp catches programmatic setSize. Every section is spaced for + // exactly this box; the SIZES grid reserves its two rows so the height + // never shifts. + node.resizable = false; + const fixedHeight = Math.max( + panelHeight(state) + WIDGET_FRAME + 86, + node.computeSize?.()[1] || 0, + ); + state.fixedHeight = fixedHeight; + node.setSize?.([FIXED_WIDTH, fixedHeight]); + chainCallback(node, "onResize", function () { + if (this.size?.[0] !== FIXED_WIDTH) this.size[0] = FIXED_WIDTH; + if (state.fixedHeight && this.size?.[1] !== state.fixedHeight) { + this.size[1] = state.fixedHeight; + } + }); + + chainCallback(node, "onConfigure", function () { + liftSockets(); + // Restore lands widget values after creation: re-read and repaint. A + // workflow saved before the latent widgets existed lands its panel's + // empty placeholder in their slots; put the defaults back. + if (latentWidget && !["16ch", "4ch", "128ch"].includes(latentWidget.value)) latentWidget.value = "16ch"; + if (batchWidget && !(Number.isInteger(batchWidget.value) && batchWidget.value >= 1)) batchWidget.value = 1; + requestAnimationFrame(() => { if (!state.disposed) { syncPrimitiveLinks(state); repaint(state); } }); + }); + chainCallback(node, "onConnectionsChange", function () { + // Convert-to-input honesty: link changes flip the read-only axes. + requestAnimationFrame(() => { if (!state.disposed) { syncPrimitiveLinks(state); repaint(state); } }); + }); + chainCallback(node, "onRemoved", () => { + state.disposed = true; + state.dragAbort?.abort(); + state.resizeObserver?.disconnect(); + clearTimeout(state.easeTimer); + }); +} + +app.registerExtension({ + name: "AusBoss.Resolution", + beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData?.name !== NODE_CLASS) return; + hideInputsInDef(nodeData, ["width", "height", "latent", "batch_size"]); + chainCallback(nodeType.prototype, "onNodeCreated", function () { + installResolutionNode(this); + }); + }, +}); diff --git a/js/run_timer/index.js b/js/run_timer/index.js new file mode 100644 index 0000000..31261e8 --- /dev/null +++ b/js/run_timer/index.js @@ -0,0 +1,427 @@ +// Run Timer 🆎 — a stopwatch for the whole queue, drawn as one black readout. +// +// The node IS the display: no title bar, no slots, no padding. On the +// classic canvas the readout is painted by the node itself (onDrawForeground) +// - no DOM widget, so the pointer lands on the canvas everywhere (drag from +// anywhere, native resize, right-click) and the box is exactly the node. +// The Nodes 2.0 renderer does not run canvas paint, so there the same +// readout mounts as a DOM widget instead. +// +// It listens to the api's execution events; a run starts the clock, ticks +// it, and the total holds when the run ends. Completed totals go into +// node.properties so a saved workflow reopens showing its author's time. +// Decision logic lives in js/shared/run_timer.mjs under node:test. + +import { api } from "/scripts/api.js"; +import { app } from "/scripts/app.js"; +import { BRAND, chainCallback, keepDomWidgetWidthAuto } from "../shared/index.mjs"; +import { + createTimer, + formatElapsed, + startTimer, + stopTimer, + tickTimer, +} from "../shared/run_timer.mjs"; + +const NODE_CLASS = "AUSBOSS_NODES_RunTimer"; +const CSS_ID = "ausboss-run-timer-css"; +const WIDGET_NAME = "ausboss_run_timer_panel"; +const PROPERTY = "ausboss_run_timer"; // { history: [seconds, newest first] } +const BASE_WIDTH = 176; +const BASE_HEIGHT = 56; +const MIN_WIDTH = 110; +const TICK_MS = 100; +const NO_TITLE = globalThis.LiteGraph?.NO_TITLE ?? 1; +const READOUT_COLOR = "#07090a"; +const TEAL = "#2ee8dc"; +const AMBER = "#ffd166"; +const RED = "#ff5c4d"; +const DIM = "#3d5a58"; +const MONO = 'ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace'; + +// --------------------------------------------------------------------------- +// Shared state and events +// --------------------------------------------------------------------------- + +function savedHistory(node) { + const stored = node.properties?.[PROPERTY]; + return Array.isArray(stored?.history) ? stored.history : []; +} + +// "1:05" + ".3", or "12" + ".3" + "s": the whole seconds carry the weight, +// the tenths ride small beside them, hours fold in as h:mm:ss. +function splitDigits(seconds) { + if (!Number.isFinite(seconds) || seconds < 0) return { main: "0", tenths: ".0", unit: "s" }; + const whole = Math.floor(seconds); + const tenths = `.${Math.floor((seconds - whole) * 10)}`; + if (whole < 60) return { main: String(whole), tenths, unit: "s" }; + const hours = Math.floor(whole / 3600); + const minutes = Math.floor((whole % 3600) / 60); + const rest = String(whole % 60).padStart(2, "0"); + if (hours) return { main: `${hours}:${String(minutes).padStart(2, "0")}:${rest}`, tenths: "", unit: "" }; + return { main: `${minutes}:${rest}`, tenths, unit: "" }; +} + +// What the readout shows right now: the digits, the LED status and the +// digit color - one place, shared by the canvas paint and the DOM panel. +function readout(state) { + const { timer } = state; + const hasRun = timer.running || timer.outcome !== null || timer.history.length > 0; + const shown = timer.running || timer.outcome ? timer.elapsed : timer.history[0]; + const parts = splitDigits(hasRun ? shown : 0); + let led = "idle"; + if (timer.running) led = "running"; + else if (timer.outcome) led = timer.outcome; + else if (hasRun) led = "done"; + const failed = timer.outcome === "error" || timer.outcome === "interrupted"; + return { ...parts, led, color: failed ? "#ff8a7d" : hasRun ? TEAL : DIM, hasRun }; +} + +function stopTicking(state) { + if (state.interval) clearInterval(state.interval); + state.interval = null; +} + +function finish(state, outcome) { + if (!state.timer.running) return; + stopTicking(state); + state.timer = stopTimer(state.timer, performance.now(), outcome); + if (outcome === "done") { + state.node.properties ??= {}; + state.node.properties[PROPERTY] = { history: state.timer.history }; + } + state.render(); +} + +function begin(state) { + stopTicking(state); + state.timer = startTimer(state.timer, performance.now()); + state.render(); + state.interval = setInterval(() => { + state.timer = tickTimer(state.timer, performance.now()); + state.render(); + }, TICK_MS); +} + +function listen(state) { + const { signal } = state.abort; + api.addEventListener("execution_start", () => begin(state), { signal }); + api.addEventListener("execution_success", () => finish(state, "done"), { signal }); + api.addEventListener("execution_error", () => finish(state, "error"), { signal }); + api.addEventListener("execution_interrupted", () => finish(state, "interrupted"), { signal }); +} + +// The frontend hangs a "which pack" badge above every custom node; on a +// title-less readout it floats in space. An empty badges list draws none. +function suppressBadges(node) { + try { + Object.defineProperty(node, "badges", { + configurable: true, + get: () => [], + set: () => {}, + }); + } catch { + node.badges = []; + } +} + +function paintBlack(node) { + node.color = READOUT_COLOR; + node.bgcolor = READOUT_COLOR; +} + +function usesVueNodes() { + try { + return app.ui?.settings?.getSettingValue?.("Comfy.VueNodes.Enabled") === true; + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Classic canvas: the node paints its own readout +// --------------------------------------------------------------------------- + +function ledColor(status) { + if (status === "running") return AMBER; + if (status === "done") return TEAL; + if (status === "error" || status === "interrupted") return RED; + return "#2b3336"; +} + +// Glow as a blurred underlay pass, not a canvas shadow: LiteGraph leaves +// shadow offsets set from painting the node body, and a shadow drawn on +// top of that lands as a displaced copy of every glyph. +function glowText(ctx, text, x, y, color, radius) { + if (radius <= 0 || !("filter" in ctx)) return; + ctx.save(); + ctx.filter = `blur(${radius}px)`; + ctx.globalAlpha = 0.85; + ctx.fillStyle = color; + ctx.fillText(text, x, y); + ctx.restore(); +} + +function drawReadout(node, ctx, state) { + const [w, h] = node.size; + const s = h / BASE_HEIGHT; + const view = readout(state); + const big = Math.max(10, Math.round(28 * s)); + const small = Math.max(7, Math.round(15 * s)); + const tiny = Math.max(6, Math.round(10 * s)); + ctx.save(); + ctx.shadowColor = "transparent"; + ctx.shadowBlur = 0; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + ctx.textBaseline = "alphabetic"; + ctx.textAlign = "left"; + + // LED with its own halo + const led = ledColor(view.led); + const pulse = view.led === "running" ? 0.55 + 0.45 * Math.sin(performance.now() / 160) : 1; + const ledX = 12 * s; + const ledY = h / 2; + if (view.led !== "idle") { + ctx.save(); + ctx.filter = `blur(${4 * s}px)`; + ctx.globalAlpha = 0.9 * pulse; + ctx.fillStyle = led; + ctx.beginPath(); + ctx.arc(ledX, ledY, 4.5 * s, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + ctx.globalAlpha = pulse; + ctx.fillStyle = led; + ctx.beginPath(); + ctx.arc(ledX, ledY, 3.2 * s, 0, Math.PI * 2); + ctx.fill(); + ctx.globalAlpha = 1; + + // digits, measured then centred in the space right of the LED + const fontBig = `700 ${big}px ${MONO}`; + const fontSmall = `600 ${small}px ${MONO}`; + const fontTiny = `500 ${tiny}px ${MONO}`; + ctx.font = fontBig; + const mainW = ctx.measureText(view.main).width; + ctx.font = fontSmall; + const tenthsW = view.tenths ? ctx.measureText(view.tenths).width : 0; + ctx.font = fontTiny; + const unitW = view.unit ? ctx.measureText(view.unit).width + 3 * s : 0; + const total = mainW + tenthsW + unitW; + const left = 22 * s; + const x0 = left + Math.max(0, (w - left - 8 * s - total) / 2); + const baseline = h / 2 + big * 0.36; + const glow = view.hasRun ? 5 * s : 0; + + ctx.font = fontBig; + glowText(ctx, view.main, x0, baseline, view.color, glow); + ctx.fillStyle = view.color; + ctx.fillText(view.main, x0, baseline); + let x = x0 + mainW; + if (view.tenths) { + ctx.font = fontSmall; + glowText(ctx, view.tenths, x, baseline, view.color, glow * 0.8); + ctx.globalAlpha = 0.85; + ctx.fillStyle = view.color; + ctx.fillText(view.tenths, x, baseline); + ctx.globalAlpha = 1; + x += tenthsW; + } + if (view.unit) { + ctx.font = fontTiny; + ctx.globalAlpha = 0.6; + ctx.fillStyle = view.color; + ctx.fillText(view.unit, x + 3 * s, baseline); + ctx.globalAlpha = 1; + } + ctx.restore(); +} + +// Width is the handle, height follows. During a corner drag LiteGraph +// writes node.size directly on every move and calls onResize once at +// most, so this runs from the draw path as well: a tall or squat box is +// corrected before the next frame, the way Pixaroma's clock snaps back. +function keepAspect(node) { + const width = Math.max(MIN_WIDTH, Math.round(node.size?.[0] ?? BASE_WIDTH)); + const height = Math.round((width * BASE_HEIGHT) / BASE_WIDTH); + if (node.size[0] === width && node.size[1] === height) return false; + // setSize is the frontend's sanctioned write (it runs onResize); the + // equality check above is what stops that callback from recursing. + if (typeof node.setSize === "function") node.setSize([width, height]); + else node.size = [width, height]; + return true; +} + +function installCanvasReadout(node) { + if (node.__ausbossRunTimer) return node.__ausbossRunTimer; + node.flags ??= {}; + node.flags.no_title = true; + suppressBadges(node); + paintBlack(node); + const state = (node.__ausbossRunTimer = { + node, abort: new AbortController(), interval: null, mode: "canvas", + timer: createTimer(savedHistory(node)), + render: () => node.setDirtyCanvas?.(true, false), + }); + // Width is the handle: drag the corner and the readout scales with it. + // computeSize is the MINIMUM the canvas will let a drag reach - it must + // never echo the current size, or the node can only ever grow. + node.resizable = true; + node.computeSize = () => [MIN_WIDTH, Math.round((MIN_WIDTH * BASE_HEIGHT) / BASE_WIDTH)]; + // A saved workflow overwrites this in onConfigure; a fresh node starts here. + node.size = [BASE_WIDTH, BASE_HEIGHT]; + chainCallback(node, "onResize", function () { keepAspect(this); }); + chainCallback(node, "onDrawForeground", function (ctx) { + if (this.flags?.collapsed) return; + // The body was already painted at whatever size the drag left; fix + // the size now and ask for one more frame so the box catches up. + if (keepAspect(this)) this.setDirtyCanvas?.(true, false); + drawReadout(this, ctx, state); + }); + listen(state); + return state; +} + +// --------------------------------------------------------------------------- +// Nodes 2.0 fallback: the same readout as a DOM panel +// --------------------------------------------------------------------------- + +function ensureCss() { + if (document.getElementById(CSS_ID)) return; + const style = document.createElement("style"); + style.id = CSS_ID; + style.textContent = ` +.ausboss-timer{box-sizing:border-box;width:100%;height:100%;pointer-events:none;overflow:hidden;} +.ausboss-timer-box{box-sizing:border-box;display:flex;align-items:center;justify-content:center;gap:10px;width:100%;height:100%;padding:0 12px;overflow:hidden;border-radius:9px;background:${READOUT_COLOR};} +.ausboss-timer-led{flex:none;width:7px;height:7px;border-radius:50%;background:#2b3336;} +.ausboss-timer-led.running{background:${AMBER};box-shadow:0 0 8px rgba(255,209,102,.9);animation:ausboss-timer-pulse 1s ease-in-out infinite;} +.ausboss-timer-led.done{background:${BRAND};box-shadow:0 0 8px rgba(0,180,170,.8);} +.ausboss-timer-led.error,.ausboss-timer-led.interrupted{background:${RED};box-shadow:0 0 8px rgba(255,92,77,.8);} +@keyframes ausboss-timer-pulse{0%,100%{opacity:1;}50%{opacity:.35;}} +.ausboss-timer-digits{display:flex;align-items:baseline;color:${TEAL};font:700 28px/1 ${MONO};font-variant-numeric:tabular-nums;letter-spacing:.02em;text-shadow:0 0 12px rgba(46,232,220,.35);white-space:nowrap;} +.ausboss-timer-digits.idle{color:${DIM};text-shadow:none;} +.ausboss-timer-digits.error{color:#ff8a7d;text-shadow:0 0 12px rgba(255,92,77,.35);} +.ausboss-timer-digits .tenths{font-size:15px;margin-left:2px;opacity:.78;} +.ausboss-timer-digits .unit{font-size:10px;margin-left:5px;letter-spacing:.12em;opacity:.55;} +/* Nodes 2.0 only: the Vue node wraps this panel in a 225px-minimum body and + hangs its own pack badge in a footer row; the readout is the whole node. */ +.lg-node:has(.ausboss-timer){min-width:0!important;} +.lg-node:has(.ausboss-timer) .bg-component-node-background>div:last-child{display:none;} +`; + document.head.appendChild(style); +} + +function installDomReadout(node) { + if (node.__ausbossRunTimer) return node.__ausbossRunTimer; + ensureCss(); + node.flags ??= {}; + node.flags.no_title = true; + suppressBadges(node); + paintBlack(node); + + const root = document.createElement("div"); + root.className = "ausboss-timer"; + const box = document.createElement("div"); + box.className = "ausboss-timer-box"; + const led = document.createElement("span"); + led.className = "ausboss-timer-led"; + const digits = document.createElement("span"); + digits.className = "ausboss-timer-digits idle"; + const main = document.createElement("span"); + const tenths = document.createElement("span"); + tenths.className = "tenths"; + const unit = document.createElement("span"); + unit.className = "unit"; + digits.append(main, tenths, unit); + box.append(led, digits); + root.append(box); + + const widget = node.addDOMWidget(WIDGET_NAME, "ausboss_run_timer", root, { + serialize: false, + hideOnZoom: false, + margin: 0, + getMinHeight: () => BASE_HEIGHT, + }); + keepDomWidgetWidthAuto(widget); + // A constant-height readout, not a viewport: pinned on purpose (see + // tests/panel_guards.test.mjs, fixedByDesign). + widget.computeSize = (width) => [Math.max(MIN_WIDTH, Number(width || node.size?.[0] || BASE_WIDTH)), BASE_HEIGHT]; + widget.computeLayoutSize = () => ({ minWidth: MIN_WIDTH, minHeight: BASE_HEIGHT }); + widget.options.minNodeSize = [MIN_WIDTH, BASE_HEIGHT]; + + const state = (node.__ausbossRunTimer = { + node, abort: new AbortController(), interval: null, mode: "dom", + timer: createTimer(savedHistory(node)), + render: () => { + const view = readout(state); + main.textContent = view.main; + tenths.textContent = view.tenths; + unit.textContent = view.unit; + led.className = `ausboss-timer-led ${view.led === "idle" ? "" : view.led}`; + digits.className = "ausboss-timer-digits"; + if (!view.hasRun) digits.classList.add("idle"); + if (view.led === "error" || view.led === "interrupted") digits.classList.add("error"); + }, + }); + state.render(); + listen(state); + return state; +} + +function install(node) { + return usesVueNodes() ? installDomReadout(node) : installCanvasReadout(node); +} + +app.registerExtension({ + name: "AusBoss.RunTimer", + beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData?.name !== NODE_CLASS) return; + // Set once on the TYPE at registration: the classic renderer reads it + // per draw, and the Nodes 2.0 renderer snapshots it when the node is + // created, so a per-node write would come too late there. + nodeType.title_mode = NO_TITLE; + chainCallback(nodeType.prototype, "onNodeCreated", function () { + install(this); + }); + chainCallback(nodeType.prototype, "onConfigure", function () { + // Saved properties land after onNodeCreated: pick the history up then, + // and keep the readout black however the save was colored. + queueMicrotask(() => { + const state = install(this); + if (!state) return; + if (!state.timer.running) state.timer = createTimer(savedHistory(this)); + this.flags.no_title = true; + paintBlack(this); + if (state.mode === "canvas") keepAspect(this); + state.render(); + }); + }); + chainCallback(nodeType.prototype, "getExtraMenuOptions", function (_canvas, options) { + const state = this.__ausbossRunTimer; + if (!state || !Array.isArray(options)) return; + const previous = state.timer.history.slice(0, 5).map(formatElapsed).join(" · "); + options.unshift( + { content: previous ? `Runs: ${previous}` : "No completed runs yet", disabled: true }, + { + content: "Reset Run Timer history", + callback: () => { + state.timer = createTimer([]); + this.properties ??= {}; + this.properties[PROPERTY] = { history: [] }; + state.render(); + }, + }, + null, + ); + }); + chainCallback(nodeType.prototype, "onRemoved", function () { + const state = this.__ausbossRunTimer; + if (!state) return; + stopTicking(state); + state.abort.abort(); + this.__ausbossRunTimer = null; + }); + }, +}); diff --git a/js/save_image/index.js b/js/save_image/index.js new file mode 100644 index 0000000..08f2622 --- /dev/null +++ b/js/save_image/index.js @@ -0,0 +1,366 @@ +// Save Image 🆎: one compact card in place of the widget stack. +// +// Folder (with a Browse into ComfyUI's output folder), Filename, a live +// Path preview, the name tags as small chips, the Format pills and the +// Embed workflow switch. The standard widgets stay the source of truth and +// stay serialized in their old order; the card only reads and writes them. +// When the `filename` input is linked the name comes from upstream: the +// field shows {{filename}} and the tags fold away, since an exact name is +// never decorated. +import { api } from "/scripts/api.js"; +import { app } from "/scripts/app.js"; +import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs"; +import { hideInputsInDef, hideWidget } from "../shared/widget_visibility.mjs"; +import { ensureCardCss } from "../shared/widget_card.mjs"; +import { + FORMAT_LABELS, TAGS, namingMode, previewFolder, previewName, tagsEnabled, +} from "../shared/save_image_naming.mjs"; + +const NODE_CLASS = "AUSBOSS_NODES_SaveImage"; +const WIDGET_NAME = "ausboss_save_image_card"; +const CSS_ID = "ausboss-save-image-css"; +const NODE_MIN_WIDTH = 340; +// Captions, fields, the two-line preview, chips, pills, the switch row and +// the gaps between them; the frontend's wrapper insets ride on top. +const CARD_HEIGHT = 288; +const WRAPPER_INSET = 16; +const HIDDEN = [ + "filename_prefix", "format", "save_metadata", "exact_name", "on_existing", + "output_dir", "caption", "name_counter", "name_date", "name_time", "name_size", "name_batch", +]; +const FONT = `-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif`; +const MONO = `ui-monospace,SFMono-Regular,Menlo,Consolas,monospace`; + +function ensureCss() { + ensureCardCss(); + if (document.getElementById(CSS_ID)) return; + const style = document.createElement("style"); + style.id = CSS_ID; + style.textContent = ` +.ausboss-save{box-sizing:border-box;width:100%;height:100%;display:flex;flex-direction:column;gap:6px;padding:8px 9px;overflow:hidden;border:1px solid rgba(0,180,170,.22);border-radius:8px;background:rgba(0,0,0,.28);font:12px/1.3 ${FONT};color:#c8dddd;pointer-events:none} +.ausboss-save-cap{height:14px;flex:none;color:#5f7674;font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;user-select:none} +.ausboss-save-row{display:flex;align-items:center;gap:6px;flex:none;height:26px;pointer-events:auto} +.ausboss-save .ausboss-card-seg.ausboss-save-format{height:36px;flex:none;pointer-events:auto} +.ausboss-save .ausboss-save-format button{font-size:12px} +.ausboss-save-row.linked .ausboss-card-text{color:#8ba3a1;font-family:${MONO}} +.ausboss-save-tag{flex:none;color:${BRAND};font-size:11px;padding:0 4px;user-select:none} +.ausboss-save-preview{flex:none;padding:6px 9px;border:1px solid #2a3437;border-radius:6px;background:#0b0f10;font-family:${MONO};overflow:hidden;pointer-events:auto} +.ausboss-save-preview .folder{color:#5f7674;font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.ausboss-save-preview .name{color:#d8ecea;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:2px} +.ausboss-save-preview.saved .name{color:${BRAND}} +.ausboss-save-chips{display:flex;flex-wrap:wrap;gap:5px;flex:none;pointer-events:auto} +.ausboss-save-chip{height:22px;padding:0 9px;border:1px solid #3a4047;border-radius:6px;background:transparent;color:#8ba3a1;font:600 10.5px/1 ${FONT};cursor:pointer;white-space:nowrap} +.ausboss-save-chip:hover{color:#fff;border-color:${BRAND}} +.ausboss-save-chip.on{background:${BRAND};border-color:${BRAND};color:#04201d} +.ausboss-save-chips.off .ausboss-save-chip{opacity:.4;pointer-events:none} +.ausboss-save-switch-row{display:flex;align-items:center;justify-content:space-between;flex:none;height:26px;padding:0 2px;pointer-events:auto} +.ausboss-save-switch{flex:none;width:30px;height:16px;border-radius:8px;border:none;background:#3a4047;cursor:pointer;position:relative;padding:0} +.ausboss-save-switch::after{content:"";position:absolute;top:2px;left:2px;width:12px;height:12px;border-radius:50%;background:#9ba2aa;transition:left .12s} +.ausboss-save-switch.on{background:${BRAND}} +.ausboss-save-switch.on::after{left:16px;background:#fff} +.ausboss-save-switch:focus-visible{outline:2px solid ${BRAND};outline-offset:2px} +.ausboss-save-switch-row span{color:#8ba3a1;font-size:11px;user-select:none} +.ausboss-save-browse{position:fixed;z-index:10000;width:260px;max-height:320px;overflow:auto;padding:4px;border:1px solid #3a4047;border-radius:7px;background:#1c1f23;box-shadow:0 8px 28px rgba(0,0,0,.5);font:12px/1.3 ${FONT};color:#d8ecea} +.ausboss-save-browse-head{padding:5px 8px 6px;border-bottom:1px solid #2c3238;color:#78908e;font:10px/1 ${FONT};letter-spacing:.08em;text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.ausboss-save-browse button{display:block;width:100%;padding:6px 8px;border:none;border-radius:5px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer} +.ausboss-save-browse button:hover{background:#2a3037} +.ausboss-save-browse button.use{color:${BRAND};font-weight:600} +`; + document.head.append(style); +} + +const el = (tag, className = "", text) => { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +}; + +function widget(node, name) { + return node.widgets?.find((item) => item.name === name) ?? null; +} + +function value(node, name, fallback) { + const target = widget(node, name); + return target ? target.value : fallback; +} + +function filenameLinked(node) { + return Boolean(node.inputs?.some((input) => input?.name === "filename" && input.link !== null && input.link !== undefined)); +} + +function setValue(node, name, next) { + const target = widget(node, name); + if (!target || target.value === next) return false; + target.value = next; + target.callback?.(next, app.canvas, node); + node.graph?.setDirtyCanvas?.(true, true); + return true; +} + +function readValues(node) { + const values = {}; + for (const name of HIDDEN) values[name] = value(node, name, undefined); + values.filenameLinked = filenameLinked(node); + return values; +} + +// ---------- the Browse popup: subfolders of ComfyUI's output folder ---------- + +let openPopup = null; +function closeBrowse() { + openPopup?.remove(); + openPopup = null; +} + +async function listFolders(path) { + const response = await api.fetchApi(`/ausboss/save_image/folders?path=${encodeURIComponent(path)}`); + if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || "Could not list folders."); + return response.json(); +} + +async function openBrowse(state, anchor) { + closeBrowse(); + const popup = el("div", "ausboss-save-browse"); + const rect = anchor.getBoundingClientRect(); + popup.style.left = `${Math.max(8, Math.min(rect.left, window.innerWidth - 270))}px`; + popup.style.top = `${rect.bottom + 4}px`; + document.body.append(popup); + openPopup = popup; + const current = String(value(state.node, "output_dir", "")).trim().replace(/\\/g, "/"); + let path = /^([A-Za-z]:[\\/]|\/|~)/.test(current) ? "" : current.replace(/^\/+|\/+$/g, ""); + const render = async () => { + popup.textContent = ""; + popup.append(el("div", "ausboss-save-browse-head", `ComfyUI/output/${path}`)); + let listing; + try { + listing = await listFolders(path); + } catch (error) { + popup.append(el("div", "ausboss-save-browse-head", error.message)); + return; + } + path = listing.path; + const use = el("button", "use", path ? `Use “${path}”` : "Use the output folder"); + use.addEventListener("click", () => { setValue(state.node, "output_dir", path); state.refresh(); notifyAusbossChange(); closeBrowse(); }); + popup.append(use); + if (path) { + const up = el("button", "", "↑ up one level"); + up.addEventListener("click", () => { path = path.split("/").slice(0, -1).join("/"); render(); }); + popup.append(up); + } + for (const folder of listing.folders) { + const button = el("button", "", `📁 ${folder}`); + button.addEventListener("click", () => { path = path ? `${path}/${folder}` : folder; render(); }); + popup.append(button); + } + if (!listing.folders.length) popup.append(el("div", "ausboss-save-browse-head", "no subfolders here")); + }; + await render(); + const dismiss = (event) => { + if (popup.contains(event.target) || event.target === anchor) return; + closeBrowse(); + window.removeEventListener("pointerdown", dismiss, true); + }; + window.addEventListener("pointerdown", dismiss, true); +} + +// ---------- the card ---------- + +function textField(state, name, placeholder, title) { + const input = el("input", "ausboss-card-text"); + input.type = "text"; input.placeholder = placeholder; input.title = title; input.spellcheck = false; + const commit = () => { + if (input.disabled) return; + if (setValue(state.node, name, input.value.trim())) { state.savedPath = null; notifyAusbossChange(); } + state.refresh(); + }; + input.addEventListener("change", commit); + input.addEventListener("blur", commit); + input.addEventListener("keydown", (event) => { + event.stopPropagation(); + if (event.key === "Enter") input.blur(); + if (event.key === "Escape") { input.value = String(value(state.node, name, "")); input.blur(); } + }); + return input; +} + +function buildCard(node) { + ensureCss(); + const root = el("div", "ausboss-save"); + const state = { node, root, savedPath: null, alive: true }; + node.__ausbossSaveImage = state; + for (const name of HIDDEN) { + const target = widget(node, name); + if (!target) continue; + hideWidget(target); + // The legacy caption is a multiline DOM widget: collapsing its layout is + // not enough, its textarea has to go too. Its value still saves. + if (target.element) target.element.style.display = "none"; + } + + // Folder + root.append(el("div", "ausboss-save-cap", "Folder")); + const folderRow = el("div", "ausboss-save-row"); + const folder = textField(state, "output_dir", "ComfyUI/output", "Where to save. Empty is ComfyUI's output folder; a relative path is a subfolder of it; an absolute path saves anywhere you can write."); + const browse = el("button", "ausboss-card-btn", "Browse"); + browse.title = "Pick a subfolder of ComfyUI's output folder"; + browse.addEventListener("click", () => openBrowse(state, browse)); + folderRow.append(folder, browse); + root.append(folderRow); + + // Filename + root.append(el("div", "ausboss-save-cap", "Filename")); + const nameRow = el("div", "ausboss-save-row"); + const nameField = textField(state, "filename_prefix", "image", "The local filename; subfolders are allowed. The tags below are appended to it."); + const nameTag = el("span", "ausboss-save-tag", ""); + nameRow.append(nameField, nameTag); + root.append(nameRow); + + // Path preview + root.append(el("div", "ausboss-save-cap", "Path preview")); + const preview = el("div", "ausboss-save-preview"); + const previewFolderLine = el("div", "folder"); + const previewNameLine = el("div", "name"); + preview.append(previewFolderLine, previewNameLine); + preview.title = "The name this save will produce; after a run, the first file it actually wrote."; + root.append(preview); + + // Tags + const chips = el("div", "ausboss-save-chips"); + const chipButtons = new Map(); + for (const tag of TAGS) { + const chip = el("button", "ausboss-save-chip", tag.label); + chip.type = "button"; chip.title = tag.title; + chip.addEventListener("click", () => { + if (setValue(node, tag.key, !Boolean(value(node, tag.key, false)))) { state.savedPath = null; notifyAusbossChange(); } + state.refresh(); + }); + chips.append(chip); chipButtons.set(tag.key, chip); + } + root.append(chips); + + // Format: the pill alone, tall enough to read as the one big choice on + // the card - the three names say what it is. + const seg = el("div", "ausboss-card-seg ausboss-save-format"); + const formatButtons = new Map(); + const formatTitles = { + png: "Lossless and most compatible. Preserves alpha and can embed the workflow for drag-back into ComfyUI.", + "webp lossless": "Lossless and usually smaller than PNG. Preserves alpha; the workflow rides in EXIF.", + "jxl lossless": "Lossless and compact. Needs pillow-jxl-plugin in ComfyUI's python; browser and node preview support is limited.", + }; + for (const [key, label] of Object.entries(FORMAT_LABELS)) { + const button = el("button", "", label); + button.type = "button"; button.title = formatTitles[key] ?? key; + button.addEventListener("click", () => { if (setValue(node, "format", key)) { state.savedPath = null; notifyAusbossChange(); } state.refresh(); }); + seg.append(button); formatButtons.set(key, button); + } + root.append(seg); + + // Embed workflow + const switchRow = el("div", "ausboss-save-switch-row"); + const switchLabel = el("span", "", "Embed workflow"); + const toggle = el("button", "ausboss-save-switch"); + toggle.type = "button"; toggle.setAttribute("role", "switch"); + toggle.title = "On stores the prompt and workflow in the image for drag-back. Off writes a clean image for sharing or datasets."; + toggle.addEventListener("click", () => { if (setValue(node, "save_metadata", !Boolean(value(node, "save_metadata", true)))) notifyAusbossChange(); state.refresh(); }); + switchRow.append(switchLabel, toggle); + root.append(switchRow); + + root.addEventListener("pointerdown", (event) => { + if (event.target.closest("button,input,select")) event.stopPropagation(); + }); + + state.refresh = () => { + if (!state.alive) return; + const values = readValues(node); + const mode = namingMode({ filenameLinked: values.filenameLinked, exactName: values.exact_name }); + if (document.activeElement !== folder) folder.value = String(values.output_dir ?? ""); + nameRow.classList.toggle("linked", mode === "linked"); + nameField.disabled = mode === "linked"; + if (mode === "linked") { + nameField.value = "{{filename}}"; nameTag.textContent = "linked"; + nameField.title = "The name comes from the filename input."; + } else if (mode === "exact") { + if (document.activeElement !== nameField) nameField.value = String(values.exact_name ?? ""); + nameTag.textContent = "exact"; + nameField.title = "Legacy exact_name: saved under exactly this name, no tags. Clear it to compose the name here."; + } else { + if (document.activeElement !== nameField) nameField.value = String(values.filename_prefix ?? ""); + nameTag.textContent = ""; + nameField.title = "The local filename; subfolders are allowed. The tags below are appended to it."; + } + // An exact-mode edit writes the exact name; a local-mode edit the prefix. + nameField.dataset.target = mode === "exact" ? "exact_name" : "filename_prefix"; + if (state.savedPath) { + const slash = state.savedPath.lastIndexOf("/"); + previewFolderLine.textContent = slash >= 0 ? (state.savedPath.startsWith("/") ? state.savedPath.slice(0, slash + 1) : `ComfyUI/output/${state.savedPath.slice(0, slash + 1)}`) : "ComfyUI/output/"; + previewNameLine.textContent = state.savedPath.slice(slash + 1); + preview.classList.add("saved"); + } else { + previewFolderLine.textContent = previewFolder(values); + previewNameLine.textContent = previewName(values); + preview.classList.remove("saved"); + } + chips.classList.toggle("off", !tagsEnabled(values)); + for (const [key, chip] of chipButtons) chip.classList.toggle("on", Boolean(values[key])); + for (const [key, button] of formatButtons) button.classList.toggle("on", values.format === key); + const embed = Boolean(values.save_metadata); + toggle.classList.toggle("on", embed); toggle.setAttribute("aria-checked", String(embed)); + }; + // The one field serves both the prefix and a legacy exact name. + nameField.addEventListener("change", () => { + const target = nameField.dataset.target; + if (target === "exact_name") { + if (setValue(node, "exact_name", nameField.value.trim())) { state.savedPath = null; notifyAusbossChange(); } + } + state.refresh(); + }); + + const domWidget = node.addDOMWidget(WIDGET_NAME, WIDGET_NAME, root, { + serialize: false, + hideOnZoom: false, + getMinHeight: () => CARD_HEIGHT + WRAPPER_INSET, + }); + keepDomWidgetWidthAuto(domWidget); + // A constant-height card, pinned on purpose (tests/panel_guards.test.mjs, + // fixedByDesign): fields and pills, not a stage that follows the node. + domWidget.computeSize = (width) => [Math.max(NODE_MIN_WIDTH, Number(width || node.size?.[0] || NODE_MIN_WIDTH)), CARD_HEIGHT + WRAPPER_INSET]; + domWidget.computeLayoutSize = () => ({ minWidth: NODE_MIN_WIDTH, minHeight: CARD_HEIGHT + WRAPPER_INSET }); + domWidget.options.minNodeSize = [NODE_MIN_WIDTH, 120]; + // Ahead of the legacy caption textarea, which stays hidden underneath. + const index = node.widgets.indexOf(domWidget); + const firstDom = node.widgets.findIndex((item) => item !== domWidget && item.element); + if (index >= 0 && firstDom >= 0 && firstDom < index) { + node.widgets.splice(index, 1); node.widgets.splice(firstDom, 0, domWidget); + } + + for (const name of HIDDEN) { + const target = widget(node, name); + if (target) chainCallback(target, "callback", () => state.refresh()); + } + chainCallback(node, "onConfigure", () => queueMicrotask(() => { + for (const name of HIDDEN) { const target = widget(node, name); if (target) { hideWidget(target); if (target.element) target.element.style.display = "none"; } } + state.savedPath = null; state.refresh(); + })); + chainCallback(node, "onConnectionsChange", () => queueMicrotask(() => state.refresh())); + chainCallback(node, "onExecuted", (output) => { + const saved = output?.ausboss_saved_path?.[0]; + if (typeof saved === "string" && saved) { state.savedPath = saved; state.refresh(); } + }); + chainCallback(node, "onRemoved", () => { state.alive = false; closeBrowse(); }); + state.refresh(); + node.setSize?.([Math.max(node.size?.[0] || 0, NODE_MIN_WIDTH), node.computeSize?.()[1] || CARD_HEIGHT]); + return state; +} + +app.registerExtension({ + name: "ausboss.save_image", + beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData?.name !== NODE_CLASS) return; + hideInputsInDef(nodeData, HIDDEN); + chainCallback(nodeType.prototype, "onNodeCreated", function () { + if (this.__ausbossSaveImage) return; + buildCard(this); + }); + }, +}); diff --git a/js/seed/index.js b/js/seed/index.js new file mode 100644 index 0000000..83ec727 --- /dev/null +++ b/js/seed/index.js @@ -0,0 +1,368 @@ +// Seed 🆎 — one card: the number, the mode, and the memory of what ran. +// +// The seed INT and its control_after_generate combo stay ordinary widgets +// (hidden), so save/load, undo and the API format never change. The card +// on top of them is the whole face: a big editable number, a Random / +// Fixed / Step segment that drives the control, and the buttons a shared +// workflow needs - roll a new fixed seed, put the last run's seed back, +// browse the last few, copy. +// +// The backend echoes the seed each run used through its ui payload; that +// is what "last run" shows, so it stays right even after randomize has +// already moved the box on to the next number. + +import { app } from "/scripts/app.js"; +import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs"; +import { hideInputsInDef, hideWidget } from "../shared/widget_visibility.mjs"; +import { formatSeed, pushSeed, seedFromExecuted } from "../shared/seed_history.mjs"; + +const NODE_CLASS = "AUSBOSS_NODES_Seed"; +const CSS_ID = "ausboss-seed-css"; +const WIDGET_NAME = "ausboss_seed_panel"; +const PROPERTY = "ausboss_seed_history"; // [seed, newest first] +// The frontend's own randomize rolls below 2^50 so the number stays exact +// in a double; a fresh seed rolled here obeys the same ceiling. +const ROLL_MAX = 0x4000000000000; +const CARD_HEIGHT = 158; +const PANEL_DECLARED_HEIGHT = CARD_HEIGHT + 20; // + the frontend's wrapper insets +const NODE_WIDTH = 300; + +let openMenu = null; + +function ensureCss() { + if (document.getElementById(CSS_ID)) return; + const style = document.createElement("style"); + style.id = CSS_ID; + style.textContent = ` +.ausboss-seed{box-sizing:border-box;width:100%;height:100%;padding:0 6px 6px;pointer-events:none;overflow:hidden;font:12px/1.3 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#c8dddd;} +.ausboss-seed *{box-sizing:border-box;} +.ausboss-seed-card{display:flex;flex-direction:column;gap:7px;height:100%;padding:8px;overflow:hidden;border:1px solid rgba(0,180,170,.22);border-radius:8px;background:rgba(0,0,0,.28);} +.ausboss-seed-field{display:flex;align-items:center;gap:6px;height:38px;padding:0 6px 0 12px;border:1px solid #2a3437;border-radius:7px;background:#0b0f10;pointer-events:auto;} +.ausboss-seed-field:focus-within{border-color:${BRAND};} +.ausboss-seed-field.linked{opacity:.55;} +.ausboss-seed-field input{flex:1 1 auto;min-width:0;height:100%;border:none;outline:none;background:transparent;color:#eef7f6;font:600 17px/1 ui-monospace,"SF Mono",Menlo,Consolas,monospace;font-variant-numeric:tabular-nums;letter-spacing:.01em;} +.ausboss-seed-field input:disabled{color:#8ba3a1;} +.ausboss-seed-tag{flex:none;color:#5f7674;font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;} +.ausboss-seed-icon{flex:none;width:26px;height:26px;border:1px solid transparent;border-radius:5px;background:transparent;color:#8ba3a1;cursor:pointer;font:13px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;pointer-events:auto;} +.ausboss-seed-icon:hover{border-color:#3a4a4d;background:#1a2224;color:#fff;} +.ausboss-seed-seg{display:grid;grid-template-columns:1fr 1fr 1fr;height:30px;padding:3px;border:1px solid #2a3437;border-radius:7px;background:#0f1516;pointer-events:auto;} +.ausboss-seed-seg button{border:none;border-radius:5px;background:transparent;color:#8ba3a1;font:600 12px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;cursor:pointer;} +.ausboss-seed-seg button:hover{color:#fff;} +.ausboss-seed-seg button.on{background:${BRAND};color:#04201d;} +.ausboss-seed-row{display:grid;grid-template-columns:1fr 1fr 30px;gap:6px;height:32px;} +.ausboss-seed-btn{height:100%;border:1px solid #2a3437;border-radius:7px;background:#16201f;color:#d8ecea;font:600 12px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;cursor:pointer;pointer-events:auto;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} +.ausboss-seed-btn:hover{border-color:${BRAND};color:#fff;} +.ausboss-seed-btn:disabled{opacity:.4;cursor:default;pointer-events:none;} +.ausboss-seed-hint{overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:#6f8886;font-size:10.5px;text-align:center;} +.ausboss-seed-hint b{color:#9fe3dc;font-weight:600;font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace;} +.ausboss-seed-menu{position:fixed;z-index:10000;min-width:230px;padding:4px;border:1px solid #3a4047;border-radius:7px;background:#1c1f23;box-shadow:0 8px 28px rgba(0,0,0,.5);font:12px/1.3 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#d7dde2;} +.ausboss-seed-menu-head{padding:5px 8px 6px;border-bottom:1px solid #2c3238;color:#78908e;font:10px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;letter-spacing:.08em;text-transform:uppercase;} +.ausboss-seed-menu-item{display:block;width:100%;padding:6px 8px;border:none;border-radius:5px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;} +.ausboss-seed-menu-item:hover{background:#2c3238;color:#fff;} +.ausboss-seed-menu-item.current{color:${BRAND};} +`; + document.head.appendChild(style); +} + +function closeMenu() { + if (!openMenu) return; + openMenu.abort.abort(); + openMenu.element.remove(); + openMenu = null; +} + +function history(node) { + const stored = node.properties?.[PROPERTY]; + return Array.isArray(stored) ? stored : []; +} + +function seedWidget(node) { + return node.widgets?.find((widget) => widget.name === "seed"); +} + +function controlWidget(node) { + const seed = seedWidget(node); + const linked = seed?.linkedWidgets?.find?.((widget) => Array.isArray(widget.options?.values)); + return linked ?? node.widgets?.find((widget) => widget.name === "control_after_generate"); +} + +function seedLinked(node) { + return Boolean(node.inputs?.some((input) => input?.name === "seed" && input.link != null)); +} + +function setControl(node, value) { + const control = controlWidget(node); + if (!control || !Array.isArray(control.options?.values)) return; + if (!control.options.values.includes(value)) return; + control.value = value; + control.callback?.(value); +} + +function setSeed(node, seed, { pin = true } = {}) { + const widget = seedWidget(node); + if (!widget) return; + widget.value = seed; + widget.callback?.(seed); + if (pin) setControl(node, "fixed"); + node.setDirtyCanvas?.(true, true); + notifyAusbossChange(); +} + +function rollSeed() { + return Math.floor(Math.random() * ROLL_MAX); +} + +function render(state) { + const { node } = state; + const seeds = history(node); + const last = seeds[0]; + const control = controlWidget(node)?.value ?? "fixed"; + const linked = seedLinked(node); + const value = seedWidget(node)?.value; + if (document.activeElement !== state.input) state.input.value = formatSeed(Number(value)); + state.input.disabled = linked; + state.field.classList.toggle("linked", linked); + state.field.title = linked ? "The seed comes from its input link" : "Click to type a seed"; + state.tag.textContent = control === "randomize" ? "next" : control === "fixed" ? "seed" : control; + state.segRandom.classList.toggle("on", control === "randomize"); + state.segFixed.classList.toggle("on", control === "fixed"); + const stepping = control === "increment" || control === "decrement"; + state.segStep.classList.toggle("on", stepping); + state.segStep.textContent = control === "decrement" ? "Step −1" : "Step +1"; + state.segStep.title = stepping + ? "Click again to step the other way" + : "Increment the seed after each queue (click again for decrement)"; + state.useLast.disabled = !seeds.length; + state.more.disabled = seeds.length < 2; + state.hint.textContent = ""; + if (seeds.length) { + state.hint.append("last run "); + const b = document.createElement("b"); + b.textContent = formatSeed(last); + state.hint.append(b); + if (control === "randomize") state.hint.append(" · rolls a new seed each queue"); + } else { + state.hint.textContent = control === "randomize" + ? "rolls a new seed each queue" + : control === "fixed" ? "same seed every queue" : "steps the seed after each queue"; + } +} + +function showMenu(state) { + closeMenu(); + const seeds = history(state.node); + if (!seeds.length) return; + const menu = document.createElement("div"); + menu.className = "ausboss-seed-menu"; + const head = document.createElement("div"); + head.className = "ausboss-seed-menu-head"; + head.textContent = "seeds this node ran with"; + menu.append(head); + const current = seedWidget(state.node)?.value; + for (const seed of seeds) { + const item = document.createElement("button"); + item.type = "button"; + item.className = "ausboss-seed-menu-item"; + if (seed === current) item.classList.add("current"); + item.textContent = formatSeed(seed); + item.title = "Put this seed back and pin it"; + item.addEventListener("click", () => { + setSeed(state.node, seed); + render(state); + closeMenu(); + }); + menu.append(item); + } + document.body.append(menu); + const anchor = state.more.getBoundingClientRect(); + const rect = menu.getBoundingClientRect(); + const left = Math.min(anchor.right - rect.width, window.innerWidth - rect.width - 8); + let top = anchor.bottom + 4; + if (top + rect.height > window.innerHeight - 8) top = Math.max(8, anchor.top - rect.height - 4); + menu.style.left = `${Math.max(8, left)}px`; + menu.style.top = `${top}px`; + const abort = new AbortController(); + document.addEventListener( + "pointerdown", + (event) => { if (!menu.contains(event.target)) closeMenu(); }, + { capture: true, signal: abort.signal }, + ); + window.addEventListener( + "keydown", + (event) => { if (event.key === "Escape") { event.stopPropagation(); closeMenu(); } }, + { capture: true, signal: abort.signal }, + ); + openMenu = { element: menu, abort }; +} + +async function copySeed(state) { + const value = seedWidget(state.node)?.value; + try { + await navigator.clipboard?.writeText(formatSeed(Number(value))); + state.copy.textContent = "✓"; + setTimeout(() => { state.copy.textContent = "⧉"; }, 700); + } catch { + // Clipboard blocked: the number is on screen to read. + } +} + +function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +function button(className, text, title) { + const b = el("button", className, text); + b.type = "button"; + if (title) b.title = title; + b.addEventListener("pointerdown", (event) => event.stopPropagation()); + return b; +} + +function buildPanel(node) { + if (node.__ausbossSeed) return node.__ausbossSeed; + ensureCss(); + const seed = seedWidget(node); + const control = controlWidget(node); + if (!seed) return null; + hideWidget(seed); + if (control) hideWidget(control); + + const root = el("div", "ausboss-seed"); + const card = el("div", "ausboss-seed-card"); + + const field = el("div", "ausboss-seed-field"); + const tag = el("span", "ausboss-seed-tag", "seed"); + const input = el("input"); + input.type = "text"; + input.inputMode = "numeric"; + input.spellcheck = false; + const copy = button("ausboss-seed-icon", "⧉", "Copy the seed"); + field.append(tag, input, copy); + field.addEventListener("pointerdown", (event) => event.stopPropagation()); + + const seg = el("div", "ausboss-seed-seg"); + const segRandom = button("", "Random", "Roll a new seed after every queue"); + const segFixed = button("", "Fixed", "Keep this seed for every queue"); + const segStep = button("", "Step +1", "Increment the seed after each queue"); + seg.append(segRandom, segFixed, segStep); + + const row = el("div", "ausboss-seed-row"); + const newSeed = button("ausboss-seed-btn", "New seed", "Roll a fresh seed and pin it (Fixed)"); + const useLast = button("ausboss-seed-btn", "Use last run", "Put the last run's seed back and pin it (Fixed)"); + const more = button("ausboss-seed-btn", "▾", "Earlier seeds this node ran with"); + row.append(newSeed, useLast, more); + + const hint = el("div", "ausboss-seed-hint"); + card.append(field, seg, row, hint); + root.append(card); + + const state = (node.__ausbossSeed = { + node, root, field, tag, input, copy, segRandom, segFixed, segStep, newSeed, useLast, more, hint, + }); + + const commitTyped = () => { + const text = input.value.trim().replace(/[\s,_]/g, ""); + if (/^\d+$/.test(text)) { + const number = Number(text); + if (Number.isFinite(number) && number >= 0 && number !== seed.value) setSeed(node, number, { pin: false }); + } + render(state); + }; + input.addEventListener("keydown", (event) => { + event.stopPropagation(); + if (event.key === "Enter") input.blur(); + if (event.key === "Escape") { input.value = formatSeed(Number(seed.value)); input.blur(); } + }); + input.addEventListener("focus", () => input.select()); + input.addEventListener("blur", commitTyped); + copy.addEventListener("click", () => copySeed(state)); + segRandom.addEventListener("click", () => { setControl(node, "randomize"); render(state); notifyAusbossChange(); }); + segFixed.addEventListener("click", () => { setControl(node, "fixed"); render(state); notifyAusbossChange(); }); + segStep.addEventListener("click", () => { + const current = controlWidget(node)?.value; + setControl(node, current === "increment" ? "decrement" : "increment"); + render(state); + notifyAusbossChange(); + }); + newSeed.addEventListener("click", () => { setSeed(node, rollSeed()); render(state); }); + useLast.addEventListener("click", () => { + const seeds = history(node); + if (seeds.length) { setSeed(node, seeds[0]); render(state); } + }); + more.addEventListener("click", () => (openMenu ? closeMenu() : showMenu(state))); + + // The control widget rewrites the seed after each queue; ride its callback + // so the card shows the new number without waiting for a redraw. + chainCallback(seed, "callback", () => render(state)); + if (control) chainCallback(control, "callback", () => render(state)); + + const widget = node.addDOMWidget(WIDGET_NAME, "ausboss_seed", root, { + serialize: false, + hideOnZoom: false, + getMinHeight: () => PANEL_DECLARED_HEIGHT, + }); + keepDomWidgetWidthAuto(widget); + // A constant-height card: pinned on purpose (see + // tests/panel_guards.test.mjs, fixedByDesign). + widget.computeSize = (width) => [ + Math.max(NODE_WIDTH, Number(width || node.size?.[0] || NODE_WIDTH)), + PANEL_DECLARED_HEIGHT, + ]; + widget.computeLayoutSize = () => ({ minWidth: NODE_WIDTH, minHeight: PANEL_DECLARED_HEIGHT }); + widget.options.minNodeSize = [NODE_WIDTH, 120]; + + render(state); + node.setSize?.([ + Math.max(node.size?.[0] ?? 0, NODE_WIDTH), + node.computeSize?.()[1] ?? node.size?.[1], + ]); + // The card is a fixed-height row: width may grow, but a corner drag + // that pulls the node taller only opens dead space under the card, so + // the height snaps back to the card's own on the next frame (LiteGraph + // writes size directly during a drag and rarely calls onResize). + chainCallback(node, "onDrawForeground", function () { + if (this.flags?.collapsed) return; + const natural = this.computeSize?.()[1]; + if (natural && this.size[1] !== natural) { + this.setSize?.([this.size[0], natural]); + this.setDirtyCanvas?.(true, false); + } + }); + return state; +} + +app.registerExtension({ + name: "AusBoss.Seed", + beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData?.name !== NODE_CLASS) return; + hideInputsInDef(nodeData, ["seed"]); + chainCallback(nodeType.prototype, "onNodeCreated", function () { + buildPanel(this); + }); + chainCallback(nodeType.prototype, "onConfigure", function () { + queueMicrotask(() => { + const state = buildPanel(this); + if (state) render(state); + }); + }); + chainCallback(nodeType.prototype, "onConnectionsChange", function () { + const state = this.__ausbossSeed; + if (state) requestAnimationFrame(() => render(state)); + }); + chainCallback(nodeType.prototype, "onExecuted", function (message) { + const state = buildPanel(this); + const seed = seedFromExecuted(message); + if (!state || seed === null) return; + this.properties ??= {}; + this.properties[PROPERTY] = pushSeed(history(this), seed); + render(state); + }); + chainCallback(nodeType.prototype, "onRemoved", function () { + closeMenu(); + this.__ausbossSeed = null; + }); + }, +}); diff --git a/js/shared/index.mjs b/js/shared/index.mjs index 9cf3180..25ca0c6 100644 --- a/js/shared/index.mjs +++ b/js/shared/index.mjs @@ -9,7 +9,7 @@ import { app } from "/scripts/app.js"; // Must match `version` in pyproject.toml — scripts/release_preflight.py // enforces it. The backend serves its copy at /ausboss/pack_version; a // mismatch means the browser cached JavaScript from an older install. -export const AUSBOSS_JS_VERSION = "1.3.0"; +export const AUSBOSS_JS_VERSION = "2.0.0"; export const BRAND = "#00b4aa"; // AusBoss teal — keep node accents consistent. export const BRAND_DARK = "#007f78"; diff --git a/js/shared/lora_stack.mjs b/js/shared/lora_stack.mjs index 768bed8..13181b6 100644 --- a/js/shared/lora_stack.mjs +++ b/js/shared/lora_stack.mjs @@ -310,10 +310,14 @@ export const BAR_COLORS = { // Bars ignore values this close to zero; the center tick alone marks them. const BAR_EPSILON = 0.005; +// Bars share one scale so magnitudes compare across rows: the largest +// enabled strength fills its half, never less than 1. A disabled row is +// dimmed and sits out, so switching one off rescales the others to the +// strongest row still in play (its own bar clamps at the edge meanwhile). export function strengthBarScale(rows) { let peak = 0; for (const row of rows ?? []) { - if (!row?.name) continue; + if (!row?.name || row.enabled === false) continue; const value = Math.abs(Number(row.strength)); if (Number.isFinite(value)) peak = Math.max(peak, value); } diff --git a/js/shared/panel_layout.mjs b/js/shared/panel_layout.mjs index a1d6d33..5c6f4f8 100644 --- a/js/shared/panel_layout.mjs +++ b/js/shared/panel_layout.mjs @@ -39,3 +39,15 @@ export function fillNodeHeight(widget, { minWidth = 0, minHeight = 0, minNodeSiz if (minNodeSize) widget.options.minNodeSize = minNodeSize; return widget; } + +// Grow a node to the height its widgets ask for. A workflow saved before a +// panel or card existed carries the node's old size, and the frontend keeps +// that size on load - the new panel is then squeezed under its floor and +// clipped flat. Returns true when the node was resized. +export function ensureNodeMinHeight(node) { + const min = Number(node?.computeSize?.()?.[1]); + const current = Number(node?.size?.[1]); + if (!Number.isFinite(min) || !Number.isFinite(current) || current >= min) return false; + node.setSize?.([node.size[0], min]); + return true; +} diff --git a/js/shared/resolution_math.mjs b/js/shared/resolution_math.mjs new file mode 100644 index 0000000..582eb04 --- /dev/null +++ b/js/shared/resolution_math.mjs @@ -0,0 +1,408 @@ +// Pure geometry for the Resolution Master 🆎 node: snapping, ratio math, drag +// mapping, and readouts. DOM-free so node:test covers it +// (tests/resolution_math.test.mjs); js/resolution/index.js wires it to the +// panel. + +export const DIM_MIN = 64; +export const DIM_MAX = 8192; +export const SNAP_STEPS = [8, 16, 32, 64]; +export const DEFAULT_SNAP = 32; + +export function clampDim(value) { + const number = Math.round(Number(value)); + if (!Number.isFinite(number)) return DIM_MIN; + return Math.max(DIM_MIN, Math.min(DIM_MAX, number)); +} + +// Round to the step without ever snapping below one step, so a small +// dimension snaps UP instead of collapsing to zero. +export function snapDim(value, step = DEFAULT_SNAP) { + const s = Number(step) > 0 ? Number(step) : 1; + return clampDim(Math.max(s, Math.round(Number(value) / s) * s)); +} + +function gcd(a, b) { + let x = Math.abs(Math.round(a)); + let y = Math.abs(Math.round(b)); + while (y) [x, y] = [y, x % y]; + return x || 1; +} + +function lcm(a, b) { + return (a * b) / gcd(a, b); +} + +// One snap system for ratio-locked sizes: with width = rx*k and +// height = ry*k, both dimensions land on the snap grid exactly when k is a +// multiple of lcm(step/gcd(step,rx), step/gcd(step,ry)). 7:4 at /32 gives +// stride 32 (k=192 -> 1344x768); 16:9 at /16 gives stride 16 (k=80 -> +// 1280x720). The ratio stays mathematically exact AND on-grid - no second +// snapping pass, no drift. +export function ratioStride(ratio, step) { + const s = Number(step) > 0 ? Math.round(step) : 1; + if (s <= 1) return 1; + return lcm(s / gcd(s, ratio.x), s / gcd(s, ratio.y)); +} + +// Some ratio/step pairs only align at coarse strides (16:9 at /32 moves +// width in 512px jumps). The panel warns past this per-axis step so the +// user can pick a finer snap instead of thinking the drag is stuck. +export function strideWarning(ratio, step, threshold = 256) { + const stride = ratioStride(ratio, step); + return stride * Math.max(ratio.x, ratio.y) > threshold; +} + +// Reduced integer ratio of a size, e.g. 1344x768 -> {x: 7, y: 4}. +export function reduceRatio(width, height) { + const d = gcd(width, height); + return { x: Math.round(width / d), y: Math.round(height / d) }; +} + +// Human ratio label: exact when the reduced terms stay small, otherwise a +// one-decimal approximation ("~1.78:1" / "~1:1.42") that reads better than +// a monster fraction like 341:192. +export function ratioLabel(width, height) { + const { x, y } = reduceRatio(width, height); + if (x <= 32 && y <= 32) return `${x}:${y}`; + const value = width / height; + return value >= 1 + ? `~${(Math.round(value * 100) / 100)}:1` + : `~1:${(Math.round((1 / value) * 100) / 100)}`; +} + +export function megapixels(width, height) { + return Math.round((width * height) / 10000) / 100; +} + +export function readout(width, height) { + return `${width} × ${height} · ${megapixels(width, height).toFixed(2)} MP · ${ratioLabel(width, height)}`; +} + +// Largest w x h with the given aspect that fits a box - the chip and stage +// thumbnails. +export function fitRectInBox(aspectW, aspectH, boxW, boxH) { + const scale = Math.min(boxW / aspectW, boxH / aspectH); + return { w: Math.max(1, aspectW * scale), h: Math.max(1, aspectH * scale) }; +} + +// A size on an exact integer ratio nearest a pixel target for the driving +// axis: width = rx*k, height = ry*k for whole k, so the ratio never drifts +// however far the drag travels or however coarse the snap. When snapping, +// k moves in ratioStride multiples so BOTH dimensions stay on the grid. +export function ratioSizeForAxis(ratio, axis, target, step = 1) { + const r = axis === "height" ? ratio.y : ratio.x; + const kStep = ratioStride(ratio, step); + const minK = Math.ceil(Math.max(DIM_MIN / ratio.x, DIM_MIN / ratio.y) / kStep) * kStep; + const maxK = Math.floor(Math.min(DIM_MAX / ratio.x, DIM_MAX / ratio.y) / kStep) * kStep; + if (minK > maxK) { + // Extreme ratios and coarse grids may have no exact solution. Keep + // dimensions legal and use the nearest bounded approximation. + const factor = Math.min(DIM_MAX / Math.max(ratio.x, ratio.y), + Math.max(DIM_MIN / Math.min(ratio.x, ratio.y), target / r)); + return { width: snapDim(ratio.x * factor, step), height: snapDim(ratio.y * factor, step) }; + } + const k = Math.max(minK, Math.min(maxK, Math.round(target / r / kStep) * kStep)); + const width = ratio.x * k; + const height = ratio.y * k; + return { width, height }; +} + +// Nearest size on the ratio to a free wxh point, judged by area, driven by +// the longer axis for stability. +export function ratioSizeNear(ratio, width, height, step = 1) { + const byW = ratioSizeForAxis(ratio, "width", width, step); + const byH = ratioSizeForAxis(ratio, "height", height, step); + const errW = Math.abs(byW.width - width) + Math.abs(byW.height - height); + const errH = Math.abs(byH.width - width) + Math.abs(byH.height - height); + return errW <= errH ? byW : byH; +} + +// ---------- the stage drag model ---------- +// +// The stage shows the current rectangle centered in a fixed box at a live +// scale (px per CSS px). A drag grabs an edge or corner and moves that +// edge; the rectangle stays centered, so moving the right edge out grows +// width symmetrically. deltas are CSS px; scale converts to image px. +// +// handle: "e" | "w" grow width; "n" | "s" grow height; +// "ne" "nw" "se" "sw" grow both. +// modifiers: snap (default true; false = 1px freedom), +// lockRatio (true = keep the grab-time ratio exactly). + +export function stageScale(width, height, boxW, boxH) { + // Fit with fixed headroom for the handles, corner hints, and the label + // (the design's 90px horizontal / 60px vertical margins), never below a + // sliver on a tiny stage. + const usableW = Math.max(40, boxW - 90); + const usableH = Math.max(40, boxH - 60); + return Math.max(0.002, Math.min(usableW / width, usableH / height)); +} + +const HANDLE_AXES = { + e: { x: 1, y: 0 }, w: { x: 1, y: 0 }, + n: { x: 0, y: 1 }, s: { x: 0, y: 1 }, + ne: { x: 1, y: 1 }, nw: { x: 1, y: 1 }, se: { x: 1, y: 1 }, sw: { x: 1, y: 1 }, +}; + +// Outward-positive delta for a handle: dragging the east edge right, the +// west edge left, the north edge up, or the south edge down all GROW. +export function outwardDelta(handle, dx, dy) { + const sx = handle.includes("w") ? -1 : handle.includes("e") ? 1 : 0; + const sy = handle.includes("n") ? -1 : handle.includes("s") ? 1 : 0; + return { ox: dx * sx, oy: dy * sy }; +} + +export function dragResize({ + handle, startWidth, startHeight, dx, dy, scale, + snap = DEFAULT_SNAP, snapOn = true, lockRatio = false, +}) { + const axes = HANDLE_AXES[handle] ?? { x: 1, y: 1 }; + const { ox, oy } = outwardDelta(handle, dx, dy); + // Centered rectangle: an edge moving out d grows the dimension 2d. + const growX = axes.x ? (ox * 2) / scale : 0; + const growY = axes.y ? (oy * 2) / scale : 0; + const step = snapOn ? snap : 1; + + if (lockRatio) { + const ratio = reduceRatio(startWidth, startHeight); + // Exact-k locking is only pleasant when the ratio aligns to the grid + // at a workable stride. Custom ratios (a snapped 88:115, say) would + // jump hundreds of pixels per step, so they scale proportionally at + // snap granularity instead - fluid, ratio approximately kept. + if (snapOn && strideWarning(ratio, snap)) { + const factor = Math.max( + 0.05, + 1 + (growX / startWidth + growY / startHeight) / 2, + ); + return { + width: snapDim(startWidth * factor, step), + height: snapDim(startHeight * factor, step), + }; + } + // The dominant travel axis drives; the other follows the exact ratio. + const useWidth = axes.x && (!axes.y || Math.abs(growX) >= Math.abs(growY)); + return useWidth + ? ratioSizeForAxis(ratio, "width", startWidth + growX, step) + : ratioSizeForAxis(ratio, "height", startHeight + growY, step); + } + return { + width: axes.x ? snapDim(startWidth + growX, step) : snapDim(startWidth, 1), + height: axes.y ? snapDim(startHeight + growY, step) : snapDim(startHeight, 1), + }; +} + +// ---------- chip policy: SHAPE x BUDGET ---------- + +// Clicking a ratio chip changes the SHAPE while keeping the pixel budget +// and the current orientation: 3:2 clicked while portrait yields 2:3, and +// the area stays as close to the current area as the grid allows. From the +// 1024x1024 default this lands exactly on the real-world sizes: 7:4 at /32 +// gives 1344x768, 16:9 at /16 gives 1280x720. +// portrait defaults to the size's own orientation; a square caller passes +// the orientation it wants, since a square has none to read. +export function applyRatioAtBudget(width, height, ratio, step = DEFAULT_SNAP, portrait = height > width) { + const flipped = portrait !== ratio.y > ratio.x && ratio.x !== ratio.y; + const shaped = flipped ? { x: ratio.y, y: ratio.x } : { x: ratio.x, y: ratio.y }; + const area = width * height; + const idealWidth = Math.sqrt(area * (shaped.x / shaped.y)); + const kStep = ratioStride(shaped, step); + const candidates = [ + ratioSizeForAxis(shaped, "width", idealWidth, step), + ratioSizeForAxis(shaped, "width", idealWidth + kStep * shaped.x, step), + ratioSizeForAxis(shaped, "width", idealWidth - kStep * shaped.x, step), + ]; + let best = candidates[0]; + let bestError = Infinity; + for (const size of candidates) { + const error = Math.abs(size.width * size.height - area); + if (error < bestError) { + bestError = error; + best = size; + } + } + return best; +} + +// How the current size relates to a chip's ratio: "exact", "near" (within +// tolerance, rendered outlined), or null. Orientation-blind - a portrait +// 2:3 lights the 3:2 chip with flipped:true. +export function chipMatch(width, height, ratio, tolerance = 0.015) { + const current = reduceRatio(width, height); + const flippedMatch = current.x === ratio.y && current.y === ratio.x; + if ((current.x === ratio.x && current.y === ratio.y) || (flippedMatch && ratio.x !== ratio.y)) { + return { state: "exact", flipped: flippedMatch && ratio.x !== ratio.y }; + } + const value = width / height; + const chipValue = ratio.x / ratio.y; + const drift = Math.abs(Math.log(value / chipValue)); + const driftFlipped = Math.abs(Math.log(value / (ratio.y / ratio.x))); + if (drift <= tolerance) return { state: "near", flipped: false }; + if (ratio.x !== ratio.y && driftFlipped <= tolerance) return { state: "near", flipped: true }; + return null; +} + +// Parse the gear menu's editable rail text ("1:1, 4:3, 7:4") into ratio +// objects; junk entries drop, duplicates collapse, and an empty result +// falls back to the shipped defaults so the rail can never vanish. +export const DEFAULT_RAIL = "1:1, 4:3, 3:2, 16:9, 7:4, 2:1"; + +export function parseRail(text) { + const seen = new Set(); + const rail = []; + for (const token of String(text ?? "").split(",")) { + const match = token.trim().match(/^(\d{1,3})\s*[:x]\s*(\d{1,3})$/i); + if (!match) continue; + const x = Number(match[1]); + const y = Number(match[2]); + if (!x || !y) continue; + const d = gcd(x, y); + const key = `${x / d}:${y / d}`; + if (seen.has(key)) continue; + seen.add(key); + rail.push({ x: x / d, y: y / d, label: `${x}:${y}` }); + } + return rail.length ? rail : parseRail(DEFAULT_RAIL); +} + +// The rail shows every ratio in the CANVAS's current orientation - a +// portrait canvas renders the 3:2 chip as "2:3" with a portrait glyph - +// so what you see is exactly what a click gives you. +export function orientRatio(ratio, portrait) { + const long = Math.max(ratio.x, ratio.y), short = Math.min(ratio.x, ratio.y); + const x = portrait ? short : long, y = portrait ? long : short; + return { x, y, label: `${x}:${y}` }; +} + +// ---------- quick sizes ---------- + +// The per-ratio quick list, anchored to the canonical square areas +// (512^2, 768^2, 1024^2, 1280^2, 1536^2, 1792^2, 2048^2): each rung is +// the nearest on-ratio, on-grid size to that pixel budget. Generated, +// never curated - so 1:1 at /64 IS the familiar 512/768/1024/1280/1536/ +// 1792/2048 ladder, and every other ratio gets the equivalent rungs +// (16:9 at /16 includes 1280x720, 7:4 at /32 includes 1344x768). +export const LADDER_EDGES = [512, 768, 1024, 1280, 1536, 1792, 2048]; + +export function quickSizes(width, height, step, count = 8) { + const ratio = reduceRatio(width, height); + const kStep = ratioStride(ratio, step); + const area = ratio.x * ratio.y; + const seen = new Set(); + const list = []; + for (const edge of LADDER_EDGES) { + const idealK = Math.sqrt((edge * edge) / area); + const k = Math.max(kStep, Math.round(idealK / kStep) * kStep); + const w = ratio.x * k; + const h = ratio.y * k; + if (w < DIM_MIN || h < DIM_MIN || w > DIM_MAX || h > DIM_MAX) continue; + const key = `${w}x${h}`; + if (seen.has(key)) continue; + seen.add(key); + list.push({ width: w, height: h }); + } + return list + .sort((a, b) => a.width * a.height - b.width * b.height) + .slice(0, count); +} + +// Every ratio's canonical default - the ~1MP rung, exactly what a chip +// click lands on (1:1 -> 1024x1024, 16:9 at /16 -> 1280x720, 7:4 at /32 +// -> 1344x768). Same idea as Pixaroma's hand-written per-ratio default +// table, derived instead of curated. +export function defaultSizeForRatio(ratio, step) { + return applyRatioAtBudget(1024, 1024, ratio, step, ratio.y > ratio.x); +} + +// ---------- megapixel ladder ---------- + +export const MP_LADDER = [0.25, 0.5, 1, 1.5, 2, 3, 4]; + +// Walk the rung ladder from wherever the current area sits: nearest rung +// first (by log distance), then one step in the asked direction. +export function stepMegapixels(currentMp, direction) { + let nearest = 0; + let bestDistance = Infinity; + MP_LADDER.forEach((rung, index) => { + const distance = Math.abs(Math.log(Math.max(0.01, currentMp) / rung)); + if (distance < bestDistance) { + bestDistance = distance; + nearest = index; + } + }); + const next = Math.max(0, Math.min(MP_LADDER.length - 1, nearest + Math.sign(direction))); + return MP_LADDER[next]; +} + +// ---------- pins (user presets) ---------- + +export const MAX_PINS = 12; + +export function parsePins(raw) { + try { + const data = JSON.parse(raw); + if (!Array.isArray(data)) return []; + return data + .filter((pin) => pin && Number.isFinite(pin.w) && Number.isFinite(pin.h)) + .map((pin) => ({ w: clampDim(pin.w), h: clampDim(pin.h) })) + .slice(0, MAX_PINS); + } catch { + return []; + } +} + +export function serializePins(pins) { + return JSON.stringify(pins); +} + +// Star toggles: pinning an already-pinned size removes it. +export function togglePin(pins, width, height) { + const list = pins.filter((pin) => !(pin.w === width && pin.h === height)); + if (list.length === pins.length) list.unshift({ w: clampDim(width), h: clampDim(height) }); + return list.slice(0, MAX_PINS); +} + +export function pinLabel(pin) { + return `${pin.w}×${pin.h}`; +} + +// ---------- misc panel helpers ---------- + +export function offGrid(width, height, step) { + const s = Number(step) > 0 ? step : 1; + return width % s !== 0 || height % s !== 0; +} + +// The W/H boxes reuse the pack's scrub grammar: pointer travel past a dead +// zone moves the value in snap-step increments. +export function scrubDim(startValue, deltaX, step, pixelsPerStep = 4, deadZone = 3) { + if (Math.abs(deltaX) <= deadZone) return clampDim(startValue); + const travel = deltaX - Math.sign(deltaX) * deadZone; + const steps = Math.round(travel / pixelsPerStep); + return snapDim(startValue + steps * (Number(step) > 0 ? step : 1), Number(step) > 0 ? step : 1); +} + +// ---------- megapixel retarget ---------- + +// Scale a size to a megapixel target. Exact-ratio version of sqrt scaling: +// walk the reduced ratio's integer k to the area nearest the target, so +// 2/3-ish sizes do not drift ratio when retargeted. +export function sizeForMegapixels(width, height, targetMp, step = DEFAULT_SNAP) { + const ratio = reduceRatio(width, height); + const targetPixels = Math.max(0.05, Number(targetMp) || 0.05) * 1_000_000; + const idealWidth = Math.sqrt(targetPixels * (ratio.x / ratio.y)); + const candidates = [ + ratioSizeForAxis(ratio, "width", idealWidth, step), + ratioSizeForAxis(ratio, "width", idealWidth + step, step), + ratioSizeForAxis(ratio, "width", idealWidth - step, step), + ]; + let best = candidates[0]; + let bestError = Infinity; + for (const size of candidates) { + const error = Math.abs(size.width * size.height - targetPixels); + if (error < bestError) { + bestError = error; + best = size; + } + } + return best; +} diff --git a/js/shared/run_timer.mjs b/js/shared/run_timer.mjs new file mode 100644 index 0000000..9a378c9 --- /dev/null +++ b/js/shared/run_timer.mjs @@ -0,0 +1,57 @@ +// Run Timer decision logic: a tiny stopwatch reducer and the display +// formats. No DOM, no ComfyUI imports; the entry file wires api events. + +export const HISTORY_LIMIT = 5; + +export function createTimer(history = []) { + return { + running: false, + startedAt: null, + elapsed: 0, + outcome: null, // "done" | "error" | "interrupted" once a run has ended + history: Array.isArray(history) ? history.filter(isSeconds).slice(0, HISTORY_LIMIT) : [], + }; +} + +function isSeconds(value) { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} + +export function startTimer(state, now) { + return { ...state, running: true, startedAt: now, elapsed: 0, outcome: null }; +} + +export function tickTimer(state, now) { + if (!state.running || state.startedAt === null) return state; + return { ...state, elapsed: Math.max(0, (now - state.startedAt) / 1000) }; +} + +// Ends a run. Only a completed run joins the history: an interrupted or +// failed one has no meaning as a reference time, so it shows but is not kept. +export function stopTimer(state, now, outcome = "done") { + if (!state.running) return { ...state, outcome }; + const elapsed = state.startedAt === null ? state.elapsed : Math.max(0, (now - state.startedAt) / 1000); + const history = outcome === "done" + ? [elapsed, ...state.history].slice(0, HISTORY_LIMIT) + : state.history; + return { ...state, running: false, startedAt: null, elapsed, outcome, history }; +} + +// "12.3 s" under a minute, "1:05.3" under an hour, "1:02:03" beyond. +export function formatElapsed(seconds) { + if (!isSeconds(seconds)) return "—"; + if (seconds < 60) return `${seconds.toFixed(1)} s`; + const whole = Math.floor(seconds); + const hours = Math.floor(whole / 3600); + const minutes = Math.floor((whole % 3600) / 60); + const rest = whole % 60; + if (hours) return `${hours}:${String(minutes).padStart(2, "0")}:${String(rest).padStart(2, "0")}`; + const tenths = Math.floor((seconds - whole) * 10); + return `${minutes}:${String(rest).padStart(2, "0")}.${tenths}`; +} + +// The small print under the big number: the previous runs, oldest last. +export function historyLine(history) { + if (!history?.length) return ""; + return history.map(formatElapsed).join(" · "); +} diff --git a/js/shared/save_image_naming.mjs b/js/shared/save_image_naming.mjs new file mode 100644 index 0000000..3074466 --- /dev/null +++ b/js/shared/save_image_naming.mjs @@ -0,0 +1,80 @@ +// Pure naming logic behind the Save Image card: what the path preview says +// before a run, which name tags apply, and how the card reads the node's +// widgets. Mirrors nodes/_image_save_helpers.py (name_stem, plan_local_names) +// so the preview and the file agree. No DOM, no app import: tests run it +// under node:test. + +export const FORMAT_EXTENSIONS = { png: "png", "webp lossless": "webp", "jxl lossless": "jxl" }; +export const FORMAT_LABELS = { png: "PNG", "webp lossless": "WebP", "jxl lossless": "JXL" }; + +export const TAGS = [ + { key: "name_counter", label: "Counter", token: "#####", + title: "Append the next free five-digit number. On is collision-safe; turning it off reuses the same path and replaces an existing file." }, + { key: "name_date", label: "Date", token: "YYYY-MM-DD", title: "Append the local save date as YYYY-MM-DD." }, + { key: "name_time", label: "Time", token: "HH-mm-ss", title: "Append the local save time as HH-mm-ss." }, + { key: "name_size", label: "Size", token: "WxH", title: "Append the image dimensions as WIDTHxHEIGHT, such as 1024x1024." }, + { key: "name_batch", label: "Batch #", token: "b###", + title: "For an image batch, append b001, b002... so each image's position in this run is visible. A single image is unchanged." }, +]; + +const pad = (n, width) => String(n).padStart(width, "0"); + +export function localDate(now) { + return `${now.getFullYear()}-${pad(now.getMonth() + 1, 2)}-${pad(now.getDate(), 2)}`; +} + +export function localTime(now) { + return `${pad(now.getHours(), 2)}-${pad(now.getMinutes(), 2)}-${pad(now.getSeconds(), 2)}`; +} + +// Strip one trailing image extension, as the backend does for exact names. +export function stripImageExtension(name) { + return String(name ?? "").replace(/\.(avif|bmp|gif|jpe?g|jxl|png|tiff?|webp)$/i, ""); +} + +// Which naming mode the widgets describe. +// linked - the filename input is connected: exact name from upstream +// exact - the legacy exact_name widget holds a name +// local - prefix plus tags +export function namingMode({ filenameLinked, exactName }) { + if (filenameLinked) return "linked"; + if (String(exactName ?? "").trim()) return "exact"; + return "local"; +} + +// The filename the preview shows for the current widget values. `size` +// is {width, height} when an upstream image size is known, else the +// WxH placeholder stands in. +export function previewName(values, { now = new Date(), size = null } = {}) { + const ext = FORMAT_EXTENSIONS[values.format] ?? "png"; + const mode = namingMode({ filenameLinked: values.filenameLinked, exactName: values.exact_name }); + if (mode === "linked") return `{{filename}}.${ext}`; + if (mode === "exact") return `${stripImageExtension(String(values.exact_name).trim())}.${ext}`; + const prefix = String(values.filename_prefix ?? "").trim().replace(/\\/g, "/"); + const base = prefix.split("/").filter((part) => part && part !== ".").pop() || "image"; + const parts = [base]; + if (values.name_date) parts.push(localDate(now)); + if (values.name_time) parts.push(localTime(now)); + if (values.name_size) parts.push(size ? `${size.width}x${size.height}` : "WxH"); + if (values.name_counter) parts.push("#####"); + if (values.name_batch) parts.push("b###"); + return `${parts.join("_")}.${ext}`; +} + +// The folder line of the preview: the output folder plus any subfolders +// from the folder field and the prefix, or an absolute folder as typed. +export function previewFolder(values, outputLabel = "ComfyUI/output") { + const folder = String(values.output_dir ?? "").trim().replace(/\\/g, "/"); + const prefix = String(values.filename_prefix ?? "").trim().replace(/\\/g, "/"); + const mode = namingMode({ filenameLinked: values.filenameLinked, exactName: values.exact_name }); + const prefixFolder = mode === "local" ? prefix.split("/").filter((part) => part && part !== ".").slice(0, -1).join("/") : ""; + const absolute = /^([A-Za-z]:[\\/]|\/|~)/.test(folder); + const segments = absolute ? [folder.replace(/\/+$/, "")] : [outputLabel, folder.replace(/^\/+|\/+$/g, "")]; + if (prefixFolder) segments.push(prefixFolder); + return segments.filter(Boolean).join("/") + "/"; +} + +// Tags only decorate a locally composed name. +export function tagsEnabled(values) { + return namingMode({ filenameLinked: values.filenameLinked, exactName: values.exact_name }) === "local"; +} diff --git a/js/shared/scrub_input.mjs b/js/shared/scrub_input.mjs index 9632731..afe8973 100644 --- a/js/shared/scrub_input.mjs +++ b/js/shared/scrub_input.mjs @@ -67,6 +67,7 @@ function ensureScrubCss() { padding:0;margin:0;background:transparent;color:inherit;text-align:center;cursor:ew-resize; user-select:none;font:inherit;outline:none;border-radius:0} .ausboss-scrub>.ausboss-scrub-input:focus{cursor:text;user-select:text} + .ausboss-scrub>.ausboss-scrub-unit{flex:none;align-self:center;padding:0 3px 0 1px;color:#5f7674;font-size:9.5px;letter-spacing:.06em;text-transform:uppercase;text-align:center;user-select:none;pointer-events:none} .ausboss-scrub>.ausboss-scrub-step{flex:none;width:14px;display:flex;flex-direction:column; border-left:1px solid #3a4047} .ausboss-scrub>.ausboss-scrub-step>button{flex:1 1 0;min-height:0;border:none;background:transparent; @@ -88,6 +89,7 @@ export function makeScrubInput(options = {}) { value: 0, min: -Infinity, max: Infinity, step: 1, fineStep: null, decimals: 2, width: null, title: "", onChange: null, onSettle: null, + unit: "", unitWidth: 0, ...options, }; let current = quantizeScrubValue(opts.value, opts); @@ -196,7 +198,22 @@ export function makeScrubInput(options = {}) { steppers.append(button); } - box.append(input, steppers); + if (opts.unit || opts.unitWidth > 0) { + // The unit rides inside the box, ahead of the chevrons. A `unitWidth` + // reserves that slot at a fixed width whether or not this field has a + // unit, so in a column of fields every number is centred on the same + // axis - a "px" here and nothing there no longer nudges one of them. + const unit = document.createElement("span"); + unit.className = "ausboss-scrub-unit"; + unit.textContent = opts.unit; + if (opts.unitWidth > 0) { + unit.style.width = `${opts.unitWidth}px`; + unit.style.padding = "0"; + } + box.append(input, unit, steppers); + } else { + box.append(input, steppers); + } return { root: box, input, diff --git a/js/shared/seed_history.mjs b/js/shared/seed_history.mjs new file mode 100644 index 0000000..dc8809b --- /dev/null +++ b/js/shared/seed_history.mjs @@ -0,0 +1,26 @@ +// Seed node logic: the "last used" memory. No DOM, no ComfyUI imports. + +export const SEED_HISTORY_LIMIT = 8; + +function isSeed(value) { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} + +// The backend reports the seed a run actually used via its ui payload. +export function seedFromExecuted(message) { + const value = message?.seed; + const first = Array.isArray(value) ? value[0] : value; + if (typeof first === "string" && /^\d+$/.test(first)) return Number(first); + return isSeed(first) ? first : null; +} + +// Most recent first, no duplicates (a re-run moves its seed to the front). +export function pushSeed(history, seed, limit = SEED_HISTORY_LIMIT) { + const kept = Array.isArray(history) ? history.filter((item) => isSeed(item) && item !== seed) : []; + if (!isSeed(seed)) return kept.slice(0, limit); + return [seed, ...kept].slice(0, limit); +} + +export function formatSeed(seed) { + return isSeed(seed) ? String(seed) : "—"; +} diff --git a/js/shared/settings_menu.mjs b/js/shared/settings_menu.mjs index 9d4da64..a26393d 100644 --- a/js/shared/settings_menu.mjs +++ b/js/shared/settings_menu.mjs @@ -8,6 +8,7 @@ // title: "LoRA Loader settings", // onChange: (values, key) => rerender(values) }); +import { makeScrubInput } from "./scrub_input.mjs"; import { BRAND } from "./index.mjs"; import { isOverrideActive, @@ -209,6 +210,15 @@ function buildControl(entry, values, commit) { } return seg; } + if (entry.type === "number" && entry.scrub) { + const control = makeScrubInput({ + value: values[entry.key], min: entry.min, max: entry.max, + step: entry.step ?? 1, fineStep: entry.step ?? 1, decimals: 0, + title: entry.hint ?? entry.label, + onChange: (value) => commit(entry, value), + }); + return control.root; + } const input = el("input", `ausboss-set-input${entry.type === "text" ? " wide" : ""}`); input.type = "text"; if (entry.type === "number") input.inputMode = "decimal"; diff --git a/js/shared/stitch_preview.mjs b/js/shared/stitch_preview.mjs new file mode 100644 index 0000000..0537c82 --- /dev/null +++ b/js/shared/stitch_preview.mjs @@ -0,0 +1,131 @@ +// The stitch mask math, mirrored from the backend so the editor's Show +// blend overlay is the paste mask the stitcher will actually use - not a +// sketch of it. Pure Float32Array functions on a row-major mask (1 = +// generated), testable under node:test against a fixture the Python +// helpers produced (tests/fixtures/stitch_blend_parity.json). +// +// Mirrors nodes/_mask_helpers.py grow_shrink_mask (square max filter, +// separable, -inf padding) and blur_mask (gaussian, radius ceil(3 sigma), +// replicate padding), and nodes/_inpaint_crop_helpers.py +// stitch_blend_from_mask (signed grow, then grow by blend and blur by +// blend / 3). + +// Sliding window max/min over one line, window 2r+1, missing samples ignored +// (that is the -inf padding of a max pool). Monotonic deque: O(n) per line. +function slidingExtreme(line, out, r, useMax) { + const n = line.length; + const deque = new Int32Array(n); + let head = 0, tail = 0; + const better = useMax ? (a, b) => a >= b : (a, b) => a <= b; + for (let i = 0; i < n + r; i++) { + if (i < n) { + while (tail > head && better(line[i], line[deque[tail - 1]])) tail--; + deque[tail++] = i; + } + const center = i - r; + if (center >= 0) { + while (deque[head] < center - r) head++; + out[center] = line[deque[head]]; + } + } +} + +export function growShrinkMask(values, width, height, pixels) { + const steps = Math.abs(Math.round(pixels)); + if (!steps) return Float32Array.from(values); + const useMax = pixels > 0; + const pass = new Float32Array(values.length); + const row = new Float32Array(width), rowOut = new Float32Array(width); + for (let y = 0; y < height; y++) { + row.set(values.subarray(y * width, (y + 1) * width)); + slidingExtreme(row, rowOut, steps, useMax); + pass.set(rowOut, y * width); + } + const out = new Float32Array(values.length); + const col = new Float32Array(height), colOut = new Float32Array(height); + for (let x = 0; x < width; x++) { + for (let y = 0; y < height; y++) col[y] = pass[y * width + x]; + slidingExtreme(col, colOut, steps, useMax); + for (let y = 0; y < height; y++) out[y * width + x] = colOut[y]; + } + return out; +} + +export function gaussianKernel(sigma) { + const radius = Math.max(1, Math.ceil(sigma * 3)); + const kernel = new Float32Array(radius * 2 + 1); + let sum = 0; + for (let i = -radius; i <= radius; i++) { const v = Math.exp(-(i * i) / (2 * sigma * sigma)); kernel[i + radius] = v; sum += v; } + for (let i = 0; i < kernel.length; i++) kernel[i] /= sum; + return { kernel, radius }; +} + +export function blurMask(values, width, height, sigma) { + if (!(sigma > 0)) return Float32Array.from(values); + const { kernel, radius } = gaussianKernel(sigma); + const pass = new Float32Array(values.length); + for (let y = 0; y < height; y++) { + const base = y * width; + for (let x = 0; x < width; x++) { + let acc = 0; + for (let k = -radius; k <= radius; k++) { + const sx = Math.min(width - 1, Math.max(0, x + k)); // replicate padding + acc += values[base + sx] * kernel[k + radius]; + } + pass[base + x] = acc; + } + } + const out = new Float32Array(values.length); + for (let x = 0; x < width; x++) { + for (let y = 0; y < height; y++) { + let acc = 0; + for (let k = -radius; k <= radius; k++) { + const sy = Math.min(height - 1, Math.max(0, y + k)); + acc += pass[sy * width + x] * kernel[k + radius]; + } + out[y * width + x] = acc; + } + } + return out; +} + +function clamp01(values) { + for (let i = 0; i < values.length; i++) values[i] = values[i] < 0 ? 0 : values[i] > 1 ? 1 : values[i]; + return values; +} + +// stitch_blend_from_mask: signed grow first, then the blend ramp - grown +// by `blend` and blurred with sigma blend / 3 - clamped to 0..1. +export function stitchBlendFromMask(values, width, height, blendPixels, growPixels = 0) { + const grow = Math.round(growPixels) || 0; + const ramp = Math.max(0, Math.round(blendPixels) || 0); + let out = values; + if (grow) out = growShrinkMask(out, width, height, grow); + if (ramp > 0) { out = growShrinkMask(out, width, height, ramp); out = blurMask(out, width, height, ramp / 3); } + return (grow || ramp) ? clamp01(out) : Float32Array.from(values); +} + +// The transform's own mask feather (transform_pil): the generated-area +// mask blurred, doubled and clipped, then max'd with the original - the +// ramp starts at full strength on the edge and runs into the kept pixels. +export function featherGeneratedMask(values, width, height, featherPixels) { + if (!(featherPixels > 0)) return Float32Array.from(values); + const blurred = blurMask(values, width, height, featherPixels); + const out = new Float32Array(values.length); + for (let i = 0; i < out.length; i++) out[i] = Math.max(values[i], Math.min(1, blurred[i] * 2)); + return out; +} + +// Work-resolution planning for the overlay. Blend and grow are output +// pixels AFTER any resize; the stage draws the pre-resize canvas. One +// output pixel is `unit` work pixels (the two axes can differ by a step +// rounding; the mean keeps the ramp within a percent of either). +export function overlayPlan(outputWidth, outputHeight, target, maxSide = 384) { + const k = Math.min(1, maxSide / Math.max(1, outputWidth, outputHeight)); + const width = Math.max(1, Math.round(outputWidth * k)); + const height = Math.max(1, Math.round(outputHeight * k)); + const targetWidth = target?.width || outputWidth; + const targetHeight = target?.height || outputHeight; + const unit = k * ((outputWidth / targetWidth) + (outputHeight / targetHeight)) / 2; + return { k, width, height, unit }; +} diff --git a/js/shared/transform_editor.mjs b/js/shared/transform_editor.mjs index 695fa1b..05e8f8a 100644 --- a/js/shared/transform_editor.mjs +++ b/js/shared/transform_editor.mjs @@ -1,12 +1,23 @@ import { api } from "/scripts/api.js"; import { app } from "/scripts/app.js"; +import { hideInputsInDef, hideWidget } from "./widget_visibility.mjs"; +import { mountTransformTrim } from "./transform_trim.mjs"; import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "./index.mjs"; import { fillNodeHeight } from "./panel_layout.mjs"; import { normalizeFillColor } from "./fill_color.mjs"; import { makeScrubInput } from "./scrub_input.mjs"; +import { featherGeneratedMask, overlayPlan, stitchBlendFromMask } from "./stitch_preview.mjs"; +import { + INPUT_FOLDER_MODE, + LOCAL_PATH_MODE, + normalizeVideoOptions, + mediaSourceState, + videoSourceState, +} from "./video_source_card.mjs"; import { canvasLocalPoint, clamp, + fitSourceToAspect, cropHandleCenters, nearestHandle, paddingHandleCenters, @@ -23,9 +34,14 @@ import { } from "./transform_geometry.mjs"; const HIDDEN_WIDGETS = [ + "image", "upload", + "video", "source_mode", "local_path", "rotation_degrees", "crop_aspect_ratio", "crop_x", "crop_y", "crop_width", "crop_height", "pad_left", "pad_top", "pad_right", "pad_bottom", "feather", "canvas_multiple", "fill_color", "seek_mode", "frame_index", "frame_time", + "start_seconds", "end_seconds", "every_nth", "max_frames", "frame_snap", + // Clip-node stitch settings, driven by the editor's Inpaint & Stitch section. + "stitch_blend", "stitch_grow", // Image-node resize block; hideWidget on a missing widget is a no-op, so // the video node sharing this list is unaffected. "resize_to_megapixels", "megapixels", "resize_method", "resolution_steps", @@ -41,11 +57,29 @@ function installStyles() { style.textContent = ` .ausboss-transform-panel{display:flex;flex-direction:column;gap:8px;padding:8px;color:#ddd;font:12px system-ui;box-sizing:border-box;width:100%;height:100%;overflow:hidden} .ausboss-transform-preview{width:100%;flex:1 1 180px;min-height:0;border:1px solid #50555b;border-radius:8px;background:#111;display:block;touch-action:none} + .ausboss-transform-source{display:flex;flex-direction:column;gap:7px;flex:0 0 auto;padding:8px;border:1px solid rgba(0,184,174,.28);border-radius:8px;background:rgba(0,0,0,.24)} + .ausboss-transform-source-heading{color:${BRAND};font:600 10px system-ui;letter-spacing:.08em;text-transform:uppercase} + .ausboss-transform-source-mode{display:grid;grid-template-columns:1fr 1fr;height:30px;padding:3px;border:1px solid #2a3437;border-radius:7px;background:#0f1516} + .ausboss-transform-source-mode button{border:0;border-radius:5px;background:transparent;color:#8ba3a1;font:600 12px system-ui;cursor:pointer} + .ausboss-transform-source-mode button:hover{color:#fff}.ausboss-transform-source-mode button.on{background:${BRAND};color:#04201d} + .ausboss-transform-source-field{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px;align-items:center} + .ausboss-transform-source-field select,.ausboss-transform-source-field input{box-sizing:border-box;width:100%;height:34px;min-width:0;padding:0 10px;border:1px solid #2a3437;border-radius:7px;outline:0;background:#0b0f10;color:#dce9e8;font:12px ui-monospace,"SF Mono",Menlo,Consolas,monospace} + .ausboss-transform-source-field select:focus,.ausboss-transform-source-field input:focus{border-color:${BRAND}} + .ausboss-transform-source-action{height:34px;box-sizing:border-box;white-space:nowrap} + .ausboss-transform-source-field:has(input[type=text]){grid-template-columns:minmax(0,1fr)} + .ausboss-transform-source-hint{overflow:hidden;color:#6f8886;font-size:10.5px;line-height:1.25;white-space:nowrap;text-overflow:ellipsis} .lg-node:has(.ausboss-transform-panel) .image-preview{display:none!important} .ausboss-transform-row{display:flex;gap:7px;align-items:center;flex:0 0 auto}.ausboss-transform-row>*{min-width:0;flex:1} .ausboss-transform-check{display:flex;align-items:center;justify-content:center;gap:5px;background:#30343a;color:#eee;border:1px solid #555b63;border-radius:5px;padding:6px 8px;cursor:pointer;white-space:nowrap;user-select:none} .ausboss-transform-check:hover{border-color:${BRAND};background:#383e44} .ausboss-transform-check input{accent-color:${BRAND};margin:0;flex:0 0 auto;cursor:pointer} + .ausboss-transform-aspects{display:flex;gap:5px;align-items:center;flex:0 0 auto} + .ausboss-transform-aspects>span{flex:0 0 auto;color:#8ca8a5;font-size:10px;padding:0 3px;user-select:none} + .ausboss-transform-aspect{flex:1 1 0;min-width:0;background:#262a30;color:#cfd6dc;border:1px solid #4a5058;border-radius:4px;height:28px;padding:3px 2px;font:600 10px system-ui;font-variant-numeric:tabular-nums;white-space:nowrap;cursor:pointer;text-align:center} + .ausboss-transform-aspect-flip{flex:0 0 30px;display:flex;align-items:center;justify-content:center;margin-right:3px} + .ausboss-transform-aspect-glyph{display:block;border:1px solid currentColor;border-radius:1px;box-sizing:border-box} + .ausboss-transform-aspect:hover{border-color:${BRAND};color:#fff} + .ausboss-transform-aspect.active{background:rgba(0,184,174,.18);border-color:${BRAND};color:#e5fffc} .ausboss-transform-button,.ausboss-transform-modal button{background:#30343a;color:#eee;border:1px solid #555b63;border-radius:5px;padding:7px 10px;cursor:pointer} .ausboss-transform-button:hover,.ausboss-transform-modal button:hover{border-color:${BRAND};background:#383e44} .ausboss-transform-file{position:relative;text-align:center;overflow:hidden}.ausboss-transform-file input{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer} @@ -67,11 +101,18 @@ function installStyles() { .ausboss-legend-pad{background:#ff9d42;border:1px solid #3b2108;width:9px;height:9px;transform:rotate(45deg)} .ausboss-final-preview{display:block;max-width:100%;margin:2px auto 0;border:1px solid #34383d;border-radius:6px;background:#0c0e10} .ausboss-transform-section label{display:grid;grid-template-columns:88px 1fr 58px;gap:7px;align-items:center;margin:7px 0} + .ausboss-transform-section label>.ausboss-scrub:last-child{width:58px} + .ausboss-transform-section label>*{min-width:0} .ausboss-transform-section input,.ausboss-transform-section select{box-sizing:border-box;width:100%;background:#0e1012;color:#eee;border:1px solid #454b52;border-radius:4px;padding:5px} .ausboss-transform-stage{position:relative;min-width:0;min-height:0;background-color:#0c0e10;background-image:radial-gradient(#292d31 1px,transparent 1px);background-size:18px 18px;overflow:hidden} .ausboss-transform-canvas{width:100%;height:100%;display:block;touch-action:none} .ausboss-transform-status{line-height:1.55;color:#b8bec5;white-space:pre-wrap}.ausboss-transform-help{line-height:1.55;color:#aeb4ba} - .ausboss-transform-timeline{display:flex;align-items:center;gap:6px;padding:8px 12px;border-top:1px solid #30343a;background:#17191c} + .ausboss-transform-sidebar.right .ausboss-transform-section{margin-top:13px} + .ausboss-transform-section details{margin:8px 0 0}.ausboss-transform-section summary{cursor:pointer;color:#8de0da;font-size:11px;text-transform:uppercase;letter-spacing:.06em;list-style:none;user-select:none} + .ausboss-transform-section summary::before{content:"▸";display:inline-block;width:12px;transition:transform .12s}.ausboss-transform-section details[open] summary::before{transform:rotate(90deg)} + .ausboss-transform-section input[type=checkbox]{width:auto;justify-self:start;accent-color:${BRAND}} + .ausboss-transform-timeline{display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:8px 12px;border-top:1px solid #30343a;background:#17191c} + .ausboss-transform-timeline>.ausboss-transform-trim{flex-basis:100%} .ausboss-transform-timeline input[type=range]{flex:1}.ausboss-transform-steps{display:flex;gap:4px;flex-wrap:wrap}.ausboss-transform-steps button{padding:5px 7px} .ausboss-transform-badge{padding:3px 7px;border-radius:99px;background:#263034;color:#8de0da;font-size:11px} @media(max-width:900px){.ausboss-transform-body{grid-template-columns:220px minmax(260px,1fr)}.ausboss-transform-sidebar.right{display:none}} @@ -90,15 +131,6 @@ function setValue(node, name, next) { function values(node) { return Object.fromEntries(Object.keys(TRANSFORM_DEFAULTS).map((name) => [name, value(node, name, TRANSFORM_DEFAULTS[name])])); } -function hideWidget(target) { - if (!target || target.__ausbossHidden) return; - target.__ausbossHidden = true; - target.__ausbossComputeSize = target.computeSize; - target.computeSize = () => [0, -4]; - target.options ??= {}; - target.options.hidden = true; - target.hidden = true; -} function suppressCoreImagePreview(node) { const previewIndex = node.widgets?.findIndex((item) => item.name === CORE_IMAGE_PREVIEW_WIDGET) ?? -1; @@ -126,11 +158,11 @@ function suppressCoreImagePreview(node) { function sourceKey(node, kind) { if (kind === "image") return String(value(node, "image", "")); - const mode = value(node, "source_mode", "input folder"); - const selection = String(mode === "local path" ? value(node, "local_path", "") : value(node, "video", "")); - // Empty selection is "no source yet": switching modes before picking a - // file must not count as a source change (which would reset transforms). - return selection ? `${mode === "local path" ? "local" : "input"}:${selection}` : ""; + return videoSourceState( + value(node, "source_mode", INPUT_FOLDER_MODE), + value(node, "video", ""), + value(node, "local_path", ""), + ).key; } function parseInputReference(selection) { @@ -145,35 +177,39 @@ function imageSourceUrl(selection) { return api.apiURL(`/view?${new URLSearchParams(reference)}`); } -function videoParams(node, maxSize = 1600) { +// peekIndex previews a frame other than the committed position (a trim +// handle mid-drag) without touching the preview widgets. +function videoParams(node, maxSize = 1600, peekIndex = null) { + const peeking = Number.isFinite(peekIndex); return new URLSearchParams({ source_mode: String(value(node, "source_mode", "input folder")), video: String(value(node, "video", "")), local_path: String(value(node, "local_path", "")), - seek_mode: String(value(node, "seek_mode", "frame index")), - frame_index: String(Math.max(0, Math.round(Number(value(node, "frame_index", 0)) || 0))), + seek_mode: peeking ? "frame index" : String(value(node, "seek_mode", "frame index")), + frame_index: String(peeking ? Math.max(0, Math.round(peekIndex)) : Math.max(0, Math.round(Number(value(node, "frame_index", 0)) || 0))), frame_time: String(Math.max(0, Number(value(node, "frame_time", 0)) || 0)), max_width: String(maxSize), max_height: String(maxSize), }); } -async function uploadVideo(node, file) { +async function uploadMedia(node, kind, file) { const body = new FormData(); body.append("image", file, file.name); body.append("type", "input"); const response = await api.fetchApi("/upload/image", { method: "POST", body }); - if (!response.ok) throw new Error((await response.text()) || "Video upload failed."); + if (!response.ok) throw new Error((await response.text()) || "Upload failed."); const result = await response.json(); const selection = result.subfolder ? `${result.subfolder}/${result.name}` : result.name; - const target = widget(node, "video"); - if (target?.options?.values && !target.options.values.includes(selection)) target.options.values.push(selection); - setValue(node, "source_mode", "input folder"); - setValue(node, "video", selection); + const target = widget(node, kind); + if (Array.isArray(target?.options?.values) && !target.options.values.includes(selection)) target.options.values.push(selection); + if (kind === "video") setValue(node, "source_mode", "input folder"); + setValue(node, kind, selection); return selection; } function resetTransform(node, includeTimeline = false) { + if (node.properties) delete node.properties.ausboss_fit_aspect; for (const [name, next] of Object.entries(resetTransformValues(includeTimeline))) setValue(node, name, next); node.setDirtyCanvas?.(true, true); } @@ -192,6 +228,125 @@ function addLabeledControl(section, title, control, suffix = "") { return label; } +function buildMediaSourceCard(state) { + const { node, kind } = state; + const root = createElement("div", "ausboss-transform-source"); + const modes = createElement("div", "ausboss-transform-source-mode"); + const uploadsMode = createElement("button", "", "Uploads"); + const localMode = createElement("button", "", "Local path"); + uploadsMode.type = localMode.type = "button"; + uploadsMode.title = "Choose a video already in ComfyUI's input folder or upload another."; + localMode.title = "Read a video directly from an absolute path on this server without copying it."; + modes.append(uploadsMode, localMode); + + const field = createElement("div", "ausboss-transform-source-field"); + const selection = createElement("select"); + selection.setAttribute("aria-label", `Uploaded ${kind}`); + const localPath = createElement("input"); + localPath.type = "text"; + localPath.spellcheck = false; + localPath.placeholder = "/absolute/path/to/video.mp4"; + localPath.setAttribute("aria-label", "Local video path"); + const upload = createElement("label", "ausboss-transform-button ausboss-transform-file ausboss-transform-source-action"); + const uploadText = createElement("span", "", "Upload"); + upload.append(uploadText); + const fileInput = createElement("input"); + fileInput.type = "file"; + fileInput.accept = `${kind}/*`; + fileInput.setAttribute("aria-label", `Upload ${kind}`); + upload.append(fileInput); + const hint = createElement("div", "ausboss-transform-source-hint"); + field.append(selection, upload); + root.append(kind === "image" ? createElement("div", "ausboss-transform-source-heading", "Image source") : modes, field, hint); + + const currentOptions = () => { + const target = widget(node, kind); + let options = target?.options?.values; + if (typeof options === "function") options = options(target, node); + return normalizeVideoOptions(options, target?.value); + }; + const sync = () => { + const source = mediaSourceState( + kind, + value(node, "source_mode", INPUT_FOLDER_MODE), + value(node, kind, ""), + value(node, "local_path", ""), + ); + uploadsMode.classList.toggle("on", source.mode === INPUT_FOLDER_MODE); + localMode.classList.toggle("on", source.mode === LOCAL_PATH_MODE); + selection.replaceChildren(); + const empty = createElement("option", "", `Choose an uploaded ${kind}…`); + empty.value = ""; + selection.append(empty); + for (const name of currentOptions()) { + const option = createElement("option", "", name); + option.value = name; + selection.append(option); + } + selection.value = String(value(node, kind, "")); + selection.title = selection.value || `Choose an uploaded ${kind}`; + localPath.value = String(value(node, "local_path", "")); + field.replaceChildren(); + if (source.mode === LOCAL_PATH_MODE) field.append(localPath); + else field.append(selection, upload); + hint.textContent = source.hint; + hint.title = source.mode === LOCAL_PATH_MODE + ? `${source.hint} Editor previews also require local preview access to be enabled by the server owner.` + : source.hint; + }; + const chooseMode = (mode) => { + if (value(node, "source_mode", INPUT_FOLDER_MODE) === mode) return; + setValue(node, "source_mode", mode); + sync(); + notifyAusbossChange(); + }; + uploadsMode.addEventListener("click", () => chooseMode(INPUT_FOLDER_MODE)); + localMode.addEventListener("click", () => chooseMode(LOCAL_PATH_MODE)); + selection.addEventListener("change", () => { + setValue(node, kind, selection.value); + sync(); + notifyAusbossChange(); + }); + const commitLocalPath = () => { + const next = localPath.value.trim(); + if (next !== String(value(node, "local_path", ""))) { + setValue(node, "local_path", next); + notifyAusbossChange(); + } + sync(); + }; + localPath.addEventListener("keydown", (event) => { + event.stopPropagation(); + if (event.key === "Enter") localPath.blur(); + if (event.key === "Escape") { localPath.value = String(value(node, "local_path", "")); localPath.blur(); } + }); + localPath.addEventListener("blur", commitLocalPath); + fileInput.addEventListener("change", async () => { + if (!fileInput.files?.[0]) return; + fileInput.disabled = true; + uploadText.textContent = "Uploading…"; + upload.setAttribute("aria-busy", "true"); + try { + await uploadMedia(node, kind, fileInput.files[0]); + sync(); + notifyAusbossChange(); + } catch (error) { + alert(`Crop + Rotate + Pad: ${error.message}`); + } finally { + fileInput.value = ""; + fileInput.disabled = false; + uploadText.textContent = "Upload"; + upload.removeAttribute("aria-busy"); + } + }); + root.addEventListener("pointerdown", (event) => { + if (event.target.closest("button,select,input,label")) event.stopPropagation(); + }); + state.syncSourceCard = sync; + sync(); + return root; +} + export function installTransformNode(node, kind, mountPanel = null) { installStyles(); const state = { @@ -202,7 +357,17 @@ export function installTransformNode(node, kind, mountPanel = null) { playbackTimer: null, playing: false, playbackSession: 0, disposed: false, loadSerial: 0, }; node.__ausbossTransformState = state; + state.isClip = Boolean(widget(node, "start_seconds") && widget(node, "end_seconds")); + state.trimViews = new Set(); + if (state.isClip) { + // The executor includes every input in its cache key, even when + // IS_CHANGED ignores it. Preview position is UI state, not clip input. + for (const [name, constant] of [["seek_mode", "frame index"], ["frame_index", 0], ["frame_time", 0]]) { + widget(node, name).serializeValue = () => constant; + } + } if (kind === "image") suppressCoreImagePreview(node); + if (kind === "video") installVideoDrop(state); for (const name of HIDDEN_WIDGETS) hideWidget(widget(node, name)); const panel = createElement("div", "ausboss-transform-panel"); @@ -210,20 +375,11 @@ export function installTransformNode(node, kind, mountPanel = null) { const row = createElement("div", "ausboss-transform-row"); const open = createElement("button", "ausboss-transform-button", "Open editor"); row.append(open); - if (kind === "video") { - const choose = createElement("label", "ausboss-transform-button ausboss-transform-file"); - choose.append(createElement("span", "", "Upload video")); - const fileInput = createElement("input"); - fileInput.type = "file"; fileInput.accept = "video/*"; fileInput.setAttribute("aria-label", "Upload video"); - fileInput.addEventListener("change", async () => { - if (!fileInput.files?.[0]) return; - try { await uploadVideo(node, fileInput.files[0]); await onSourceChanged(state, true); notifyAusbossChange(); } - catch (error) { alert(`Video Crop + Rotate + Pad: ${error.message}`); } - finally { fileInput.value = ""; } - }); - choose.append(fileInput); row.prepend(choose); - } - panel.append(preview, row); + panel.append(buildMediaSourceCard(state)); + panel.append(preview); + panel.append(buildAspectChipRow(state)); + if (state.isClip) panel.append(buildTrim(state)); + panel.append(row); if (kind === "image") panel.append(buildImageQuickRow(state)); state.previewCanvas = preview; open.addEventListener("click", () => openEditor(state)); @@ -233,11 +389,11 @@ export function installTransformNode(node, kind, mountPanel = null) { } else if (typeof node.addDOMWidget === "function") { const domWidget = node.addDOMWidget("ausboss_transform_preview", "ausboss_transform_preview", panel, { serialize: false }); keepDomWidgetWidthAuto(domWidget); - fillNodeHeight(domWidget, { minWidth: 300, minHeight: 230, minNodeSize: [300, 300] }); + fillNodeHeight(domWidget, { minWidth: 330, minHeight: state.isClip ? 480 : kind === "video" ? 360 : 260, minNodeSize: [330, state.isClip ? 680 : 420] }); } else { node.addWidget?.("button", "Open editor", null, () => openEditor(state), { serialize: false }); } - const baseHeight = kind === "video" ? 455 : 390; + const baseHeight = state.isClip ? 720 : kind === "video" ? 485 : 475; node.setSize?.([ Math.max(330, Math.min(520, node.size?.[0] || 330)), Math.max(baseHeight, node.computeSize?.()[1] || 0), @@ -254,12 +410,16 @@ export function installTransformNode(node, kind, mountPanel = null) { // it was the one that came out stretched. state.panelResizeObserver = new ResizeObserver(() => draw(state)); state.panelResizeObserver.observe(preview); + // The graph scales the DOM widget with its zoom, so the backing store + // sized at one zoom turns to mush at another: redraw when it changes. + chainCallback(node, "onDrawForeground", function () { + if (state.disposed || state.zoomRedraw || Math.abs(panelOversample() - (state.panelOversample ?? 1)) < 0.01) return; + state.zoomRedraw = requestAnimationFrame(() => { state.zoomRedraw = null; if (!state.disposed) draw(state); }); + }); } - if (kind === "image" && typeof node.addDOMWidget === "function") { - // The compact panel is a live stage for the image node: the same - // handles and drag logic as the editor over a fit-only view (no wheel - // zoom or pan on the node — the wheel keeps zooming the graph). The - // video panel stays a passive preview; its editor holds the timeline. + if (typeof node.addDOMWidget === "function") { + // All transform nodes share inline handles. Wheel/middle-drag still + // belong to the graph; only direct handle gestures are captured. state.panelInteractive = true; state.panelAbort = new AbortController(); attachStageHandlers(state, preview, state.panelAbort.signal); @@ -269,20 +429,126 @@ export function installTransformNode(node, kind, mountPanel = null) { for (const name of watched) { const target = widget(node, name); if (!target) continue; - const prior = target.callback; - target.callback = function (...args) { - const result = prior?.apply(this, args); + chainCallback(target, "callback", function () { + state.syncSourceCard?.(); if (state.ready) onSourceChanged(state, true); - return result; - }; + }); } + chainCallback(node, "onConfigure", () => queueMicrotask(() => { + if (state.disposed) return; + for (const name of HIDDEN_WIDGETS) hideWidget(widget(node, name)); + state.syncSourceCard?.(); + for (const trim of state.trimViews) trim.sync(); + if (state.ready) onSourceChanged(state, false); + })); queueMicrotask(async () => { + // Core's upload helper can add its button after our creation hook. + for (const name of HIDDEN_WIDGETS) hideWidget(widget(node, name)); state.ready = true; await onSourceChanged(state, false); }); return state; } +// Dropping a video file on the node uploads it and makes it the source, +// as core's upload widgets do for their own nodes. Core's canvas drop +// handler asks the node under the cursor first; a handled drop keeps it +// from spawning a separate Load Video node for the file. +const VIDEO_FILE_PATTERN = /\.(avi|m2ts|m4v|mkv|mov|mp4|mpeg|mpg|mts|webm)$/i; + +function installVideoDrop(state) { + const node = state.node; + node.onDragOver = (event) => { + const items = event?.dataTransfer?.items; + return Boolean(items && Array.from(items).some((item) => item.kind === "file")); + }; + node.onDragDrop = async (event) => { + const file = Array.from(event?.dataTransfer?.files ?? []).find( + (candidate) => String(candidate.type).startsWith("video/") || VIDEO_FILE_PATTERN.test(candidate.name), + ); + if (!file) return false; + try { + await uploadMedia(node, "video", file); + state.syncSourceCard?.(); + if (state.ready) await onSourceChanged(state, true); + notifyAusbossChange(); + } catch (error) { + alert(`Crop + Rotate + Pad: ${error.message}`); + } + return true; + }; +} + +// Format chips right under the preview: one tap pads the whole source to +// that aspect with centered fill bands (the editor's Pad to aspect), so an +// outpaint canvas is a single click. Tapping the lit chip clears it again. +const ASPECT_CHIP_ORDER = ["1:1", "4:3", "3:2", "16:9", "21:9"]; + +function buildAspectChipRow(state) { + const node = state.node; + const row = createElement("div", "ausboss-transform-aspects"); + const flip = createElement("button", "ausboss-transform-aspect ausboss-transform-aspect-flip"); + flip.type = "button"; + const glyph = createElement("span", "ausboss-transform-aspect-glyph"); + flip.append(glyph); + row.append(flip); + const caption = createElement("span", "", "Pad"); + caption.title = "Pad the whole source to a format with centered fill bands. Tap the lit chip to clear it."; + row.append(caption); + const portrait = () => { + const [w, h] = String(node.properties?.ausboss_fit_aspect ?? "").split(":").map(Number); + return w && h && w !== h ? h > w : Boolean(node.properties?.ausboss_pad_portrait); + }; + const oriented = (aspect) => portrait() ? aspect.split(":").reverse().join(":") : aspect; + flip.addEventListener("click", () => { + const next = !portrait(); + const current = String(node.properties?.ausboss_fit_aspect ?? ""); + node.properties ??= {}; + node.properties.ausboss_pad_portrait = next; + if (state.image && /^\d+:\d+$/.test(current) && current !== "1:1") { + fitAspect(state, current.split(":").reverse().join(":"), "pad"); + } + sync(); + notifyAusbossChange(); + }); + const chips = []; + for (const aspect of ASPECT_CHIP_ORDER) { + const chip = createElement("button", "ausboss-transform-aspect", aspect); + chip.type = "button"; + chip.addEventListener("click", () => { + if (!state.image) return; + node.properties ??= {}; + node.properties.ausboss_pad_portrait = portrait(); + if (chip.classList.contains("active")) { + fitAspect(state, "free", "pad"); + delete node.properties.ausboss_fit_aspect; + } else { + fitAspect(state, oriented(aspect), "pad"); + } + sync(); + }); + chips.push({ chip, aspect }); row.append(chip); + } + const sync = () => { + const current = String(node.properties?.ausboss_fit_aspect ?? ""); + flip.title = `${portrait() ? "Portrait" : "Landscape"} padding — click to flip to ${portrait() ? "landscape" : "portrait"}`; + flip.setAttribute("aria-label", flip.title); + flip.setAttribute("aria-pressed", String(portrait())); + glyph.style.width = portrait() ? "10px" : "16px"; + glyph.style.height = portrait() ? "16px" : "10px"; + for (const { chip, aspect } of chips) { + const ratio = oriented(aspect); + chip.textContent = ratio; + chip.title = `Pad to ${ratio}: keep every source pixel and add centered fill bands. Tap again to clear.`; + chip.classList.toggle("active", ratio === current); + chip.setAttribute("aria-pressed", String(ratio === current)); + } + }; + sync(); + state.syncAspectChips = sync; + return row; +} + // The image node's quick row under the canvas: reset, the feather on/off, // and the resize-to-megapixels toggle with its budget box. These mirror // hidden widgets, so the row re-reads them after a workflow restore lands @@ -333,6 +599,7 @@ function buildImageQuickRow(state) { title: "Output budget in megapixels (x 1024x1024).", onChange: (amount) => { setValue(node, "megapixels", amount); + draw(state); updateModalInfo(state); }, onSettle: () => notifyAusbossChange(), @@ -340,6 +607,7 @@ function buildImageQuickRow(state) { resize.box.addEventListener("change", () => { setValue(node, "resize_to_megapixels", resize.box.checked); budget.root.style.display = resize.box.checked ? "" : "none"; + draw(state); updateModalInfo(state); notifyAusbossChange(); }); @@ -362,9 +630,10 @@ async function onSourceChanged(state, reset) { const key = sourceKey(state.node, state.kind); if (reset && sourceChanged(state.source, key, state.ready)) { resetTransform(state.node, state.kind === "video"); + if (state.isClip) { setValue(state.node, "start_seconds", 0); setValue(state.node, "end_seconds", 0); } resetView(state); } - state.source = key; + if (key) state.source = key; await loadSource(state); } @@ -373,13 +642,16 @@ async function loadSource(state) { try { if (state.kind === "image") { const selection = value(state.node, "image", ""); - if (!selection) return; + if (!selection) { + state.image = null; state.sourceWidth = state.sourceHeight = 0; + drawEmpty(state, "Choose a source to begin"); return; + } const image = new Image(); await new Promise((resolve, reject) => { image.onload = resolve; image.onerror = () => reject(new Error("Could not load image preview.")); image.src = imageSourceUrl(selection); }); - if (serial !== state.loadSerial) return; + if (serial !== state.loadSerial || state.disposed) return; state.image = image; state.sourceWidth = image.naturalWidth; state.sourceHeight = image.naturalHeight; // Core's image-upload helper also installs a source preview. This node has // its own transformed preview, so keep only the useful one. @@ -403,6 +675,7 @@ async function loadSource(state) { } draw(state); updateModalInfo(state); } catch (error) { + if (serial !== state.loadSerial || state.disposed) return; if (error?.name === "AbortError") return; state.image = null; drawEmpty(state, error.message); @@ -410,8 +683,10 @@ async function loadSource(state) { } function syncTimelineRange(state) { + for (const trim of state.trimViews) trim.sync(); if (!state.timelineSlider) return; state.timelineSlider.max = String(Math.max(0, (state.metadata?.frame_count || 1) - 1)); + state.timelineSlider.value = String(value(state.node, "frame_index", 0)); if (state.timelineLabel) state.timelineLabel.textContent = `${state.timelineSlider.value} / ${state.timelineSlider.max}`; } @@ -482,7 +757,7 @@ function requestScrubFrame(state) { while (state.scrubPending && !state.disposed) { state.scrubPending = false; try { - await loadVideoFrame(state, ++state.loadSerial, { maxSize: SCRUB_PREVIEW_SIZE, syncWidgets: false }); + await loadVideoFrame(state, ++state.loadSerial, { maxSize: SCRUB_PREVIEW_SIZE, syncWidgets: false, peekIndex: state.peekIndex }); draw(state); updateModalInfo(state); } catch (error) { if (error?.name !== "AbortError") { drawEmpty(state, error.message); break; } @@ -493,11 +768,11 @@ function requestScrubFrame(state) { } async function loadVideoFrame(state, serial = ++state.loadSerial, options = {}) { - const { maxSize = 1600, syncWidgets = true } = options; + const { maxSize = 1600, syncWidgets = true, peekIndex = null } = options; state.frameController?.abort(); const controller = new AbortController(); state.frameController = controller; - const response = await api.fetchApi(`/ausboss/transform/video/frame?${videoParams(state.node, maxSize)}`, { signal: controller.signal }); + const response = await api.fetchApi(`/ausboss/transform/video/frame?${videoParams(state.node, maxSize, peekIndex)}`, { signal: controller.signal }); if (!response.ok) { const payload = await response.json().catch(() => ({})); throw new Error(payload.error || "Could not decode video preview frame."); @@ -521,6 +796,7 @@ async function loadVideoFrame(state, serial = ++state.loadSerial, options = {}) const actualTime = Number(response.headers.get("X-AusBoss-Frame-Time")); if (Number.isFinite(actualIndex)) setValue(state.node, "frame_index", actualIndex); if (Number.isFinite(actualTime)) setValue(state.node, "frame_time", actualTime); + syncTimelineRange(state); } } catch (error) { URL.revokeObjectURL(objectUrl); @@ -545,6 +821,7 @@ function openEditor(state) { buildControls(state, left); const status = createElement("div", "ausboss-transform-status"); status.dataset.ausbossStatus = ""; right.append(status); right.append(createElement("div", "ausboss-transform-help", "Drag cyan squares to crop. Drag inside the crop to move it. Orange diamonds add padding. The rotate knob at the top-right corner rotates; hold Shift to snap to 15 degrees. Wheel zooms. Middle mouse or Alt-drag pans.")); + if (state.isClip && widget(state.node, "stitch_blend")) right.append(buildStitchSection(state)); if (state.kind === "video") modal.append(buildTimeline(state)); document.body.append(modal); @@ -561,9 +838,11 @@ function openEditor(state) { function closeEditor(state) { const hadModal = Boolean(state.modal); stopPlayback(state); - state.scrubPending = false; + state.scrubPending = false; state.peekIndex = null; state.modalAbort?.abort(); state.resizeObserver?.disconnect(); state.modal?.remove(); - state.modal = null; state.canvas = null; state.finalPreviewCanvas = null; state.drag = null; state.grid = false; + if (state.modalTrim) state.trimViews.delete(state.modalTrim); + state.modalTrim = null; state.timelineSlider = null; state.timelineLabel = null; + state.modal = null; state.canvas = null; state.finalPreviewCanvas = null; state.drag = null; state.grid = false; state.syncEditorControls = null; state.syncStitchControls = null; state.blendOverlay = null; draw(state); state.node.setDirtyCanvas?.(true, true); // Sidebar and timeline controls write widgets without a canvas drag, so a // closing editor is their commit point. The disposal path (node removed, @@ -571,6 +850,88 @@ function closeEditor(state) { if (hadModal && !state.disposed) notifyAusbossChange(); } +// Inpaint & Stitch (clip node): the stitcher this node emits pastes the source +// frames back over the generated clip. Blend is the ramp where generated +// pixels take over; it is deliberately separate from the padding feather, +// which shapes the mask the model sees. Grow moves the paste boundary and +// sits behind a disclosure - most outpaints never touch it. +function buildStitchSection(state) { + const node = state.node; + const section = createElement("section", "ausboss-transform-section"); + section.append(sectionHeading("Inpaint & Stitch")); + section.append(createElement("div", "ausboss-transform-help", "Outside the paste mask the stitcher puts the source frames back bit-for-bit. Inside it - the padded and rotated-in area plus the blend ramp - the generation takes over.")); + const blend = makeScrubInput({ value: value(node, "stitch_blend", 32), min: 0, max: 512, step: 1, decimals: 0, + title: "Ramp where generated pixels fade over the source, in pixels of the output. Separate from the padding feather.", + onChange: (amount) => { setValue(node, "stitch_blend", amount); draw(state); updateModalInfo(state); }, onSettle: notifyAusbossChange }); + addLabeledControl(section, "Blend", blend.root, "px"); + const show = createElement("input"); show.type = "checkbox"; show.checked = Boolean(state.showBlend); + show.title = "Tint the paste mask the stitcher will use - the same mask math as the backend, at preview resolution."; + show.addEventListener("change", () => { state.showBlend = show.checked; draw(state); }); + addLabeledControl(section, "Show blend", show); + const advanced = createElement("details"); advanced.append(createElement("summary", "", "Advanced")); + const grow = makeScrubInput({ value: value(node, "stitch_grow", 0), min: -256, max: 256, step: 1, decimals: 0, + title: "Moves the paste boundary before the ramp. Positive lets the generation replace a strip of the source next to the seam; negative keeps more source.", + onChange: (amount) => { setValue(node, "stitch_grow", amount); draw(state); updateModalInfo(state); }, onSettle: notifyAusbossChange }); + addLabeledControl(advanced, "Grow paste", grow.root, "px"); + advanced.append(createElement("div", "ausboss-transform-help", "Use a few pixels of grow when a seam still shows: the generation then repaints the source edge too.")); + section.append(advanced); + state.syncStitchControls = () => { blend.set(value(node, "stitch_blend", 32)); grow.set(value(node, "stitch_grow", 0)); }; + return section; +} + +// Show blend: the stitcher's paste mask, computed the way the backend +// computes it, at preview resolution. The generated-area mask (padding and +// rotation voids, everything the rotated source does not cover inside the +// crop) is rasterised on the pre-resize canvas, feathered like the +// transform, then grown and blurred by the stitch settings converted from +// output pixels through any resize - the same steps as +// stitch_blend_from_mask, mirrored in stitch_preview.mjs and tested against +// the Python helper. Cached on its inputs: a drag that changes geometry +// rebuilds it, a pan or zoom does not. +function blendOverlayCanvas(state, render) { + const node = state.node; + const { source, crop, padding } = render; + const rotation = Number(value(node, "rotation_degrees", 0)) || 0; + const feather = Math.max(0, Number(value(node, "feather", 0)) || 0); + const blend = Math.max(0, Number(value(node, "stitch_blend", 32)) || 0); + const grow = Number(value(node, "stitch_grow", 0)) || 0; + const resize = value(node, "resize_to_megapixels", false) + ? scaleToMegapixels(padding.outputWidth, padding.outputHeight, value(node, "megapixels", 1), value(node, "resolution_steps", 1)) + : null; + const key = JSON.stringify([state.sourceWidth, state.sourceHeight, rotation, crop, padding, feather, blend, grow, resize]); + if (state.blendOverlay?.key === key) return state.blendOverlay.canvas; + const plan = overlayPlan(padding.outputWidth, padding.outputHeight, resize); + const { width, height, k } = plan; + const raster = document.createElement("canvas"); raster.width = width; raster.height = height; + const rc = raster.getContext("2d", { willReadFrequently: true }); + rc.fillStyle = "#fff"; rc.fillRect(0, 0, width, height); + rc.save(); + rc.beginPath(); rc.rect(padding.left * k, padding.top * k, crop.width * k, crop.height * k); rc.clip(); + rc.translate((padding.left - crop.x) * k + source.width * k / 2, (padding.top - crop.y) * k + source.height * k / 2); + rc.rotate(rotation * Math.PI / 180); + rc.fillStyle = "#000"; rc.fillRect(-state.sourceWidth * k / 2, -state.sourceHeight * k / 2, state.sourceWidth * k, state.sourceHeight * k); + rc.restore(); + const pixels = rc.getImageData(0, 0, width, height).data; + let mask = new Float32Array(width * height); + for (let i = 0; i < mask.length; i++) mask[i] = pixels[i * 4] / 255; + mask = featherGeneratedMask(mask, width, height, feather * k); + mask = stitchBlendFromMask(mask, width, height, blend * plan.unit, grow * plan.unit); + const overlay = document.createElement("canvas"); overlay.width = width; overlay.height = height; + const oc = overlay.getContext("2d"); const image = oc.createImageData(width, height); const data = image.data; + for (let i = 0; i < mask.length; i++) { data[i * 4] = 0; data[i * 4 + 1] = 184; data[i * 4 + 2] = 174; data[i * 4 + 3] = Math.round(mask[i] * 150); } + oc.putImageData(image, 0, 0); + state.blendOverlay = { key, canvas: overlay, mask, width, height }; + return overlay; +} + +function drawBlendOverlay(context, state, render) { + const overlay = blendOverlayCanvas(state, render); + const { outputRect } = render; + context.save(); context.imageSmoothingEnabled = true; + context.drawImage(overlay, outputRect.x, outputRect.y, outputRect.width, outputRect.height); + context.restore(); +} + // Section headers carry a small marker matching the on-canvas handle for // that group (cyan square = crop, green knob = rotate, orange diamond = // padding), teaching the editor's color language without a word of text. @@ -605,22 +966,38 @@ function buildControls(state, sidebar) { let ratioValues = ratioWidget?.options?.values; if (typeof ratioValues === "function") ratioValues = ratioValues(ratioWidget, node); if (!Array.isArray(ratioValues) || !ratioValues.length) ratioValues = ["free", "source", "1:1", "9:16", "16:9", "2:3", "3:2", "3:4", "4:3", "9:21", "21:9"]; - const currentRatio = String(value(node, "crop_aspect_ratio", "free")); + // The target is a UI preference, separate from the inner crop lock. + // Pad to aspect deliberately unlocks that inner crop to keep all pixels. + const currentRatio = String(node.properties?.ausboss_fit_aspect ?? value(node, "crop_aspect_ratio", "free")); if (!ratioValues.includes(currentRatio)) ratioValues = [...ratioValues, currentRatio]; for (const optionValue of ratioValues) { const option = createElement("option", "", optionValue); option.value = optionValue; ratio.append(option); } - ratio.value = currentRatio; ratio.addEventListener("change", () => { setValue(node, "crop_aspect_ratio", ratio.value); fitCrop(state); }); - addLabeledControl(cropSection, "Aspect", ratio); - const fit = createElement("button", "", "Fit crop to source"); fit.addEventListener("click", () => fitCrop(state)); cropSection.append(fit); + ratio.value = currentRatio; + ratio.setAttribute("aria-label", "Target aspect"); + ratio.title = "Choose a target, then Crop or Pad. Changing the target alone does not alter your framing."; + ratio.addEventListener("change", () => { + node.properties ??= {}; node.properties.ausboss_fit_aspect = ratio.value; + notifyAusbossChange(); + }); + addLabeledControl(cropSection, "Target aspect", ratio); + const fitRow = createElement("div", "ausboss-transform-row"); + for (const [mode, title, tip] of [ + ["crop", "Crop to aspect", "Center the largest crop inside the rotated source. Removes pixels and resets padding."], + ["pad", "Pad to aspect", "Keep the full rotated source. Add centered fill-color bands to reach the target aspect; no stretching or cropping."], + ]) { + const button = createElement("button", "", title); button.title = tip; + button.addEventListener("click", () => fitAspect(state, ratio.value, mode)); fitRow.append(button); + } + cropSection.append(fitRow, createElement("div", "ausboss-transform-help", "Crop trims. Pad keeps the whole source. Both replace the current crop and padding; rotation stays. Free restores the full source.")); const rotateSection = createElement("section", "ausboss-transform-section"); rotateSection.append(sectionHeading("Rotate", "rotate")); const rotation = createElement("input"); rotation.type = "range"; rotation.min = "-180"; rotation.max = "180"; rotation.step = "0.1"; rotation.value = value(node, "rotation_degrees", 0); - const rotationNumber = createElement("input"); rotationNumber.type = "number"; rotationNumber.min = "-180"; rotationNumber.max = "180"; rotationNumber.step = "0.1"; rotationNumber.value = rotation.value; - rotation.addEventListener("input", () => { rotationNumber.value = rotation.value; setRotation(state, Number(rotation.value)); }); - rotationNumber.addEventListener("change", () => { rotation.value = rotationNumber.value; setRotation(state, Number(rotationNumber.value)); }); - addLabeledControl(rotateSection, "Degrees", rotation, ""); rotateSection.append(rotationNumber); - const zeroRotation = createElement("button", "", "Reset rotation"); zeroRotation.addEventListener("click", () => { rotation.value = "0"; rotationNumber.value = "0"; setRotation(state, 0); }); rotateSection.append(zeroRotation); + const rotationNumber = makeScrubInput({ value: Number(rotation.value), min: -180, max: 180, step: 1, fineStep: 0.1, decimals: 1, + title: "Rotation in degrees. Shift scrubs in tenths.", onChange: (degrees) => setRotation(state, degrees), onSettle: notifyAusbossChange }); + rotation.addEventListener("input", () => setRotation(state, Number(rotation.value))); + addLabeledControl(rotateSection, "Degrees", rotation, ""); rotateSection.append(rotationNumber.root); + const zeroRotation = createElement("button", "", "Reset rotation"); zeroRotation.addEventListener("click", () => setRotation(state, 0)); rotateSection.append(zeroRotation); const padSection = createElement("section", "ausboss-transform-section"); padSection.append(sectionHeading("Padding & mask", "pad")); const color = createElement("input"); color.type = "color"; color.value = normalizeColor(value(node, "fill_color", "#808080")); color.addEventListener("input", () => { setValue(node, "fill_color", color.value); draw(state); }); @@ -628,32 +1005,32 @@ function buildControls(state, sidebar) { // Feather: slider for coarse sweeps plus a number box (with up/down // arrows) for granular single-pixel control. const feather = createElement("input"); feather.type = "range"; feather.min = "0"; feather.max = "512"; feather.step = "1"; feather.value = value(node, "feather", 24); - const featherNumber = createElement("input"); featherNumber.type = "number"; featherNumber.min = "0"; featherNumber.max = "4096"; featherNumber.step = "1"; featherNumber.value = feather.value; const applyFeather = (raw) => { const amount = Math.max(0, Math.min(4096, Math.round(Number(raw) || 0))); - feather.value = String(Math.min(512, amount)); featherNumber.value = String(amount); setValue(node, "feather", amount); draw(state); }; + const featherNumber = makeScrubInput({ value: Number(feather.value), min: 0, max: 4096, step: 1, decimals: 0, + title: "Mask feather in pixels.", onChange: applyFeather, onSettle: notifyAusbossChange }); feather.addEventListener("input", () => applyFeather(feather.value)); - featherNumber.addEventListener("input", () => applyFeather(featherNumber.value)); - const featherLabel = addLabeledControl(padSection, "Feather", feather); featherLabel.lastElementChild.replaceWith(featherNumber); - const multiple = createElement("input"); multiple.type = "number"; multiple.min = "1"; multiple.max = "4096"; multiple.step = "1"; multiple.value = value(node, "canvas_multiple", 1); - multiple.addEventListener("change", () => { setValue(node, "canvas_multiple", Math.max(1, Number(multiple.value) || 1)); draw(state); }); - addLabeledControl(padSection, "Multiple", multiple, "px"); + const featherLabel = addLabeledControl(padSection, "Feather", feather); featherLabel.lastElementChild.replaceWith(featherNumber.root); + const multiple = makeScrubInput({ value: value(node, "canvas_multiple", 1), min: 1, max: 4096, step: 1, decimals: 0, + title: "Round the outer canvas up to a pixel multiple. This can slightly change the fitted aspect.", + onChange: (amount) => { setValue(node, "canvas_multiple", amount); draw(state); }, onSettle: notifyAusbossChange }); + addLabeledControl(padSection, "Multiple", multiple.root, "px"); const resetPad = createElement("button", "", "Reset padding"); resetPad.addEventListener("click", () => { for (const name of ["pad_left", "pad_top", "pad_right", "pad_bottom"]) setValue(node, name, 0); draw(state); }); padSection.append(resetPad); - // Resize to a pixel budget (image node only): mirrors the core Scale + // Resize to a pixel budget (image and clip nodes): mirrors the core Scale // Image to Total Pixels trio - megapixels, method, resolution steps - // so the output lands render-ready without another node. let resizeSection = null; - if (state.kind === "image") { + if (widget(node, "resize_to_megapixels")) { resizeSection = createElement("section", "ausboss-transform-section"); resizeSection.append(sectionHeading("Resize output")); const enable = createElement("input"); enable.type = "checkbox"; enable.checked = Boolean(value(node, "resize_to_megapixels", false)); const applyResize = () => { setValue(node, "resize_to_megapixels", enable.checked); - state.syncQuickRow?.(); + draw(state); updateModalInfo(state); }; enable.addEventListener("change", applyResize); @@ -664,7 +1041,7 @@ function buildControls(state, sidebar) { title: "Output budget in megapixels (x 1024x1024).", onChange: (amount) => { setValue(node, "megapixels", amount); - state.syncQuickRow?.(); + draw(state); updateModalInfo(state); }, }); @@ -683,6 +1060,7 @@ function buildControls(state, sidebar) { title: "Rounds each resized dimension to a multiple of this.", onChange: (step) => { setValue(node, "resolution_steps", step); + draw(state); updateModalInfo(state); }, }); @@ -705,6 +1083,12 @@ function buildControls(state, sidebar) { sidebar.append(cropSection, rotateSection, padSection); if (resizeSection) sidebar.append(resizeSection); sidebar.append(actions, previewSection); + state.syncEditorControls = () => { + rotation.value = value(node, "rotation_degrees", 0); rotationNumber.set(Number(rotation.value)); + feather.value = Math.min(512, value(node, "feather", 24)); featherNumber.set(value(node, "feather", 24)); + multiple.set(value(node, "canvas_multiple", 1)); color.value = normalizeColor(value(node, "fill_color", "#808080")); + ratio.value = node.properties?.ausboss_fit_aspect ?? value(node, "crop_aspect_ratio", "free"); + }; } // Renders what the node will actually output: fill background, the rotated @@ -802,7 +1186,40 @@ function buildTimeline(state) { // that was actually decoded. slider.addEventListener("change", () => seekFrame(state)); state.timelineSlider = slider; state.timelineLabel = label; - label.textContent = `${slider.value} / ${slider.max}`; timeline.append(slider, label, steps); return timeline; + slider.setAttribute("aria-label", state.isClip ? "Preview frame (does not trim)" : "Output frame"); + label.textContent = `${slider.value} / ${slider.max}`; timeline.append(slider, label, steps); + if (state.isClip) { + const trim = buildTrim(state); + trim.style.gridColumn = "1 / -1"; + timeline.append(trim); + state.modalTrim = [...state.trimViews].at(-1); + } + return timeline; +} + +function buildTrim(state) { + const control = mountTransformTrim({ + get: (name, fallback) => value(state.node, name, fallback), + set: (name, next) => { + setValue(state.node, name, next); + for (const view of state.trimViews) view.sync(); + }, + has: (name) => Boolean(widget(state.node, name)), + metadata: () => state.metadata, + // A moving IN/OUT handle peeks: the stage shows the frame under the + // handle and returns to the playhead on release. The scrub bar and the + // preview-position widgets never move with a trim. + onSeek: (seconds, settled) => { + if (settled) { state.peekIndex = null; void seekFrame(state); return; } + const fps = Math.max(1, state.metadata?.fps || 30); + const index = Math.min(Math.max(0, (state.metadata?.frame_count || 1) - 1), Math.round(seconds * fps)); + state.peekIndex = index; + showScrubGhost(state, index); requestScrubFrame(state); + }, + onCommit: notifyAusbossChange, + }); + state.trimViews.add(control); + return control.root; } // Light variant for continuous motion (playback, held arrow keys): reduced @@ -878,6 +1295,13 @@ function keyUp(state, event) { function fitCrop(state) { setValue(state.node, "crop_x", 0); setValue(state.node, "crop_y", 0); setValue(state.node, "crop_width", 0); setValue(state.node, "crop_height", 0); draw(state); } +function fitAspect(state, aspect, mode) { + if (!state.image || !state.sourceWidth || !state.sourceHeight) return; + const source = rotatedSize(state.sourceWidth, state.sourceHeight, value(state.node, "rotation_degrees", 0)); + for (const [name, next] of Object.entries(fitSourceToAspect(source, aspect, mode))) setValue(state.node, name, next); + state.node.properties ??= {}; state.node.properties.ausboss_fit_aspect = aspect; + resetView(state); draw(state); updateModalInfo(state); notifyAusbossChange(); +} function setRotation(state, degrees) { setValue(state.node, "rotation_degrees", Math.round(clamp(degrees, -180, 180) * 10) / 10); fitCrop(state); draw(state); updateModalInfo(state); @@ -930,18 +1354,32 @@ function renderGeometry(state, width, height, view, map = null) { return { source, crop, padding, scale, originX, originY, layout, sourceRect, cropRect, outputRect }; } -function prepareCanvas(canvas) { - const width = Math.max(1, canvas.clientWidth || 1); const height = Math.max(1, canvas.clientHeight || 1); const dpr = window.devicePixelRatio || 1; +// The node preview sits in a DOM widget the graph scales with its zoom, so +// its backing store is sized for the zoom in play (capped) - the same +// sharpness core's canvas-drawn image previews get for free. +const PREVIEW_MAX_OVERSAMPLE = 4; +function panelOversample() { + const zoom = Number(app.canvas?.ds?.scale) || 1; + return clamp(zoom, 1, PREVIEW_MAX_OVERSAMPLE); +} + +function prepareCanvas(canvas, oversample = 1) { + const width = Math.max(1, canvas.clientWidth || 1); const height = Math.max(1, canvas.clientHeight || 1); const dpr = (window.devicePixelRatio || 1) * oversample; const pixelWidth = Math.round(width * dpr); const pixelHeight = Math.round(height * dpr); if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) { canvas.width = pixelWidth; canvas.height = pixelHeight; } const context = canvas.getContext("2d"); context.setTransform(dpr, 0, 0, dpr, 0, 0); context.clearRect(0, 0, width, height); return { context, width, height }; } function draw(state) { + state.syncQuickRow?.(); + state.syncAspectChips?.(); + state.syncEditorControls?.(); + state.syncStitchControls?.(); for (const canvas of [state.canvas, state.previewCanvas]) { if (!canvas) continue; const compact = canvas === state.previewCanvas; - const { context, width, height } = prepareCanvas(canvas); + if (compact) state.panelOversample = panelOversample(); + const { context, width, height } = prepareCanvas(canvas, compact ? state.panelOversample : 1); if (!state.image || !state.sourceWidth || !state.sourceHeight) { if (compact) state.panelRender = null; else state.render = null; drawEmptyCanvas(context, width, height, "Choose a source to begin"); continue; @@ -961,6 +1399,7 @@ function drawScene(context, state, render, compact, interactive) { drawSourceImage(context, state, render.scale); context.restore(); context.save(); context.globalCompositeOperation = "source-over"; context.fillStyle = "rgba(8,10,12,.62)"; const full = { x: 0, y: 0, width: context.canvas.width, height: context.canvas.height }; context.beginPath(); context.rect(full.x, full.y, full.width, full.height); context.rect(cropRect.x, cropRect.y, cropRect.width, cropRect.height); context.fill("evenodd"); context.restore(); + if (!compact && state.isClip && state.showBlend) drawBlendOverlay(context, state, render); context.strokeStyle = "#4bd8ef"; context.lineWidth = compact ? 1 : 2; context.setLineDash([7, 5]); context.strokeRect(cropRect.x, cropRect.y, cropRect.width, cropRect.height); context.strokeStyle = "#ff9d42"; context.setLineDash([5, 5]); context.strokeRect(outputRect.x, outputRect.y, outputRect.width, outputRect.height); context.setLineDash([]); if (interactive) { @@ -979,7 +1418,7 @@ function drawScene(context, state, render, compact, interactive) { // budget appends its target so the readout names what the run will emit. function drawOutputSize(context, state, outputRect, padding) { let text = `${padding.outputWidth} x ${padding.outputHeight}`; - if (state.kind === "image" && value(state.node, "resize_to_megapixels", false)) { + if (value(state.node, "resize_to_megapixels", false)) { const target = scaleToMegapixels( padding.outputWidth, padding.outputHeight, value(state.node, "megapixels", 1), value(state.node, "resolution_steps", 1), @@ -1222,15 +1661,16 @@ function updateModalInfo(state) { if (!state.modal || !state.sourceWidth) return; const status = state.modal.querySelector("[data-ausboss-status]"); if (!status) return; const source = rotatedSize(state.sourceWidth, state.sourceHeight, value(state.node, "rotation_degrees", 0)); const crop = resolveCrop(values(state.node), source); const pad = resolvePadding(values(state.node), crop); const frame = state.kind === "video" ? `\nFrame ${value(state.node, "frame_index", 0)} at ${Number(value(state.node, "frame_time", 0)).toFixed(3)}s` : ""; + const stitch = state.isClip && widget(state.node, "stitch_blend") ? `\nStitch blend ${value(state.node, "stitch_blend", 32)} px${Number(value(state.node, "stitch_grow", 0)) ? `, grow ${value(state.node, "stitch_grow", 0)} px` : ""}` : ""; let resized = ""; - if (state.kind === "image" && value(state.node, "resize_to_megapixels", false)) { + if (value(state.node, "resize_to_megapixels", false)) { const target = scaleToMegapixels( pad.outputWidth, pad.outputHeight, value(state.node, "megapixels", 1), value(state.node, "resolution_steps", 1), ); resized = `\nResized ${target.width} x ${target.height} (${(target.width * target.height / 1048576).toFixed(2)} MP)`; } - status.textContent = `Source ${state.sourceWidth} x ${state.sourceHeight}\nRotated ${source.width} x ${source.height}\nCrop ${crop.x}, ${crop.y}, ${crop.width} x ${crop.height}\nOutput ${pad.outputWidth} x ${pad.outputHeight}${resized}${frame}`; + status.textContent = `Source ${state.sourceWidth} x ${state.sourceHeight}\nRotated ${source.width} x ${source.height}\nCrop ${crop.x}, ${crop.y}, ${crop.width} x ${crop.height}\nOutput ${pad.outputWidth} x ${pad.outputHeight}${resized}${stitch}${frame}`; } function drawEmpty(state, text) { state.render = null; state.panelRender = null; for (const canvas of [state.canvas, state.previewCanvas]) { if (!canvas) continue; const prepared = prepareCanvas(canvas); drawEmptyCanvas(prepared.context, prepared.width, prepared.height, text); } } function drawEmptyCanvas(context, width, height, text) { context.fillStyle = "#111"; context.fillRect(0, 0, width, height); context.fillStyle = "#9ba2aa"; context.font = "13px system-ui"; context.textAlign = "center"; context.fillText(text, width / 2, height / 2); context.textAlign = "left"; } @@ -1259,9 +1699,10 @@ export function disposeTransformNode(node) { export function registerTransformExtension(nodeClass, kind, mountPanel = null) { app.registerExtension({ - name: `ausboss.transform.${kind}`, + name: `ausboss.transform.${nodeClass}`, beforeRegisterNodeDef(nodeType, nodeData) { if (nodeData.name !== nodeClass) return; + hideInputsInDef(nodeData, HIDDEN_WIDGETS); chainCallback(nodeType.prototype, "onNodeCreated", function () { installTransformNode(this, kind, mountPanel); }); chainCallback(nodeType.prototype, "onRemoved", function () { disposeTransformNode(this); }); }, diff --git a/js/shared/transform_geometry.mjs b/js/shared/transform_geometry.mjs index 551152c..3e64bb7 100644 --- a/js/shared/transform_geometry.mjs +++ b/js/shared/transform_geometry.mjs @@ -102,6 +102,39 @@ export function resolvePadding(values, crop) { }; } +// Fit actions replace previous crop/padding, but retain rotation, fill and +// resize settings. Padding must unlock the INNER crop or the backend would +// trim the source before adding the new outer canvas. +export function fitSourceToAspect(source, aspect, mode = "crop") { + const ratio = parseAspectRatio(aspect, source); + const patch = { + crop_aspect_ratio: mode === "pad" ? "free" : aspect, + crop_x: 0, crop_y: 0, crop_width: source.width, crop_height: source.height, + pad_left: 0, pad_top: 0, pad_right: 0, pad_bottom: 0, + }; + if (!ratio || aspect === "source") return patch; + if (mode === "pad") { + const width = Math.max(source.width, Math.ceil(source.height * ratio)); + const height = Math.max(source.height, Math.ceil(source.width / ratio)); + patch.pad_left = Math.floor((width - source.width) / 2); + patch.pad_right = width - source.width - patch.pad_left; + patch.pad_top = Math.floor((height - source.height) / 2); + patch.pad_bottom = height - source.height - patch.pad_top; + } else { + const crop = resolveCrop(patch, source); + // Leave one dimension open so rounding is applied exactly once when + // the backend resolves this centered crop. + if (source.width / source.height > ratio) { + patch.crop_x = Math.floor((source.width - crop.width) / 2); + patch.crop_width = 0; + } else { + patch.crop_y = Math.floor((source.height - crop.height) / 2); + patch.crop_height = 0; + } + } + return patch; +} + export function canvasLocalPoint(canvas, event) { const bounds = canvas.getBoundingClientRect(); const width = Math.max(1, canvas.clientWidth || bounds.width || 1); diff --git a/js/shared/transform_trim.mjs b/js/shared/transform_trim.mjs new file mode 100644 index 0000000..bb1654e --- /dev/null +++ b/js/shared/transform_trim.mjs @@ -0,0 +1,163 @@ +import { makeScrubInput } from "./scrub_input.mjs"; +import { dragTrimHandle, loadSummary, trimBounds, trimFractions } from "../load_video/trim_preview.mjs"; + +export function mountTransformTrim({ get, set, has = () => true, metadata, onSeek, onCommit }) { + if (!document.getElementById("ausboss-transform-trim-css")) { + const style = document.createElement("style"); + style.id = "ausboss-transform-trim-css"; + style.textContent = ` + .ausboss-transform-trim{flex:none;display:flex;flex-direction:column;gap:9px;min-width:0;padding:10px;border:1px solid #164b49;border-radius:8px;background:#0e1718;color:#cadddb;font:11px system-ui;box-sizing:border-box;overflow:hidden} + .ausboss-transform-trim-rail{position:relative;height:24px;margin:0 6px;touch-action:none;cursor:ew-resize} + .ausboss-transform-trim-rail:before{content:"";position:absolute;left:0;right:0;top:9px;height:6px;border-radius:4px;background:#293637} + .ausboss-transform-trim-span{position:absolute;top:9px;height:6px;background:#00b4aa;border-radius:4px;pointer-events:none} + .ausboss-transform-trim-handle{position:absolute!important;top:0;width:12px!important;height:24px;min-width:0;padding:0!important;margin:0!important;transform:translateX(-50%);border:2px solid #00b4aa!important;border-radius:4px!important;background:#e5fffc!important;cursor:ew-resize;touch-action:none} + .ausboss-transform-trim-handle:focus-visible{outline:2px solid white;outline-offset:2px} + .ausboss-transform-trim-line{display:flex;align-items:center;gap:7px;min-width:0} + .ausboss-transform-trim-line label{display:flex;align-items:center;gap:5px;white-space:nowrap} + .ausboss-transform-trim-line .spacer{flex:1;min-width:0} + .ausboss-transform-trim-line .ausboss-scrub{width:68px} + .ausboss-transform-trim-line select{background:#1b2627;color:#e5fffc;border:1px solid #2c4d4b;border-radius:5px;padding:3px 4px;font:11px system-ui;cursor:pointer} + .ausboss-transform-trim-line select:focus-visible{outline:2px solid #00b4aa;outline-offset:1px} + .ausboss-transform-trim-line .ausboss-scrub-step button{font-size:8px} + .ausboss-transform-trim-caption{color:#00b4aa;font-weight:650;font-size:10px;letter-spacing:.06em} + .ausboss-transform-trim-summary{color:#8ca8a5;white-space:normal;font-variant-numeric:tabular-nums} + .ausboss-transform-trim-reset{border:0;background:transparent;color:#8ca8a5;font:11px system-ui;cursor:pointer;padding:0} + `; + document.head.append(style); + } + const el = (tag, className = "", text = "") => { + const item = document.createElement(tag); item.className = className; item.textContent = text; return item; + }; + const root = el("div", "ausboss-transform-trim"); + const rail = el("div", "ausboss-transform-trim-rail"); + const span = el("div", "ausboss-transform-trim-span"); + const handles = {}; + rail.append(span); + const duration = () => Number(metadata()?.duration) || 0; + const bounds = () => trimBounds(duration(), get("start_seconds", 0), get("end_seconds", 0)); + const frameStep = () => 1 / Math.max(1, metadata()?.fps || 30); + const controls = {}; + const summary = el("div", "ausboss-transform-trim-summary"); + const sync = () => { + const window = bounds(); const fractions = trimFractions(duration(), window); + span.style.left = `${fractions.start * 100}%`; + span.style.width = `${(fractions.end - fractions.start) * 100}%`; + for (const edge of ["start", "end"]) { + const handle = handles[edge]; + handle.style.left = `${fractions[edge] * 100}%`; + handle.disabled = duration() <= 0; + handle.setAttribute("aria-valuemin", "0"); + handle.setAttribute("aria-valuemax", String(duration())); + handle.setAttribute("aria-valuenow", String(Number.isFinite(window[edge]) ? window[edge] : 0)); + handle.setAttribute("aria-valuetext", `${Number.isFinite(window[edge]) ? window[edge].toFixed(3) : "end"} seconds`); + } + controls.start_seconds?.set(get("start_seconds", 0)); + controls.end_seconds?.set(get("end_seconds", 0) || duration()); + controls.every_nth?.set(get("every_nth", 1)); + controls.max_frames?.set(get("max_frames", 0)); + if (controls.frame_snap) controls.frame_snap.value = String(get("frame_snap", "free")); + const estimate = loadSummary(duration(), window, metadata()?.fps, get("every_nth", 1), get("max_frames", 0), false, get("frame_snap", "free")); + summary.textContent = estimate ? `Est. ${estimate} · ${(window.end - window.start).toFixed(2)}s selected` : "Choose a source to set the clip window"; + summary.title = "Estimated from source fps; decoded frame count may differ for variable-rate videos. A frame limit can shorten the selected window. Preview position does not change the output."; + }; + const apply = (edge, seconds, settled = false) => { + const total = duration(); + if (!Number.isFinite(seconds)) seconds = get(`${edge}_seconds`, 0); + if (total <= 0) { + set(`${edge}_seconds`, Math.max(0, seconds)); + } else { + const next = dragTrimHandle(total, bounds(), edge, seconds / total, frameStep()); + set("start_seconds", Number(next.start.toFixed(6))); + set("end_seconds", next.end >= total - 0.000001 ? 0 : Number(next.end.toFixed(6))); + const preview = edge === "start" ? next.start : Math.max(next.start, next.end - frameStep()); + onSeek?.(preview, settled); + } + sync(); + if (settled) onCommit?.(); + }; + for (const edge of ["start", "end"]) { + const handle = el("button", "ausboss-transform-trim-handle"); + handle.type = "button"; handle.setAttribute("role", "slider"); + handle.setAttribute("aria-label", edge === "start" ? "Clip IN" : "Clip OUT"); + handle.title = `Drag ${edge === "start" ? "IN" : "OUT"} to trim. Arrow keys move one second; Shift moves one frame.`; + handle.addEventListener("keydown", (event) => { + if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; + event.preventDefault(); event.stopPropagation(); + const delta = (event.shiftKey ? frameStep() : 1) * (event.key === "ArrowLeft" ? -1 : 1); + apply(edge, event.key === "Home" ? 0 : event.key === "End" ? duration() : bounds()[edge] + delta, true); + }); + handles[edge] = handle; rail.append(handle); + } + let drag = null; + const pointerTime = (event) => { + const box = rail.getBoundingClientRect(); + return Math.max(0, Math.min(1, (event.clientX - box.left) / Math.max(1, box.width))) * duration(); + }; + rail.addEventListener("pointerdown", (event) => { + if (event.button !== 0 || duration() <= 0) return; + event.preventDefault(); event.stopPropagation(); + const seconds = pointerTime(event); const window = bounds(); + drag = event.target === handles.start ? "start" : event.target === handles.end ? "end" + : Math.abs(seconds - window.start) <= Math.abs(seconds - window.end) ? "start" : "end"; + rail.setPointerCapture(event.pointerId); apply(drag, seconds); + }); + rail.addEventListener("pointermove", (event) => { + if (!drag) return; event.preventDefault(); event.stopPropagation(); apply(drag, pointerTime(event)); + }); + const finish = (event) => { + if (!drag) return; + const edge = drag; drag = null; + if (rail.hasPointerCapture(event.pointerId)) rail.releasePointerCapture(event.pointerId); + apply(edge, bounds()[edge], true); + }; + rail.addEventListener("pointerup", finish); + rail.addEventListener("pointercancel", finish); + const timeRow = el("div", "ausboss-transform-trim-line"); + for (const [name, caption, edge] of [["start_seconds", "IN", "start"], ["end_seconds", "OUT", "end"]]) { + const label = el("label"); label.append(el("span", "ausboss-transform-trim-caption", caption)); + controls[name] = makeScrubInput({ value: get(name, 0), min: 0, max: 86400, step: 0.1, fineStep: 0.01, decimals: 3, + title: `${caption} in seconds. OUT = 0 means the end of the source.`, + onChange: (seconds) => apply(edge, seconds === 0 && edge === "end" ? duration() : seconds), + onSettle: () => apply(edge, bounds()[edge], true), + }); + label.append(controls[name].root); timeRow.append(label); + if (edge === "start") timeRow.append(el("span", "spacer")); + } + const sampling = el("div", "ausboss-transform-trim-line"); + for (const [name, caption, min, max, tip] of [ + ["every_nth", "Every nth", 1, 512, "Keep one frame in this many. Output fps is adjusted to keep real-time playback."], + ["max_frames", "Limit", 0, 100000, "Maximum returned frames. 0 keeps the whole selection."], + ]) { + const label = el("label", "", caption); + controls[name] = makeScrubInput({ value: get(name, min), min, max, step: 1, decimals: 0, title: tip, + onChange: (next) => { set(name, next); sync(); }, onSettle: onCommit, + }); + label.append(controls[name].root); sampling.append(label); + if (name === "every_nth") sampling.append(el("span", "spacer")); + } + if (has("frame_snap")) { + // Video models keep 8n+1 (LTX) or 4n+1 (Wan) frames and drop the rest; + // snapping here keeps the clip, its audio, and its stitcher the same + // length as what comes back from the sampler. + const label = el("label", "", "Snap"); + const select = el("select"); + select.title = "Drop trailing frames to a count video models keep: 8n+1 for LTX, 4n+1 for Wan. Free keeps every frame."; + for (const rule of ["free", "8n+1", "4n+1"]) { + const option = el("option", "", rule); option.value = rule; select.append(option); + } + select.addEventListener("change", () => { set("frame_snap", select.value); sync(); onCommit?.(); }); + controls.frame_snap = select; + label.append(select); sampling.append(el("span", "spacer"), label); + } + const footer = el("div", "ausboss-transform-trim-line"); + const reset = el("button", "ausboss-transform-trim-reset", "Full clip"); reset.type = "button"; + reset.title = "Reset IN/OUT to the full source. Keeps frame skipping and the frame limit."; + reset.addEventListener("click", () => { set("start_seconds", 0); set("end_seconds", 0); sync(); onCommit?.(); }); + footer.append(summary, el("span", "spacer"), reset); + root.append(rail, timeRow, sampling, footer); + root.addEventListener("pointerdown", (event) => { + if (event.target.closest("button,input")) event.stopPropagation(); + }); + sync(); + return { root, sync }; +} diff --git a/js/shared/video_source_card.mjs b/js/shared/video_source_card.mjs new file mode 100644 index 0000000..bfc8de4 --- /dev/null +++ b/js/shared/video_source_card.mjs @@ -0,0 +1,45 @@ +export const INPUT_FOLDER_MODE = "input folder"; +export const LOCAL_PATH_MODE = "local path"; + +export function normalizeVideoSourceMode(value) { + return value === LOCAL_PATH_MODE ? LOCAL_PATH_MODE : INPUT_FOLDER_MODE; +} + +export function normalizeVideoOptions(values, selected = "") { + const result = []; + for (const value of Array.isArray(values) ? values : []) { + const text = String(value ?? ""); + if (text && !result.includes(text)) result.push(text); + } + const current = String(selected ?? ""); + if (current && !result.includes(current)) result.push(current); + return result; +} + +export function videoSourceState(mode, video, localPath) { + const normalizedMode = normalizeVideoSourceMode(mode); + const selection = String( + normalizedMode === LOCAL_PATH_MODE ? localPath ?? "" : video ?? "", + ).trim(); + return { + mode: normalizedMode, + selection, + key: selection ? `${normalizedMode === LOCAL_PATH_MODE ? "local" : "input"}:${selection}` : "", + hint: normalizedMode === LOCAL_PATH_MODE + ? "Reads directly from this server without copying the file." + : "Choose an uploaded video or add one to ComfyUI's input folder.", + }; +} + +// Images use the same picker card, but their backend accepts input-folder +// references only. Do not advertise a Local path mode it cannot execute. +export function mediaSourceState(kind, mode, selection, localPath) { + if (kind !== "image") return videoSourceState(mode, selection, localPath); + const image = String(selection ?? ""); + return { + mode: INPUT_FOLDER_MODE, + selection: image, + key: image, + hint: "Choose an uploaded image or drop one onto this node.", + }; +} diff --git a/js/shared/widget_card.mjs b/js/shared/widget_card.mjs new file mode 100644 index 0000000..5147c62 --- /dev/null +++ b/js/shared/widget_card.mjs @@ -0,0 +1,594 @@ +// The widget card: one compact DOM panel that stands in for a node's classic +// canvas widgets - the full-width rounded rows with an arrow at each end. +// +// The widgets stay the single source of truth. Every row mirrors one hidden +// standard widget (or a pair of them): numbers become the pack's scrub +// control, short combos a segmented pill, long combos a select, booleans an +// off | on pill, strings a text field, a multiline string a textarea, and a hex +// string named like a color gets a swatch. Rows can depend on other values +// (`when`) and sit behind a disclosure (`group`), so a node with a dozen +// inputs shows the three that matter and keeps the rest one click away, +// with their values untouched. +// +// Save/load, undo, API format and widget-to-input links all keep working +// through the widgets: the card writes `widget.value`, runs the widget's own +// callback, and re-reads everything on configure, on connection changes and +// after any callback. A row whose widget is driven by a link dims out. +// +// Links still land on rows. The frontend draws a widget's input socket at +// the widget's own `y` and only while it matters (a link is being dragged +// or the socket is connected), so each hidden widget reports the y of the +// row standing in for it: drag a link over the card and a socket appears on +// every row that can take it, drop it on the row and the widget is driven +// by the link, exactly as it was with the classic widget. One socket per +// row, so a row never holds two linkable widgets: a pair either splits into +// rows or declares `top: true`, which moves its widgets' sockets up among +// the node's real inputs (the widget stays; its slot just stops being a +// "widget input" and is laid out with the others), where a link greys the +// field out like the classic converted input did. +// +// Pure decisions (which rows show, how tall the card is, how a number +// scrubs, where a socket sits) live in widget_card_math.mjs for node:test; +// this file is the DOM. +import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "./index.mjs"; +import { ensureNodeMinHeight, fillNodeHeight } from "./panel_layout.mjs"; +import { makeScrubInput } from "./scrub_input.mjs"; +import { hideWidget } from "./widget_visibility.mjs"; +import { + CARD_PADDING, GROUP_HEIGHT, ROW_GAP, ROW_HEIGHT, SECTION_HEIGHT, SLOT_OFFSET, UNIT_SLOT_WIDTH, + cardHeight, comboValues, rowHeight, rowKind, rowTops, scrubSteps, socketWidgetY, visibleRows, +} from "./widget_card_math.mjs"; + +export * from "./widget_card_math.mjs"; + +const CSS_ID = "ausboss-widget-card-css"; +export const CARD_WIDGET = "ausboss_widget_card"; +// The frontend's DOM-widget wrapper eats this much of the declared height +// in insets, so the widget declares content plus this and the card's rows +// get exactly the room they were measured for. +const WRAPPER_INSET = 16; +// The wrapper's top inset: where the card's element starts below the +// widget's layout y (DomWidgets: margin 10 per side). +const WRAPPER_TOP = 10; + +const FONT = `-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif`; +// A boolean pill's state labels must fit half a control; longer text is a +// tooltip, and the pill says on / off. +const SWITCH_LABEL_MAX = 16; + +export function ensureCardCss() { + if (document.getElementById(CSS_ID)) return; + const style = document.createElement("style"); + style.id = CSS_ID; + style.textContent = ` +.ausboss-card{box-sizing:border-box;width:100%;height:100%;display:flex;flex-direction:column;gap:${ROW_GAP}px;padding:${CARD_PADDING}px 8px;overflow:hidden;border:1px solid rgba(0,180,170,.22);border-radius:8px;background:rgba(0,0,0,.28);font:12px/1.3 ${FONT};color:#c8dddd;pointer-events:none} +.ausboss-card-row{display:grid;grid-template-columns:minmax(56px,30%) 1fr;align-items:center;gap:8px;height:${ROW_HEIGHT}px;flex:none;pointer-events:auto} +.ausboss-card-row.hidden{display:none} +.ausboss-card-row.linked{opacity:.5} +.ausboss-card-row.linked .ausboss-card-control{pointer-events:none} +.ausboss-card-row.area{display:block} +.ausboss-card-row.area.grow{flex:1 1 auto;height:auto} +.ausboss-card-label{overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:#8ba3a1;font-size:11px;user-select:none} +.ausboss-card-control{display:flex;align-items:center;gap:6px;min-width:0;height:100%} +.ausboss-card-field{display:flex;align-items:center;gap:6px;flex:1 1 0;min-width:0;height:100%} +.ausboss-card-field.linked{opacity:.5;pointer-events:none} +.ausboss-card-field>.ausboss-scrub{flex:1 1 0;width:auto;min-width:0} +.ausboss-card-field>.ausboss-scrub .ausboss-scrub-input{width:100%} +.ausboss-card-sep{flex:none;color:#5f7674;font-size:11px} +.ausboss-card-suffix{flex:none;color:#5f7674;font-size:10px;letter-spacing:.06em;text-transform:uppercase;user-select:none} +.ausboss-card-section{height:${SECTION_HEIGHT}px;flex:none;display:flex;align-items:flex-end;color:#5f7674;font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;user-select:none} +.ausboss-card-section.hidden{display:none} +.ausboss-card-group{height:${GROUP_HEIGHT}px;flex:none;display:flex;align-items:center;gap:6px;padding:0 4px;border:1px solid transparent;border-radius:6px;background:transparent;color:#78908e;font:600 10.5px/1 ${FONT};letter-spacing:.06em;text-transform:uppercase;cursor:pointer;text-align:left;pointer-events:auto} +.ausboss-card-group:hover{color:#fff;border-color:#2a3437} +.ausboss-card-group .glyph{display:inline-block;width:10px;transition:transform .12s} +.ausboss-card-group.open .glyph{transform:rotate(90deg)} +.ausboss-card-seg{display:grid;grid-auto-flow:column;grid-auto-columns:1fr;width:100%;height:${ROW_HEIGHT}px;padding:2px;box-sizing:border-box;border:1px solid #2a3437;border-radius:6px;background:#0f1516} +.ausboss-card-seg button{min-width:0;border:none;border-radius:4px;padding:0 2px;background:transparent;color:#8ba3a1;font:600 10.5px/1 ${FONT};cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.ausboss-card-seg button:hover{color:#fff} +.ausboss-card-seg button.on{background:${BRAND};color:#04201d} +.ausboss-card-select{width:100%;height:${ROW_HEIGHT}px;padding:0 24px 0 8px;box-sizing:border-box;border:1px solid #2a3437;border-radius:6px;background:#0b0f10 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%238ba3a1' stroke-width='1.5'/%3E%3C/svg%3E") no-repeat right 8px center;color:#d8ecea;font:600 11.5px ${FONT};appearance:none;-webkit-appearance:none;cursor:pointer;overflow:hidden;text-overflow:ellipsis} +.ausboss-card-select:hover{border-color:${BRAND}} +.ausboss-card-select:focus-visible{outline:2px solid ${BRAND};outline-offset:1px} +.ausboss-card-text{width:100%;height:${ROW_HEIGHT}px;padding:0 8px;box-sizing:border-box;border:1px solid #2a3437;border-radius:6px;background:#0b0f10;color:#d8ecea;font:12px ${FONT};min-width:0} +.ausboss-card-text:focus{outline:none;border-color:${BRAND}} +.ausboss-card-text::placeholder{color:#4f6462} +.ausboss-card-area{display:block;width:100%;height:100%;padding:5px 8px;box-sizing:border-box;border:1px solid #2a3437;border-radius:6px;background:#0b0f10;color:#d8ecea;font:12px/1.4 ${FONT};resize:none;overflow:auto;min-width:0} +.ausboss-card-area:focus{outline:none;border-color:${BRAND}} +.ausboss-card-area::placeholder{color:#4f6462} +.ausboss-card-swatch{flex:none;width:${ROW_HEIGHT}px;height:${ROW_HEIGHT}px;padding:0;border:1px solid #2a3437;border-radius:6px;background:#0b0f10;cursor:pointer} +.ausboss-card-swatch::-webkit-color-swatch-wrapper{padding:3px} +.ausboss-card-swatch::-webkit-color-swatch{border:none;border-radius:3px} +.ausboss-card-btn{flex:none;height:${ROW_HEIGHT}px;padding:0 10px;border:1px solid #2a3437;border-radius:6px;background:#16201f;color:#d8ecea;font:600 11px/1 ${FONT};cursor:pointer;white-space:nowrap} +.ausboss-card-btn:hover{border-color:${BRAND};color:#fff} +`; + document.head.append(style); +} + +// ---------- DOM ---------- + +function el(tag, className = "", text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +function findWidget(node, name) { + return node.widgets?.find((widget) => widget.name === name) ?? null; +} + +function widgetSlot(node, name) { + return node.inputs?.find((input) => input?.widget?.name === name || input?.name === name) ?? null; +} + +// A `top: true` row's widgets keep their input slots but the slots stop +// being widget inputs: the frontend then measures and draws them in the +// node's slot column, and a link into one is resolved by input name. +function liftSocket(node, name) { + const slot = node.inputs?.find((input) => input?.widget?.name === name); + if (!slot) return false; + delete slot.widget; + slot._widget = undefined; + return true; +} + +function widgetLinked(node, name) { + const slot = widgetSlot(node, name); + return Boolean(slot && slot.link !== null && slot.link !== undefined); +} + +function isFloatWidget(widget) { + const precision = widget?.options?.precision; + if (precision !== undefined) return Number(precision) > 0; + const step = widget?.options?.step2 ?? widget?.options?.round; + return step !== undefined ? !Number.isInteger(Number(step)) : !Number.isInteger(Number(widget?.value)); +} + +// Mount a card on `node`. Rows: +// { widget, label?, kind?, labels?, suffix?, title?, when?, group? } +// { widget, kind: "textarea", placeholder?, height, grow? } - a multiline +// string; `grow` takes the node's +// spare height +// { pair: [name, name, ...], label, sep?, when?, group?, suffix?, top? } +// - `top` lifts the widgets' sockets +// into the node's slot column +// { section: "Caption" } +// { group: "advanced", label: "More" } - a disclosure; later rows with +// group: "advanced" sit under it +// Options: minWidth (node floor), first (place the card ahead of the node's +// other DOM widgets), hide (extra widgets to hide). +export function mountWidgetCard(node, { rows, minWidth = 300, first = false, hide = [] } = {}) { + ensureCardCss(); + const root = el("div", "ausboss-card"); + const managed = []; + const state = { node, root, rows: [], groups: {}, height: 0, widget: null, rowTop: new Map(), rowSpan: new Map(), socketed: [] }; + node.__ausbossCard = state; + + const values = () => Object.fromEntries((node.widgets ?? []).map((widget) => [widget.name, widget.value])); + const groupProperty = (name) => `ausboss_show_${name}`; + const groupOpen = (name) => Boolean(node.properties?.[groupProperty(name)]); + const setGroupOpen = (name, open) => { + node.properties ??= {}; + node.properties[groupProperty(name)] = Boolean(open); + refresh(); + }; + + const setWidget = (name, value, { settle = true } = {}) => { + const widget = findWidget(node, name); + if (!widget || widget.value === value) return; + widget.value = value; + widget.callback?.(value, globalThis.app?.canvas, node); + node.graph?.setDirtyCanvas?.(true, true); + if (settle) notifyAusbossChange(); + }; + + // Every row keeps a `sync(values, linked)` that writes the widget's value + // into its control, so a refresh is one loop. + const buildScrub = (name, row, into) => { + const widget = findWidget(node, name); + const isFloat = isFloatWidget(widget); + const steps = scrubSteps(widget?.options, isFloat); + const control = makeScrubInput({ + value: Number(widget?.value) || 0, + min: Number(widget?.options?.min ?? -Infinity), + max: Number(widget?.options?.max ?? Infinity), + step: steps.step, + fineStep: steps.fineStep, + decimals: row.decimals ?? steps.decimals, + title: row.title ?? widget?.options?.tooltip ?? "", + unit: row.suffix ?? "", + // Single fields share one unit slot so their numbers line up down + // the card; the fields of a pair sit side by side and keep the room. + unitWidth: row.pair ? 0 : UNIT_SLOT_WIDTH, + onChange: (next) => setWidget(name, isFloat ? next : Math.round(next), { settle: false }), + onSettle: () => notifyAusbossChange(), + }); + into.append(control.root); + return (vals) => control.set(Number(vals[name]) || 0); + }; + + const buildSegment = (name, row, into) => { + const widget = findWidget(node, name); + const seg = el("div", "ausboss-card-seg"); + const buttons = new Map(); + for (const value of comboValues(widget)) { + const button = el("button", "", row.labels?.[value] ?? String(value)); + button.type = "button"; button.title = row.titles?.[value] ?? String(value); + button.addEventListener("click", () => setWidget(name, value)); + seg.append(button); buttons.set(value, button); + } + into.append(seg); + return (vals) => { for (const [value, button] of buttons) button.classList.toggle("on", vals[name] === value); }; + }; + + const buildSelect = (name, row, into) => { + const widget = findWidget(node, name); + const select = el("select", "ausboss-card-select"); + select.title = row.title ?? widget?.options?.tooltip ?? ""; + const fill = (current) => { + select.textContent = ""; + const options = comboValues(widget); + const list = options.includes(current) || current === undefined || current === "" ? options : [...options, current]; + for (const value of list) { + const option = el("option", "", row.labels?.[value] ?? String(value)); option.value = String(value); select.append(option); + } + select.value = String(current ?? ""); + }; + select.addEventListener("change", () => setWidget(name, select.value)); + into.append(select); + return (vals) => fill(vals[name]); + }; + + // A boolean is a two-segment pill, off on the left and on on the right, + // as wide as any other control on the card; the short `offText` / + // `onText` name the states ("off | embed workflow"), longer descriptions + // ride on the tooltip. + const buildSwitch = (name, row, into) => { + const widget = findWidget(node, name); + const seg = el("div", "ausboss-card-seg ausboss-card-bool"); + seg.setAttribute("role", "radiogroup"); + const label = (text, fallback) => (typeof text === "string" && text.length <= SWITCH_LABEL_MAX ? text : fallback); + const buttons = new Map(); + for (const [value, text] of [[false, label(row.offText, "off")], [true, label(row.onText, "on")]]) { + const button = el("button", "", text); + button.type = "button"; + button.title = row.title ?? widget?.options?.tooltip ?? (value ? row.onText ?? "" : row.offText ?? ""); + button.addEventListener("click", () => setWidget(name, value)); + seg.append(button); buttons.set(value, button); + } + into.append(seg); + return (vals) => { + const on = Boolean(vals[name]); + for (const [value, button] of buttons) { + button.classList.toggle("on", value === on); + button.setAttribute("aria-checked", String(value === on)); + } + }; + }; + + const buildText = (name, row, into) => { + const widget = findWidget(node, name); + const input = el("input", "ausboss-card-text"); + input.type = "text"; input.placeholder = row.placeholder ?? ""; input.title = row.title ?? widget?.options?.tooltip ?? ""; + input.spellcheck = false; + const commit = () => { if (input.value !== String(findWidget(node, name)?.value ?? "")) setWidget(name, input.value); }; + input.addEventListener("change", commit); + input.addEventListener("blur", commit); + input.addEventListener("keydown", (event) => { + event.stopPropagation(); + if (event.key === "Enter") input.blur(); + if (event.key === "Escape") { input.value = String(findWidget(node, name)?.value ?? ""); input.blur(); } + }); + into.append(input); + return (vals) => { if (document.activeElement !== input) input.value = String(vals[name] ?? ""); }; + }; + + // A multiline string. The frontend's own textarea for the widget stays + // mounted (hidden) underneath, so dynamic prompts, serialization and the + // widget's input socket are all still its business; this box only edits + // the same value. + const buildTextarea = (name, row, into) => { + const widget = findWidget(node, name); + if (widget?.element) widget.element.style.display = "none"; + const area = el("textarea", "ausboss-card-area"); + area.placeholder = row.placeholder ?? row.label ?? name; + area.title = row.title ?? widget?.options?.tooltip ?? ""; + area.spellcheck = false; + let typing = false; + area.addEventListener("input", () => { typing = true; setWidget(name, area.value, { settle: false }); typing = false; }); + area.addEventListener("change", () => notifyAusbossChange()); + area.addEventListener("keydown", (event) => { + event.stopPropagation(); + if (event.key === "Escape") area.blur(); + }); + // The box scrolls its own overflow; the canvas only zooms once it is + // at an end. + area.addEventListener("wheel", (event) => { + const down = event.deltaY > 0; + const atEnd = down ? area.scrollTop + area.clientHeight >= area.scrollHeight - 1 : area.scrollTop <= 0; + if (!atEnd) event.stopPropagation(); + }, { passive: true }); + into.append(area); + return (vals) => { if (!typing && document.activeElement !== area) area.value = String(vals[name] ?? ""); }; + }; + + const buildColor = (name, row, into) => { + const widget = findWidget(node, name); + const swatch = el("input", "ausboss-card-swatch"); swatch.type = "color"; + swatch.title = "Pick a color"; + const input = el("input", "ausboss-card-text"); input.type = "text"; input.spellcheck = false; + input.title = row.title ?? widget?.options?.tooltip ?? ""; + const commit = () => { if (input.value !== String(findWidget(node, name)?.value ?? "")) setWidget(name, input.value.trim()); }; + swatch.addEventListener("input", () => { input.value = swatch.value; setWidget(name, swatch.value, { settle: false }); }); + swatch.addEventListener("change", () => notifyAusbossChange()); + input.addEventListener("change", commit); input.addEventListener("blur", commit); + input.addEventListener("keydown", (event) => { event.stopPropagation(); if (event.key === "Enter") input.blur(); }); + into.append(swatch, input); + return (vals) => { + const text = String(vals[name] ?? ""); + if (document.activeElement !== input) input.value = text; + if (/^#[0-9a-f]{6}$/i.test(text)) swatch.value = text.toLowerCase(); + }; + }; + + const buildButton = (row, into) => { + const button = el("button", "ausboss-card-btn", row.text ?? "Go"); + button.type = "button"; button.title = row.title ?? ""; + button.addEventListener("click", () => row.onClick?.(node, state)); + into.append(button); + }; + + const builders = { scrub: buildScrub, segment: buildSegment, select: buildSelect, switch: buildSwitch, text: buildText, textarea: buildTextarea, color: buildColor }; + + for (const name of hide) { const widget = findWidget(node, name); if (widget) { hideWidget(widget); managed.push(name); } } + const topNames = new Set(rows.filter((row) => row.top).flatMap((row) => row.pair ?? (row.widget ? [row.widget] : []))); + for (const name of topNames) liftSocket(node, name); + let currentGroup = null; + for (const row of rows) { + if (row.section !== undefined) { + const caption = el("div", "ausboss-card-section", row.section); + state.rows.push({ row: { ...row, inGroup: currentGroup }, element: caption, syncs: [] }); + root.append(caption); continue; + } + if (row.group !== undefined && row.widget === undefined && row.pair === undefined) { + currentGroup = row.group; + const header = el("button", "ausboss-card-group"); + header.type = "button"; header.title = row.title ?? ""; + header.append(el("span", "glyph", "▸"), el("span", "", row.label ?? row.group)); + header.addEventListener("click", () => setGroupOpen(row.group, !groupOpen(row.group))); + state.rows.push({ row, element: header, header: true, syncs: [] }); + root.append(header); continue; + } + const names = row.pair ?? [row.widget]; + for (const name of names) { const widget = findWidget(node, name); if (widget) { hideWidget(widget); managed.push(name); } } + const line = el("div", "ausboss-card-row"); + const control = el("div", "ausboss-card-control"); + const syncs = []; + const isArea = row.kind === "textarea"; + if (isArea) { + line.classList.add("area"); + if (row.grow) { line.classList.add("grow"); line.style.minHeight = `${rowHeight(row)}px`; } + else line.style.height = `${rowHeight(row)}px`; + } else { + const label = el("span", "ausboss-card-label", row.label ?? names[0]); + label.title = row.title ?? findWidget(node, names[0])?.options?.tooltip ?? ""; + line.append(label); + } + const fields = new Map(); + names.forEach((name, index) => { + if (index > 0) control.append(el("span", "ausboss-card-sep", row.sep ?? "·")); + const widget = findWidget(node, name); + const kind = rowKind(row, widget); + if (kind === "skip" || !widget) return; + const field = el("div", "ausboss-card-field"); + syncs.push(builders[kind](name, row, field)); + control.append(field); + fields.set(name, field); + }); + if (row.button) buildButton(row.button, control); + line.append(control); + state.rows.push({ row: { ...row, inGroup: row.group ?? null }, element: line, names, syncs, fields }); + root.append(line); + } + + // Clicks on controls stay out of the graph's drag; empty card space falls through. + root.addEventListener("pointerdown", (event) => { + if (event.target.closest("button,input,select,textarea,.ausboss-scrub")) event.stopPropagation(); + }); + + const grows = rows.some((row) => row.grow); + const widget = node.addDOMWidget(CARD_WIDGET, CARD_WIDGET, root, { + serialize: false, + hideOnZoom: false, + getMinHeight: () => state.height + WRAPPER_INSET, + }); + keepDomWidgetWidthAuto(widget); + state.widget = widget; + if (grows) { + // A card with a growing textarea is a viewport: it declares a floor and + // takes the node's spare height, the textarea flexing to fill it. + fillNodeHeight(widget, { + minWidth, + minHeight: () => state.height + WRAPPER_INSET, + minNodeSize: [minWidth, 60], + }); + } else { + // A constant-height card, pinned on purpose (tests/panel_guards.test.mjs, + // fixedByDesign): its height is the sum of its rows, not a viewport. + widget.computeSize = (width) => [Math.max(minWidth, Number(width || node.size?.[0] || minWidth)), state.height + WRAPPER_INSET]; + widget.computeLayoutSize = () => ({ minWidth, minHeight: state.height + WRAPPER_INSET }); + widget.options.minNodeSize = [minWidth, 60]; + } + if (first && Array.isArray(node.widgets)) { + const index = node.widgets.indexOf(widget); + const firstDom = node.widgets.findIndex((item) => item !== widget && item.element); + if (index >= 0 && firstDom >= 0 && firstDom < index) { + node.widgets.splice(index, 1); node.widgets.splice(firstDom, 0, widget); + } + } + + // ---------- sockets ---------- + // Each hidden widget with an input slot reports the y of its row, so the + // frontend draws (and hit-tests) its socket there. A row folded away + // hands its socket to its group header, or to the card's top edge. + const socketY = (name) => { + if (state.disposed) return null; + const target = findWidget(node, name); + if (!target?.hidden) return null; + const cardY = Number(state.widget?.y); + if (!Number.isFinite(cardY)) return null; + const top = state.rowTop.get(name); + const span = state.rowSpan.get(name); + if (top === undefined) return cardY + WRAPPER_TOP - SLOT_OFFSET; + return socketWidgetY(cardY, WRAPPER_TOP, top, span ?? ROW_HEIGHT); + }; + for (const name of new Set(managed)) { + if (topNames.has(name)) continue; + const target = findWidget(node, name); + if (!target || !widgetSlot(node, name)) continue; + const own = Object.getOwnPropertyDescriptor(target, "y"); + if (own?.get) continue; + let stored = target.y; + try { + Object.defineProperty(target, "y", { + configurable: true, enumerable: true, + get: () => socketY(name) ?? stored, + set: (value) => { stored = value; }, + }); + state.socketed.push({ widget: target, restore: () => { delete target.y; target.y = stored; } }); + } catch { /* sealed widget: its socket stays wherever the frontend leaves it */ } + } + + // While a link is being dragged, the row under the pointer stands in for + // its widget, so a drop anywhere on the row lands on that widget's input. + // Outside a drag the card is one widget to the canvas, as before. + const rowWidgetAt = (canvasX, canvasY, { lifted = false } = {}) => { + const cardY = Number(state.widget?.y); + if (!Number.isFinite(cardY)) return null; + const localY = canvasY - node.pos[1] - cardY - WRAPPER_TOP; + for (const entry of state.rows) { + if (!entry.names?.length || Boolean(entry.row.top) !== lifted) continue; + const top = state.rowTop.get(entry.names[0]); + if (top === undefined) continue; + const span = state.rowSpan.get(entry.names[0]) ?? ROW_HEIGHT; + if (localY < top || localY > top + span) continue; + if (entry.names.length === 1) return entry.names[0]; + // A pair: the field under the pointer, by its on-screen box. + const canvas = globalThis.app?.canvas; + const rect = canvas?.canvas?.getBoundingClientRect?.(); + const scale = canvas?.ds?.scale || 1; + const offset = canvas?.ds?.offset || [0, 0]; + const clientX = rect ? rect.left + (canvasX + offset[0]) * scale : null; + const boxes = [...entry.element.querySelectorAll(".ausboss-scrub, .ausboss-card-text, .ausboss-card-select")]; + const hit = clientX === null ? -1 : boxes.findIndex((box) => { const r = box.getBoundingClientRect(); return clientX >= r.left && clientX <= r.right; }); + return entry.names[Math.max(0, Math.min(entry.names.length - 1, hit))]; + } + return null; + }; + const originalGetWidgetOnPos = node.getWidgetOnPos; + if (typeof originalGetWidgetOnPos === "function") { + node.getWidgetOnPos = function (canvasX, canvasY, includeDisabled) { + const found = originalGetWidgetOnPos.call(this, canvasX, canvasY, includeDisabled); + if (state.disposed || found !== state.widget) return found; + if (!globalThis.app?.canvas?.linkConnector?.isConnecting) return found; + const name = rowWidgetAt(canvasX, canvasY); + return (name && findWidget(this, name)) || found; + }; + } + // The fields of a pair share one socket on their row's left edge. A drop + // on that socket goes to the first field of the pair still free, so a + // second link does not silently replace the first; to aim at one field, + // drop on the field itself. + // A `top` row's fields have their sockets up in the slot column, but a + // drop on the field itself still means that field. + const originalGetInputOnPos = node.getInputOnPos; + if (typeof originalGetInputOnPos === "function") { + node.getInputOnPos = function (pos) { + const input = originalGetInputOnPos.call(this, pos); + if (state.disposed) return input; + if (!input && globalThis.app?.canvas?.linkConnector?.isConnecting) { + const topName = rowWidgetAt(pos[0], pos[1], { lifted: true }); + if (topName) return widgetSlot(this, topName) ?? input; + } + const name = input?.widget?.name; + if (!name || input.link === null || input.link === undefined) return input; + const entry = state.rows.find((item) => item.names?.length > 1 && !item.row.top && item.names.includes(name)); + if (!entry) return input; + const free = entry.names.find((other) => { const slot = widgetSlot(this, other); return slot && (slot.link === null || slot.link === undefined); }); + return free ? widgetSlot(this, free) : input; + }; + } + + function refresh() { + if (state.disposed) return; + const vals = values(); + const groups = {}; + for (const entry of state.rows) if (entry.header) groups[entry.row.group] = groupOpen(entry.row.group); + const visible = visibleRows(state.rows.map((entry) => entry.row), vals, groups); + const visibleSet = new Set(visible); + const tops = rowTops(visible); + state.rowTop = new Map(); + state.rowSpan = new Map(); + visible.forEach((row, index) => { + const entry = state.rows.find((item) => item.row === row); + for (const name of entry?.names ?? []) { state.rowTop.set(name, tops[index]); state.rowSpan.set(name, rowHeight(row)); } + if (entry?.header) { state.rowTop.set(`group:${row.group}`, tops[index]); } + }); + // A row folded under a closed group: its socket rides the header. + for (const entry of state.rows) { + if (!entry.names || visibleSet.has(entry.row) || !entry.row.inGroup) continue; + const headerTop = state.rowTop.get(`group:${entry.row.inGroup}`); + if (headerTop === undefined) continue; + for (const name of entry.names) { state.rowTop.set(name, headerTop); state.rowSpan.set(name, GROUP_HEIGHT); } + } + for (const entry of state.rows) { + const show = visibleSet.has(entry.row); + entry.element.classList.toggle("hidden", !show); + if (entry.header) entry.element.classList.toggle("open", groups[entry.row.group]); + if (!show) continue; + // A single field dims with its row; in a pair only the linked field + // dims, the others stay editable. + const linkedNames = (entry.names ?? []).filter((name) => widgetLinked(node, name)); + const linked = linkedNames.length > 0 && linkedNames.length === (entry.names ?? []).length; + entry.element.classList.toggle("linked", linked); + for (const [name, field] of entry.fields ?? []) field.classList.toggle("linked", linkedNames.includes(name)); + for (const sync of entry.syncs) sync(vals, linked); + } + const height = cardHeight(visible); + if (height !== state.height) { + state.height = height; + const width = Math.max(minWidth, node.size?.[0] || minWidth); + node.setSize?.([width, node.computeSize?.()[1] || height]); + node.graph?.setDirtyCanvas?.(true, true); + } + node._widgetSlotsDirty = true; + } + + for (const name of new Set(managed)) { + const target = findWidget(node, name); + if (target) chainCallback(target, "callback", () => refresh()); + } + chainCallback(node, "onConfigure", () => queueMicrotask(() => { + for (const name of managed) { + const target = findWidget(node, name); + hideWidget(target); + if (target?.element && state.rows.some((entry) => entry.row.kind === "textarea" && entry.names?.includes(name))) target.element.style.display = "none"; + } + for (const name of topNames) liftSocket(node, name); + refresh(); + // A workflow saved before this card existed sized the node for the + // classic widgets; give the card and any panel below it their floors. + ensureNodeMinHeight(node); + })); + chainCallback(node, "onConnectionsChange", () => queueMicrotask(refresh)); + chainCallback(node, "onRemoved", () => { + state.disposed = true; + for (const entry of state.socketed) entry.restore(); + if (typeof originalGetWidgetOnPos === "function") delete node.getWidgetOnPos; + if (typeof originalGetInputOnPos === "function") delete node.getInputOnPos; + }); + + refresh(); + state.refresh = refresh; state.setGroupOpen = setGroupOpen; state.groupOpen = groupOpen; + return state; +} diff --git a/js/shared/widget_card_math.mjs b/js/shared/widget_card_math.mjs new file mode 100644 index 0000000..0619096 --- /dev/null +++ b/js/shared/widget_card_math.mjs @@ -0,0 +1,106 @@ +// Pure decisions behind js/shared/widget_card.mjs: which rows a card shows, +// how tall it is, and how a number scrubs. No DOM, no app import, so +// tests/widget_card.test.mjs runs it under node:test. +export const ROW_HEIGHT = 26; +export const ROW_GAP = 5; +export const SECTION_HEIGHT = 17; +export const GROUP_HEIGHT = 22; +export const CARD_PADDING = 7; +// Every scrub field in a card reserves this much for its unit ("px", "MP", +// "×") whether it has one or not, so the numbers share one centre line. +export const UNIT_SLOT_WIDTH = 22; +// A widget input socket is drawn at the widget's y plus half a slot. +export const SLOT_OFFSET = 10; +// A segmented pill only reads when every label fits its slot. +const SEGMENT_MAX_OPTIONS = 4; +const SEGMENT_MAX_CHARS = 26; + +// Which rows the current values leave visible. `groups` maps a group name to +// its open state; a row inside a closed group is out, a row whose `when` +// says no is out, a group header stays as long as any row under it exists. +export function visibleRows(rows, values, groups = {}) { + return rows.filter((row) => { + if (row.group !== undefined && row.widget === undefined && row.pair === undefined && row.section === undefined) { + return true; // the disclosure header itself + } + if (row.when && row.when(values) === false) return false; + if (row.inGroup && !groups[row.inGroup]) return false; + return true; + }); +} + +// One row's height: a caption, a group header, a control row, or the +// height a textarea row asks for. +export function rowHeight(row) { + if (row.section !== undefined) return SECTION_HEIGHT; + if (row.widget === undefined && row.pair === undefined) return GROUP_HEIGHT; + return Number(row.height) > 0 ? Number(row.height) : ROW_HEIGHT; +} + +// Card height for a list of visible rows: rows, section captions and group +// headers each add their own height plus the gap between them. +export function cardHeight(rows) { + if (!rows.length) return CARD_PADDING * 2; + return CARD_PADDING * 2 + rows.reduce((sum, row) => sum + rowHeight(row), 0) + ROW_GAP * (rows.length - 1); +} + +// Where each visible row starts, measured from the card's top edge. +export function rowTops(rows) { + let y = CARD_PADDING; + return rows.map((row) => { + const top = y; + y += rowHeight(row) + ROW_GAP; + return top; + }); +} + +// The y a hidden widget reports so its input socket sits level with the +// card row standing in for it: the socket is drawn SLOT_OFFSET below the +// widget's y, and it belongs on the row's centre line - or, for a tall +// textarea row, just under its top edge, where the frontend puts it too. +export function socketWidgetY(cardY, insetTop, rowTop, height) { + return cardY + insetTop + rowTop + Math.min(height / 2, 13) - SLOT_OFFSET; +} + +// A combo is a segmented pill when the labels are few and short enough to +// sit side by side; otherwise it is a select. +export function segmentFits(labels) { + if (!Array.isArray(labels) || labels.length < 2 || labels.length > SEGMENT_MAX_OPTIONS) return false; + return labels.reduce((sum, label) => sum + String(label).length, 0) <= SEGMENT_MAX_CHARS; +} + +// Scrub steps from a widget's options: the fine step is the widget's own +// increment; the coarse step covers ground - a 0..1 float moves by 0.05, a +// wide float by ten increments, a wide integer by 8. +export function scrubSteps(options = {}, isFloat = true) { + const fine = Number(options.step2 ?? (options.step ? options.step / 10 : null) ?? (isFloat ? 0.01 : 1)) || (isFloat ? 0.01 : 1); + const range = Number(options.max ?? Infinity) - Number(options.min ?? -Infinity); + let step; + if (!isFloat) step = Number.isFinite(range) && range > 2048 ? 8 : Math.max(1, fine); + else if (Number.isFinite(range) && range <= 2) step = Math.max(fine, 0.05); + else step = fine * 10; + const decimals = isFloat ? Number(options.precision ?? Math.max(0, -Math.floor(Math.log10(fine)))) : 0; + return { step, fineStep: fine, decimals }; +} + +// Row kind from the widget the row mirrors, unless the config names one. +export function rowKind(row, widget) { + if (row.kind) return row.kind; + const type = String(widget?.type ?? ""); + if (type === "number" || type === "slider") return "scrub"; + if (type === "combo") return segmentFits(comboLabels(row, widget)) ? "segment" : "select"; + if (type === "toggle") return "switch"; + if (type === "customtext") return "skip"; + if (/color/i.test(String(row.widget ?? "")) && /^#[0-9a-f]{3,8}$/i.test(String(widget?.value ?? ""))) return "color"; + return "text"; +} + +export function comboValues(widget) { + let values = widget?.options?.values; + if (typeof values === "function") values = values(widget); + return Array.isArray(values) ? values : []; +} + +function comboLabels(row, widget) { + return comboValues(widget).map((value) => row.labels?.[value] ?? String(value)); +} diff --git a/js/shared/widget_visibility.mjs b/js/shared/widget_visibility.mjs index eb74485..3a967aa 100644 --- a/js/shared/widget_visibility.mjs +++ b/js/shared/widget_visibility.mjs @@ -2,6 +2,12 @@ // its serialized value) in the widgets array — only its rendering collapses — // so widgets_values order never changes and saved workflows stay compatible. // Pure widget-object surgery, no DOM: testable under node:test. +// +// Two renderers, two switches. The classic canvas lays widgets out by +// computeSize / computeLayoutSize and paints them with draw, so those are +// zeroed. The Nodes 2.0 renderer ignores all of that and filters its widget +// list on `widget.options.hidden` instead, so that flag is set as well - a +// panel's hidden storage widget stays hidden whichever renderer is on. const STASH = "__ausbossVisibilityStash"; @@ -9,14 +15,41 @@ const STASH = "__ausbossVisibilityStash"; // visibility, false when it was already hidden (callers use the return to // skip a needless node resize). export function hideWidget(widget) { - if (!widget || widget[STASH]) return false; + if (!widget) return false; + if (widget[STASH]) { + // Already hidden: re-assert the renderer flag, which the frontend + // drops whenever it rebuilds the options object from the node spec. + if (widget.options) widget.options.hidden = true; + return false; + } + let options = widget.options ?? {}; widget[STASH] = { computeSize: widget.computeSize, computeLayoutSize: widget.computeLayoutSize, draw: widget.draw, hidden: widget.hidden, + optionsHidden: options.hidden, + optionsDescriptor: Object.getOwnPropertyDescriptor(widget, "options"), }; widget.hidden = true; + options.hidden = true; + // The frontend swaps a widget's options object after creation (the + // after-generate control does, and so does a workflow restore), which + // would drop the flag. An accessor keeps re-applying it to whatever + // object is assigned, so the widget stays hidden under Nodes 2.0. + try { + Object.defineProperty(widget, "options", { + configurable: true, + enumerable: true, + get: () => options, + set: (next) => { + options = next ?? {}; + options.hidden = true; + }, + }); + } catch { + widget.options = options; + } widget.computeSize = () => [0, -4]; widget.computeLayoutSize = () => ({ minWidth: 0, minHeight: 0 }); widget.draw = () => {}; @@ -32,6 +65,21 @@ export function showWidget(widget) { widget.computeLayoutSize = stash.computeLayoutSize; widget.draw = stash.draw; widget.hidden = stash.hidden; + const options = widget.options; + try { + delete widget.options; // drop the accessor, then put a plain property back + } catch { + // Not configurable: the accessor stays and keeps the flag; nothing to do. + } + if (stash.optionsDescriptor && "value" in stash.optionsDescriptor) { + Object.defineProperty(widget, "options", { ...stash.optionsDescriptor, value: options }); + } else { + widget.options = options; + } + if (widget.options) { + if (stash.optionsHidden === undefined) delete widget.options.hidden; + else widget.options.hidden = stash.optionsHidden; + } widget[STASH] = undefined; return true; } @@ -40,3 +88,47 @@ export function showWidget(widget) { export function setWidgetVisible(widget, visible) { return visible ? showWidget(widget) : hideWidget(widget); } + +// Mark inputs hidden in the node DEFINITION, before any widget exists. +// The frontend rebuilds a widget's options from this spec at several +// points (workflow restore, the after-generate control, the Nodes 2.0 +// mount), so a flag written on a live widget is not enough on its own - +// a storage widget a panel always drives is hidden at the source, and +// hideWidget on the live widget covers the classic canvas layout. +// +// Never throws: a renderer that hands out frozen definitions gets a copied +// entry where it allows one and is otherwise left alone - the live-widget +// path still hides the widget on the classic canvas. +export function hideInputsInDef(nodeData, names) { + const groups = nodeData?.input; + if (!groups || typeof groups !== "object") return 0; + let marked = 0; + for (const name of names) { + for (const group of ["required", "optional"]) { + const spec = groups[group]?.[name]; + if (!Array.isArray(spec)) continue; + const options = spec[1] && typeof spec[1] === "object" ? spec[1] : {}; + if (options.hidden === true) { + marked += 1; + continue; + } + try { + options.hidden = true; + if (spec[1] !== options) spec[1] = options; + if (spec[1]?.hidden === true) { + marked += 1; + continue; + } + } catch { + // frozen options: fall through to replacing the entry + } + try { + groups[group][name] = [spec[0], { ...options, hidden: true }, ...spec.slice(2)]; + if (groups[group][name]?.[1]?.hidden === true) marked += 1; + } catch { + // frozen group: nothing more to do here + } + } + } + return marked; +} diff --git a/js/shared/workflow_note.mjs b/js/shared/workflow_note.mjs new file mode 100644 index 0000000..78dd8b8 --- /dev/null +++ b/js/shared/workflow_note.mjs @@ -0,0 +1,323 @@ +// Pure logic for Workflow Note: the card's state shape, a Markdown-lite +// parser that yields data (never HTML), URL safety, and the model-presence +// match. No DOM and no ComfyUI imports, so it runs under node:test. +// +// The card is stored as JSON in one hidden STRING widget. Everything here +// treats that JSON as untrusted: a downloaded workflow is someone else's +// text, so every URL is filtered to http(s) and every string is coerced. + +export const NOTE_VERSION = 1; +export const LAYOUTS = ["card", "banner"]; +// The one non-model folder a row may name: sample inputs live in +// ComfyUI/input, and a shared workflow often ships one. +export const INPUT_FOLDER = "input"; +// Folders every install has, offered first in the editor's folder picker. +export const COMMON_FOLDERS = [ + "diffusion_models", + "text_encoders", + "vae", + "loras", + "checkpoints", + "clip", + "clip_vision", + "controlnet", + "upscale_models", + "embeddings", + INPUT_FOLDER, +]; + +const ROW_LIMIT = 60; +const TEXT_LIMIT = 20000; + +function str(value, limit = 400) { + if (value === undefined || value === null) return ""; + const text = typeof value === "string" ? value : String(value); + return text.slice(0, limit); +} + +// http(s) only. A javascript: or data: URL in a downloaded workflow must +// never become a clickable button. +export function safeUrl(value) { + const text = str(value, 2000).trim(); + if (!/^https?:\/\/\S+$/i.test(text)) return ""; + return text; +} + +// "huggingface.co" from a full URL - the small print under a button. +export function hostLabel(url) { + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch { + return ""; + } +} + +export function baseName(path) { + const text = str(path, 1000).trim(); + const cut = Math.max(text.lastIndexOf("/"), text.lastIndexOf("\\")); + return cut >= 0 ? text.slice(cut + 1) : text; +} + +export function emptyNote() { + return { + v: NOTE_VERSION, + title: "", + subtitle: "", + author: "", + body: "", + models: [], + packs: [], + links: [], + layout: "card", + accent: "", + }; +} + +function normalizeModel(row) { + if (!row || typeof row !== "object") return null; + const name = str(row.name).trim(); + if (!name) return null; + return { + name, + dir: str(row.dir, 120).trim().replace(/^[\\/]+|[\\/]+$/g, ""), + url: safeUrl(row.url), + size: str(row.size, 40).trim(), + note: str(row.note, 300).trim(), + }; +} + +function normalizePack(row) { + if (!row || typeof row !== "object") return null; + const name = str(row.name).trim(); + if (!name) return null; + return { name, url: safeUrl(row.url), node: str(row.node, 200).trim() }; +} + +function normalizeLink(row) { + if (!row || typeof row !== "object") return null; + const url = safeUrl(row.url); + const label = str(row.label, 80).trim(); + if (!url && !label) return null; + return { label: label || hostLabel(url) || url, url }; +} + +function rows(list, normalize) { + if (!Array.isArray(list)) return []; + const out = []; + for (const row of list.slice(0, ROW_LIMIT)) { + const clean = normalize(row); + if (clean) out.push(clean); + } + return out; +} + +// Accepts the widget string, a parsed object, or nothing; always returns a +// complete card. Unknown keys are dropped and bad values fall back to empty. +export function normalizeNote(raw) { + let source = raw; + if (typeof raw === "string") { + try { + source = raw.trim() ? JSON.parse(raw) : {}; + } catch { + source = {}; + } + } + if (!source || typeof source !== "object" || Array.isArray(source)) source = {}; + const note = emptyNote(); + note.title = str(source.title, 200).trim(); + note.subtitle = str(source.subtitle, 400).trim(); + note.author = str(source.author, 120).trim(); + note.body = str(source.body, TEXT_LIMIT); + note.models = rows(source.models, normalizeModel); + note.packs = rows(source.packs, normalizePack); + note.links = rows(source.links, normalizeLink); + note.layout = LAYOUTS.includes(source.layout) ? source.layout : "card"; + const accent = str(source.accent, 20).trim(); + note.accent = /^#[0-9a-f]{6}$/i.test(accent) ? accent.toLowerCase() : ""; + return note; +} + +export function serializeNote(note) { + return JSON.stringify(normalizeNote(note)); +} + +// True when the card has nothing to show but its placeholder. +export function noteIsEmpty(note) { + return !( + note.title || note.subtitle || note.author || note.body.trim() + || note.models.length || note.packs.length || note.links.length + ); +} + +// "ComfyUI/models/loras" - the folder hint under each group of rows. +export function folderPath(dir) { + if (!dir) return "ComfyUI/models"; + if (dir === INPUT_FOLDER) return "ComfyUI/input"; + return `ComfyUI/models/${dir}`; +} + +// Rows grouped by folder in first-seen order, so the card reads like the +// install tree: one heading per folder, its files beneath. +export function groupModels(models) { + const groups = []; + const byDir = new Map(); + for (const row of models) { + let group = byDir.get(row.dir); + if (!group) { + group = { dir: row.dir, path: folderPath(row.dir), rows: [] }; + byDir.set(row.dir, group); + groups.push(group); + } + group.rows.push(row); + } + return groups; +} + +// Match a row's file against the server's list for its folder. A file that +// moved into a subfolder still counts - the loaders resolve it there - so +// the basename decides, case-insensitively; an exact relative path wins. +export function matchInstalled(name, files) { + if (!Array.isArray(files) || !files.length) return { found: false, path: null }; + const wanted = str(name, 1000).trim(); + const wantedBase = baseName(wanted).toLowerCase(); + if (!wantedBase) return { found: false, path: null }; + const normalized = files.map((file) => str(file, 1000).replace(/\\/g, "/")); + const exact = normalized.find((file) => file.toLowerCase() === wanted.replace(/\\/g, "/").toLowerCase()); + if (exact) return { found: true, path: exact }; + const loose = normalized.find((file) => baseName(file).toLowerCase() === wantedBase); + return loose ? { found: true, path: loose } : { found: false, path: null }; +} + +// Node packs present in a graph, from each node type's python_module +// ("custom_nodes.ComfyUI-AusBoss"). Core modules ("nodes", +// "comfy_extras.*") are skipped: nobody needs to install those. The first +// node type seen per pack becomes its presence probe. +export function packsFromGraph(nodeTypes, moduleOf) { + const packs = []; + const seen = new Set(); + for (const type of nodeTypes) { + const module = str(moduleOf(type), 300); + const match = /^custom_nodes\.([^.]+)/.exec(module); + if (!match) continue; + const name = match[1]; + if (seen.has(name)) continue; + seen.add(name); + packs.push({ name, url: "", node: type }); + } + return packs; +} + +// --------------------------------------------------------------------------- +// Markdown-lite. Headings, paragraphs, bullet and numbered lists, rules, +// fenced code, and inline bold / italic / code / links. Output is data; the +// renderer builds DOM nodes from it, so no user text ever meets innerHTML. +// --------------------------------------------------------------------------- + +const INLINE_PATTERN = /(\*\*[^*\n]+?\*\*|`[^`\n]+?`|\[[^\]\n]+?\]\([^)\s]+?\)|(? 0) spans.push({ type: "text", text: rest.slice(0, match.index) }); + const token = match[0]; + if (token.startsWith("**")) spans.push({ type: "strong", text: token.slice(2, -2) }); + else if (token.startsWith("`")) spans.push({ type: "code", text: token.slice(1, -1) }); + else if (token.startsWith("[")) { + const close = token.indexOf("]("); + const label = token.slice(1, close); + const url = safeUrl(token.slice(close + 2, -1)); + spans.push(url ? { type: "link", text: label, url } : { type: "text", text: label }); + } else spans.push({ type: "em", text: token.slice(1, -1) }); + rest = rest.slice(match.index + token.length); + } + return spans; +} + +export function parseMarkdown(text) { + const lines = str(text, TEXT_LIMIT).replace(/\r\n?/g, "\n").split("\n"); + const blocks = []; + let paragraph = []; + let list = null; + let code = null; + + const flushParagraph = () => { + if (!paragraph.length) return; + const spans = []; + paragraph.forEach((line, index) => { + if (index) spans.push({ type: "br" }); + spans.push(...parseInline(line)); + }); + blocks.push({ type: "paragraph", spans }); + paragraph = []; + }; + const flushList = () => { + if (list) blocks.push(list); + list = null; + }; + + for (const rawLine of lines) { + const line = rawLine.trimEnd(); + if (code) { + if (/^```/.test(line)) { + blocks.push(code); + code = null; + } else code.text += (code.text ? "\n" : "") + rawLine; + continue; + } + if (/^```/.test(line)) { + flushParagraph(); + flushList(); + code = { type: "code", text: "" }; + continue; + } + if (!line.trim()) { + flushParagraph(); + flushList(); + continue; + } + const heading = /^(#{1,3})\s+(.*)$/.exec(line); + if (heading) { + flushParagraph(); + flushList(); + blocks.push({ type: "heading", level: heading[1].length, spans: parseInline(heading[2]) }); + continue; + } + if (/^(-{3,}|\*{3,})$/.test(line.trim())) { + flushParagraph(); + flushList(); + blocks.push({ type: "hr" }); + continue; + } + const bullet = /^\s*[-*]\s+(.*)$/.exec(line); + const numbered = /^\s*\d+[.)]\s+(.*)$/.exec(line); + if (bullet || numbered) { + flushParagraph(); + const ordered = Boolean(numbered); + if (!list || list.ordered !== ordered) { + flushList(); + list = { type: "list", ordered, items: [] }; + } + list.items.push(parseInline((bullet || numbered)[1])); + continue; + } + if (list) { + // An indented continuation line belongs to the last item. + if (/^\s{2,}/.test(rawLine)) { + list.items[list.items.length - 1].push({ type: "br" }, ...parseInline(line.trim())); + continue; + } + flushList(); + } + paragraph.push(line); + } + if (code) blocks.push(code); + flushParagraph(); + flushList(); + return blocks; +} diff --git a/js/video_crop_rotate_pad_clip/index.js b/js/video_crop_rotate_pad_clip/index.js new file mode 100644 index 0000000..f1b8242 --- /dev/null +++ b/js/video_crop_rotate_pad_clip/index.js @@ -0,0 +1,5 @@ +import { registerTransformExtension } from "../shared/transform_editor.mjs"; + +// Same editor as the frame picker; the backend applies the transform to the +// whole start/end window, and the timeline only chooses the preview frame. +registerTransformExtension("AUSBOSS_NODES_VideoCropRotatePadClip", "video"); diff --git a/js/widget_cards/index.js b/js/widget_cards/index.js new file mode 100644 index 0000000..b0d0148 --- /dev/null +++ b/js/widget_cards/index.js @@ -0,0 +1,189 @@ +// Widget cards for the nodes whose face was still a stack of classic canvas +// widgets. Each entry names the rows the card shows in place of those +// widgets; the card (js/shared/widget_card.mjs) hides the originals and +// mirrors them, so the backend, saved workflows and widget-to-input links +// are untouched. Rows with `when` appear only while they mean something, +// and a `group` row folds the rarely-touched settings behind one click. +import { app } from "/scripts/app.js"; +import { chainCallback } from "../shared/index.mjs"; +import { hideInputsInDef } from "../shared/widget_visibility.mjs"; +import { mountWidgetCard } from "../shared/widget_card.mjs"; + +const isMode = (name, ...modes) => (values) => modes.includes(values[name]); + +const CARDS = { + AUSBOSS_NODES_ImageResize: { + minWidth: 300, + rows: [ + { widget: "target_mode", label: "Target", kind: "select", + labels: { "width+height": "Width × height", longest_edge: "Longest edge", shortest_edge: "Shortest edge", megapixels: "Megapixels", scale_factor: "Scale factor" } }, + // Sockets for the pair sit among the node's inputs (one socket per + // row is the rule, and a size is what people wire). + { pair: ["width", "height"], label: "Size", sep: "×", top: true, when: isMode("target_mode", "width+height") }, + { widget: "edge_length", label: "Edge", suffix: "px", when: isMode("target_mode", "longest_edge", "shortest_edge") }, + { widget: "megapixels", label: "Budget", suffix: "MP", when: isMode("target_mode", "megapixels") }, + { widget: "scale_factor", label: "Scale", suffix: "×", when: isMode("target_mode", "scale_factor") }, + { widget: "keep_proportion", label: "Fit", kind: "segment", labels: { cover_crop: "cover" } }, + { widget: "fill_color", label: "Fill", kind: "color", when: isMode("keep_proportion", "pad") }, + { widget: "divisible_by", label: "Multiple" }, + { widget: "interpolation", label: "Filter", kind: "select" }, + ], + }, + AUSBOSS_NODES_ColorMatch: { + minWidth: 300, + rows: [ + { widget: "strength", label: "Strength" }, + { widget: "method", label: "Method", kind: "segment", labels: { histogram: "hist" }, titles: { histogram: "histogram" } }, + { widget: "reference_mode", label: "Reference", kind: "segment", labels: { reference: "reference", first_frame: "first frame" } }, + { widget: "invert_mask", label: "Invert mask" }, + ], + }, + AUSBOSS_NODES_StitchInpaint: { + minWidth: 300, + rows: [ + { widget: "color_match", label: "Tone match" }, + { widget: "fix_edge_halo", label: "Fix edge halo" }, + ], + }, + AUSBOSS_NODES_CropForInpaint: { + minWidth: 320, + rows: [ + { widget: "context_factor", label: "Context", suffix: "×" }, + { widget: "blend_pixels", label: "Blend", suffix: "px" }, + { widget: "output_multiple", label: "Multiple" }, + { widget: "mask_grow", label: "Grow", suffix: "px" }, + { widget: "mask_blur", label: "Blur" }, + { widget: "invert_mask", label: "Invert mask" }, + { group: "advanced", label: "Target size, extend" }, + { widget: "context_pixels", label: "Extra context", suffix: "px", group: "advanced" }, + { widget: "target_width", label: "Target width", suffix: "px", group: "advanced" }, + { widget: "target_height", label: "Target height", suffix: "px", group: "advanced" }, + { widget: "target_megapixels", label: "Target", suffix: "MP", group: "advanced" }, + { widget: "rescale_algorithm", label: "Rescale", kind: "segment", group: "advanced" }, + { widget: "extend_left", label: "Extend left", suffix: "px", group: "advanced" }, + { widget: "extend_right", label: "Extend right", suffix: "px", group: "advanced" }, + { widget: "extend_up", label: "Extend up", suffix: "px", group: "advanced" }, + { widget: "extend_down", label: "Extend down", suffix: "px", group: "advanced" }, + ], + }, + AUSBOSS_NODES_RefineMask: { + minWidth: 300, first: true, + rows: [ + { widget: "expand", label: "Expand", suffix: "px" }, + { widget: "blur", label: "Blur" }, + { group: "advanced", label: "More" }, + { widget: "fill_holes", label: "Fill holes", group: "advanced" }, + { widget: "smooth", label: "Smooth", suffix: "px", group: "advanced" }, + { widget: "black_point", label: "Black point", group: "advanced" }, + { widget: "white_point", label: "White point", group: "advanced" }, + { widget: "edge_refine", label: "Edge", kind: "segment", labels: { "guided filter": "guided" }, group: "advanced" }, + ], + }, + AUSBOSS_NODES_FrameInterpolate: { + minWidth: 300, + rows: [ + { pair: ["source_fps", "target_fps"], label: "FPS", sep: "→", top: true }, + { widget: "method", label: "Method", kind: "select", labels: { "optical flow (requires cached RAFT weights)": "optical flow (RAFT)" } }, + { widget: "scene_cut_threshold", label: "Scene cut" }, + { widget: "batch_size", label: "Batch" }, + ], + }, + AUSBOSS_NODES_AlignImage: { + minWidth: 300, + rows: [ + { widget: "mode", label: "Mode", kind: "segment" }, + { widget: "multiple", label: "Multiple" }, + { widget: "crop_position", label: "Anchor", kind: "select", when: isMode("mode", "crop") }, + { widget: "pad_position", label: "Anchor", kind: "select", when: isMode("mode", "pad") }, + { widget: "pad_fill", label: "Pad fill", kind: "segment", when: isMode("mode", "pad") }, + { widget: "pad_color", label: "Pad color", kind: "color", when: (values) => values.mode === "pad" && values.pad_fill === "color" }, + ], + }, + // a, b and c are socket-only inputs (forceInput), so the card is the + // expression alone; the values arrive on the node's left edge. + AUSBOSS_NODES_MathExpression: { + minWidth: 300, + rows: [{ widget: "expression", label: "f(a, b, c)", placeholder: "a + b" }], + }, + AUSBOSS_NODES_SelectEveryNth: { minWidth: 280, rows: [{ widget: "nth", label: "Every nth" }, { widget: "offset", label: "Offset" }] }, + AUSBOSS_NODES_SplitBatch: { minWidth: 280, rows: [{ widget: "index", label: "Split after" }] }, + AUSBOSS_NODES_MergeBatches: { minWidth: 300, rows: [{ widget: "on_mismatch", label: "Mismatch", kind: "segment", labels: { "resize to a": "match a", "resize to b": "match b" }, titles: { "resize to a": "resize b to a's size", "resize to b": "resize a to b's size", error: "stop the run" } }] }, + AUSBOSS_NODES_Integer: { minWidth: 260, rows: [{ widget: "value", label: "Value" }] }, + AUSBOSS_NODES_Float: { minWidth: 260, rows: [{ widget: "value", label: "Value", decimals: 3 }] }, + AUSBOSS_NODES_Text: { minWidth: 300, rows: [{ widget: "text", kind: "textarea", placeholder: "Prompt, caption, or shared text", height: 100, grow: true }] }, + AUSBOSS_NODES_SelectFrame: { minWidth: 280, first: true, rows: [{ widget: "frame_number", label: "Frame" }] }, + AUSBOSS_NODES_LaMaInpaint: { minWidth: 280, first: true, rows: [{ widget: "model", label: "Model", kind: "select" }] }, + AUSBOSS_NODES_Krea2OutpaintModelPatch: { + minWidth: 320, + rows: [ + { widget: "placement", label: "Reference", kind: "segment", labels: { "source rectangle": "source rect", "whole canvas": "whole canvas" } }, + { widget: "kv_cache", label: "KV cache", onText: "once per run", offText: "every step" }, + ], + }, + AUSBOSS_NODES_Krea2Encode: { + minWidth: 320, + rows: [ + { widget: "prompt", kind: "textarea", placeholder: "Prompt - describe the whole finished canvas", height: 96, grow: true }, + { widget: "negative_prompt", kind: "textarea", placeholder: "Negative prompt (ignored at CFG 1)", height: 44 }, + { widget: "vlm_reference", label: "VLM reference", title: "On, the vision tower also sees the reference images. Use on for AnyPaint with a whole-canvas reference; use off for Registered Outpaint with a source-rectangle reference. Match the adapter's conditioning contract." }, + ], + }, + AUSBOSS_NODES_LoadVideo: { + minWidth: 320, first: true, hide: ["upload"], + rows: [ + { widget: "video", label: "Source", kind: "select", + button: { text: "Upload", title: "Upload a video into ComfyUI's input folder", onClick: (node) => node.widgets?.find((w) => w.name === "upload")?.callback?.() } }, + { widget: "custom_width", label: "Width", suffix: "px", title: "0 keeps the source width; set one side only to keep the aspect." }, + { widget: "custom_height", label: "Height", suffix: "px", title: "0 keeps the source height; set one side only to keep the aspect." }, + { widget: "every_nth", label: "Every nth" }, + { widget: "max_frames", label: "Limit" }, + ], + }, + AUSBOSS_NODES_LoadImagePad: { + minWidth: 340, first: true, hide: ["upload"], + rows: [ + { widget: "image", label: "Source", kind: "select", + button: { text: "Upload", title: "Upload an image into ComfyUI's input folder", onClick: (node) => node.widgets?.find((w) => w.name === "upload")?.callback?.() } }, + { widget: "mode", label: "Fill", kind: "select" }, + { widget: "fill_color", label: "Color", kind: "color", when: isMode("mode", "color") }, + { widget: "backdrop_blur", label: "Backdrop", when: isMode("mode", "pillarbox blur") }, + { widget: "feather", label: "Feather", suffix: "px" }, + { widget: "canvas_multiple", label: "Multiple" }, + { widget: "target_megapixels", label: "Budget", suffix: "MP" }, + { group: "padding", label: "Exact padding" }, + { widget: "pad_left", label: "Left", suffix: "px", group: "padding" }, + { widget: "pad_top", label: "Top", suffix: "px", group: "padding" }, + { widget: "pad_right", label: "Right", suffix: "px", group: "padding" }, + { widget: "pad_bottom", label: "Bottom", suffix: "px", group: "padding" }, + ], + }, + AUSBOSS_NODES_SaveVideo: { + minWidth: 320, first: true, + rows: [ + { widget: "filename_prefix", label: "Prefix", placeholder: "AusBoss/video" }, + { widget: "format", label: "Format", kind: "select" }, + { pair: ["fps", "crf"], label: "FPS · CRF", sep: "·", top: true }, + { widget: "pingpong", label: "Ping-pong", onText: "forward, back", offText: "off" }, + { widget: "save_metadata", label: "Metadata", title: "On embeds the workflow in the video file, so dropping the file back on the canvas restores the graph that made it." }, + ], + }, +}; + +function cardWidgetNames(config) { + return config.rows.flatMap((row) => row.pair ?? (row.widget ? [row.widget] : [])); +} + +app.registerExtension({ + name: "ausboss.widget_cards", + beforeRegisterNodeDef(nodeType, nodeData) { + const config = CARDS[nodeData?.name]; + if (!config) return; + hideInputsInDef(nodeData, cardWidgetNames(config)); + chainCallback(nodeType.prototype, "onNodeCreated", function () { + if (this.__ausbossCard) return; + mountWidgetCard(this, config); + const width = Math.max(this.size?.[0] || 0, config.minWidth ?? 300); + this.setSize?.([width, this.computeSize?.()[1] || this.size?.[1] || 0]); + }); + }, +}); diff --git a/js/widget_kv/index.js b/js/widget_kv/index.js index 4504675..7cc7edd 100644 --- a/js/widget_kv/index.js +++ b/js/widget_kv/index.js @@ -21,8 +21,10 @@ import { const NODE_TYPES = new Set([ "AUSBOSS_NODES_ImageCropRotatePad", "AUSBOSS_NODES_VideoCropRotatePad", + "AUSBOSS_NODES_VideoCropRotatePadClip", "AUSBOSS_NODES_LoadVideo", "AUSBOSS_NODES_SaveVideo", + "AUSBOSS_NODES_SaveImage", ]); app.registerExtension({ diff --git a/js/workflow_note/index.js b/js/workflow_note/index.js new file mode 100644 index 0000000..890b840 --- /dev/null +++ b/js/workflow_note/index.js @@ -0,0 +1,843 @@ +// Workflow Note 🆎 — the card that makes a shared workflow self-explanatory. +// +// One hidden STRING widget holds the card as JSON (title, subtitle, author, +// Markdown body, model rows, node-pack rows, links). This file renders it +// on the node face, checks every model file and node pack against this +// install, and opens the editor dialog. All parsing and the model match +// live in js/shared/workflow_note.mjs under node:test. +// +// Safety: a downloaded workflow is someone else's text. Nothing here goes +// through innerHTML - every string becomes a text node - and only http(s) +// URLs become buttons or links (the parser already filtered them). + +import { api } from "/scripts/api.js"; +import { app } from "/scripts/app.js"; +import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs"; +import { WIDGET_FRAME, fillNodeHeight } from "../shared/panel_layout.mjs"; +import { hideInputsInDef, hideWidget } from "../shared/widget_visibility.mjs"; +import { + COMMON_FOLDERS, + INPUT_FOLDER, + LAYOUTS, + groupModels, + hostLabel, + matchInstalled, + noteIsEmpty, + normalizeNote, + packsFromGraph, + parseMarkdown, + serializeNote, +} from "../shared/workflow_note.mjs"; + +const NODE_CLASS = "AUSBOSS_NODES_WorkflowNote"; +const CSS_ID = "ausboss-workflow-note-css"; +const WIDGET_NAME = "ausboss_workflow_note_panel"; +const PANEL_MIN_WIDTH = 300; +const CARD_MIN_HEIGHT = 160; +const BANNER_MIN_HEIGHT = 64; +const DEFAULT_SIZE = [520, 620]; +const FOLDER_CACHE_MS = 30000; + +// --------------------------------------------------------------------------- +// Server lookups, cached across every note on the canvas +// --------------------------------------------------------------------------- + +const folderLists = new Map(); // dir -> { at, promise } +let modelFolders = null; // Promise from GET /models + +function listFolder(dir, fresh = false) { + const cached = folderLists.get(dir); + if (!fresh && cached && performance.now() - cached.at < FOLDER_CACHE_MS) return cached.promise; + const promise = api + .fetchApi(`/models/${encodeURIComponent(dir)}`) + .then((response) => (response.ok ? response.json() : null)) + .then((files) => (Array.isArray(files) ? files : null)) + .catch(() => null); + folderLists.set(dir, { at: performance.now(), promise }); + return promise; +} + +function inputExists(name) { + const query = new URLSearchParams({ filename: name, type: "input" }); + return api + .fetchApi(`/view?${query}`, { method: "HEAD" }) + .then((response) => response.ok) + .catch(() => false); +} + +function knownFolders() { + modelFolders ??= api + .fetchApi("/models") + .then((response) => (response.ok ? response.json() : [])) + .then((list) => (Array.isArray(list) ? list.filter((item) => typeof item === "string") : [])) + .catch(() => []); + return modelFolders; +} + +function nodeTypeRegistered(type) { + return Boolean(type && globalThis.LiteGraph?.registered_node_types?.[type]); +} + +function moduleOfType(type) { + return globalThis.LiteGraph?.registered_node_types?.[type]?.nodeData?.python_module; +} + +// --------------------------------------------------------------------------- +// CSS +// --------------------------------------------------------------------------- + +function ensureCss() { + if (document.getElementById(CSS_ID)) return; + const style = document.createElement("style"); + style.id = CSS_ID; + style.textContent = ` +.ausboss-note{box-sizing:border-box;width:100%;height:100%;padding:2px 6px 6px;pointer-events:none;overflow:hidden;--accent:${BRAND};} +.ausboss-note *{box-sizing:border-box;} +.ausboss-note-card{position:relative;display:flex;flex-direction:column;width:100%;height:100%;overflow:hidden;border:1px solid rgba(0,180,170,.27);border-radius:6px;background:rgba(0,0,0,.28);color:#c8dddd;font:12px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;} +.ausboss-note-scroll{flex:1 1 auto;min-height:0;overflow-y:auto;overflow-x:hidden;pointer-events:auto;padding:0 0 8px;} +.ausboss-note-head{position:relative;padding:12px 40px 10px 16px;border-left:4px solid var(--accent);} +.ausboss-note-title{margin:0;color:#f2f8f8;font-size:22px;font-weight:700;line-height:1.15;letter-spacing:-.01em;overflow-wrap:anywhere;} +.ausboss-note-subtitle{margin:5px 0 0;color:#a9c4c2;font-size:12.5px;line-height:1.35;overflow-wrap:anywhere;} +.ausboss-note-author{margin:6px 0 0;color:#78908e;font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;} +.ausboss-note.is-banner .ausboss-note-head{padding:14px 40px 14px 18px;} +.ausboss-note.is-banner .ausboss-note-title{font-size:28px;} +.ausboss-note-edit{position:absolute;top:8px;right:8px;width:24px;height:24px;border:1px solid transparent;border-radius:5px;background:transparent;color:#78908e;font-size:13px;line-height:1;cursor:pointer;pointer-events:auto;} +.ausboss-note-edit:hover{border-color:#3a4047;background:#23272c;color:#fff;} +.ausboss-note-empty{padding:14px 16px 4px;color:#78908e;font-size:11.5px;line-height:1.4;} +.ausboss-note-section{padding:8px 16px 0;} +.ausboss-note-section + .ausboss-note-section{margin-top:6px;border-top:1px solid rgba(255,255,255,.06);padding-top:10px;} +.ausboss-note-h{display:flex;align-items:center;gap:8px;margin:0 0 6px;color:#78908e;font-size:10px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;} +.ausboss-note-h .spacer{flex:1 1 auto;} +.ausboss-note-mini{height:18px;padding:0 7px;border:1px solid #3a4047;border-radius:4px;background:#23272c;color:#9ba2aa;font:10px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;letter-spacing:.04em;text-transform:none;cursor:pointer;pointer-events:auto;} +.ausboss-note-mini:hover{border-color:var(--accent);color:#fff;} +.ausboss-note-body{user-select:text;-webkit-user-select:text;cursor:text;} +.ausboss-note-body p{margin:0 0 7px;overflow-wrap:anywhere;} +.ausboss-note-body h1,.ausboss-note-body h2,.ausboss-note-body h3{margin:8px 0 5px;color:#eaf4f3;font-weight:700;line-height:1.25;} +.ausboss-note-body h1{font-size:16px;} +.ausboss-note-body h2{font-size:14px;} +.ausboss-note-body h3{font-size:12.5px;text-transform:uppercase;letter-spacing:.06em;color:#a9c4c2;} +.ausboss-note-body ul,.ausboss-note-body ol{margin:0 0 7px;padding-left:20px;} +.ausboss-note-body li{margin:2px 0;overflow-wrap:anywhere;} +.ausboss-note-body hr{margin:8px 0;border:0;border-top:1px solid rgba(255,255,255,.08);} +.ausboss-note-body code{padding:1px 4px;border-radius:3px;background:rgba(255,255,255,.08);font:11px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#e6f4f3;} +.ausboss-note-body pre{margin:0 0 7px;padding:7px 9px;overflow-x:auto;border-radius:5px;background:rgba(0,0,0,.35);font:11px/1.4 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#e6f4f3;white-space:pre;} +.ausboss-note-body a,.ausboss-note-link{color:var(--accent);text-decoration:none;pointer-events:auto;} +.ausboss-note-body a:hover,.ausboss-note-link:hover{text-decoration:underline;} +.ausboss-note-folder{margin:8px 0 4px;color:#a9c4c2;font:11px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;} +.ausboss-note-folder:first-child{margin-top:0;} +.ausboss-note-row{display:flex;align-items:center;gap:8px;min-height:30px;padding:3px 0;} +.ausboss-note-dot{flex:none;width:8px;height:8px;border-radius:50%;background:#4b5a5a;} +.ausboss-note-dot.checking{background:#4b5a5a;animation:ausboss-note-pulse 1s ease-in-out infinite;} +.ausboss-note-dot.ok{background:var(--accent);box-shadow:0 0 5px rgba(0,180,170,.55);} +.ausboss-note-dot.missing{background:#e0564b;box-shadow:0 0 5px rgba(224,86,75,.5);} +.ausboss-note-dot.unknown{background:#4b5a5a;} +@keyframes ausboss-note-pulse{0%,100%{opacity:.35;}50%{opacity:1;}} +.ausboss-note-file{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:1px;} +.ausboss-note-name{overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:#e6f4f3;font:11.5px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;pointer-events:auto;cursor:copy;} +.ausboss-note-meta{overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:#78908e;font-size:10.5px;} +.ausboss-note-dl{flex:none;display:inline-flex;align-items:center;gap:6px;height:24px;padding:0 9px;border:1px solid var(--accent);border-radius:5px;background:rgba(0,180,170,.14);color:#e6f4f3;font-size:11px;font-weight:600;text-decoration:none;white-space:nowrap;pointer-events:auto;cursor:pointer;} +.ausboss-note-dl:hover{background:var(--accent);color:#08211f;} +.ausboss-note-dl .size{font-weight:400;opacity:.8;} +.ausboss-note-pill{flex:none;display:inline-flex;align-items:center;gap:5px;height:22px;padding:0 8px;border-radius:11px;background:rgba(0,180,170,.16);color:#9fe3dc;font-size:10.5px;white-space:nowrap;} +.ausboss-note-pill.missing{background:rgba(224,86,75,.16);color:#f0a59e;} +.ausboss-note-pill a{color:inherit;text-decoration:none;pointer-events:auto;} +.ausboss-note-pill a:hover{text-decoration:underline;} +.ausboss-note-chips{display:flex;flex-wrap:wrap;gap:6px;} +.ausboss-note-chip{display:inline-flex;align-items:center;height:24px;padding:0 10px;border:1px solid #3a4047;border-radius:12px;background:#23272c;color:#d7dde2;font-size:11px;text-decoration:none;white-space:nowrap;pointer-events:auto;} +.ausboss-note-chip:hover{border-color:var(--accent);color:#fff;} +/* editor dialog */ +.ausboss-note-overlay{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.55);} +.ausboss-note-dialog{display:flex;flex-direction:column;width:min(860px,94vw);max-height:92vh;border:1px solid #3a4047;border-radius:9px;background:#1c1f23;box-shadow:0 12px 40px rgba(0,0,0,.6);color:#d7dde2;font:12px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;} +.ausboss-note-dialog *{box-sizing:border-box;} +.ausboss-note-dhead{display:flex;align-items:center;gap:8px;padding:11px 14px;border-bottom:1px solid #2c3238;color:${BRAND};font-weight:600;} +.ausboss-note-dhead .spacer{flex:1 1 auto;} +.ausboss-note-dbody{flex:1 1 auto;min-height:0;overflow-y:auto;padding:12px 14px;display:flex;flex-direction:column;gap:12px;} +.ausboss-note-dfoot{display:flex;align-items:center;gap:8px;padding:10px 14px;border-top:1px solid #2c3238;} +.ausboss-note-dfoot .spacer{flex:1 1 auto;} +.ausboss-note-field{display:flex;flex-direction:column;gap:4px;} +.ausboss-note-field > label{color:#9ba2aa;font-size:10.5px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;} +.ausboss-note-field .hint{color:#78908e;font-size:10.5px;} +.ausboss-note-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;} +.ausboss-note-dialog input,.ausboss-note-dialog textarea,.ausboss-note-dialog select{width:100%;padding:5px 8px;border:1px solid #3a4047;border-radius:5px;background:#23272c;color:#e6f4f3;font:12px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;outline:none;} +.ausboss-note-dialog textarea{min-height:120px;resize:vertical;font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;} +.ausboss-note-dialog textarea.json{min-height:320px;} +.ausboss-note-dialog input:focus,.ausboss-note-dialog textarea:focus,.ausboss-note-dialog select:focus{border-color:${BRAND};} +.ausboss-note-table{display:flex;flex-direction:column;gap:4px;} +.ausboss-note-trow{display:grid;gap:6px;align-items:center;} +.ausboss-note-trow.models{grid-template-columns:minmax(120px,1.4fr) 130px 70px minmax(120px,1.6fr) 24px;} +.ausboss-note-trow.packs{grid-template-columns:minmax(120px,1fr) minmax(120px,1.4fr) minmax(120px,1fr) 24px;} +.ausboss-note-trow.links{grid-template-columns:minmax(120px,1fr) minmax(120px,2fr) 24px;} +.ausboss-note-trow.head{color:#78908e;font-size:10px;letter-spacing:.06em;text-transform:uppercase;} +.ausboss-note-x{width:24px;height:24px;padding:0;border:1px solid transparent;border-radius:5px;background:transparent;color:#9ba2aa;cursor:pointer;} +.ausboss-note-x:hover{border-color:#3a4047;color:#fff;} +.ausboss-note-btn{height:26px;padding:0 10px;border:1px solid #3a4047;border-radius:5px;background:#23272c;color:#d7dde2;font:12px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;cursor:pointer;} +.ausboss-note-btn:hover{border-color:${BRAND};color:#fff;} +.ausboss-note-btn.primary{border-color:${BRAND};background:${BRAND};color:#08211f;font-weight:600;} +.ausboss-note-btn.primary:hover{background:#19c9be;} +.ausboss-note-seg{display:inline-flex;border:1px solid #3a4047;border-radius:5px;overflow:hidden;} +.ausboss-note-seg button{height:26px;padding:0 10px;border:none;background:#23272c;color:#9ba2aa;font:12px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;cursor:pointer;} +.ausboss-note-seg button + button{border-left:1px solid #3a4047;} +.ausboss-note-seg button.on{background:${BRAND};color:#08211f;font-weight:600;} +.ausboss-note-error{color:#f0a59e;font-size:11px;} +`; + document.head.appendChild(style); +} + +// --------------------------------------------------------------------------- +// DOM helpers +// --------------------------------------------------------------------------- + +function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +function externalLink(url, className, text) { + const link = el("a", className, text); + link.href = url; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + // A click on a link must not become a node drag. + link.addEventListener("pointerdown", (event) => event.stopPropagation()); + return link; +} + +function spansInto(parent, spans) { + for (const span of spans) { + if (span.type === "br") parent.append(document.createElement("br")); + else if (span.type === "strong") parent.append(el("strong", "", span.text)); + else if (span.type === "em") parent.append(el("em", "", span.text)); + else if (span.type === "code") parent.append(el("code", "", span.text)); + else if (span.type === "link") parent.append(externalLink(span.url, "", span.text)); + else parent.append(document.createTextNode(span.text)); + } +} + +function markdownInto(parent, text) { + for (const block of parseMarkdown(text)) { + if (block.type === "heading") { + const heading = el(`h${block.level}`); + spansInto(heading, block.spans); + parent.append(heading); + } else if (block.type === "list") { + const list = el(block.ordered ? "ol" : "ul"); + for (const item of block.items) { + const li = el("li"); + spansInto(li, item); + list.append(li); + } + parent.append(list); + } else if (block.type === "hr") parent.append(el("hr")); + else if (block.type === "code") parent.append(el("pre", "", block.text)); + else { + const paragraph = el("p"); + spansInto(paragraph, block.spans); + parent.append(paragraph); + } + } +} + +async function copyText(text, element) { + try { + await navigator.clipboard?.writeText(text); + const before = element.textContent; + element.textContent = "copied"; + setTimeout(() => { if (element.textContent === "copied") element.textContent = before; }, 700); + } catch { + // Clipboard blocked: the name is still on screen. + } +} + +// --------------------------------------------------------------------------- +// The card +// --------------------------------------------------------------------------- + +function setDot(dot, status, title) { + dot.className = `ausboss-note-dot ${status}`; + dot.title = title ?? ""; +} + +function modelRow(state, row) { + const line = el("div", "ausboss-note-row"); + const dot = el("span", "ausboss-note-dot checking"); + const file = el("div", "ausboss-note-file"); + const name = el("div", "ausboss-note-name", row.name); + name.title = `${row.name} - click to copy the file name`; + name.addEventListener("pointerdown", (event) => event.stopPropagation()); + name.addEventListener("click", () => copyText(row.name, name)); + file.append(name); + const metaBits = []; + if (row.note) metaBits.push(row.note); + const meta = el("div", "ausboss-note-meta", metaBits.join(" · ")); + if (metaBits.length) file.append(meta); + const action = el("span"); + line.append(dot, file, action); + + const showDownload = () => { + action.textContent = ""; + if (row.url) { + const button = externalLink(row.url, "ausboss-note-dl", ""); + button.append(document.createTextNode("Download")); + if (row.size) button.append(el("span", "size", row.size)); + button.title = `Opens ${hostLabel(row.url) || row.url} in a new tab`; + action.append(button); + } else if (row.size) { + action.append(el("span", "ausboss-note-meta", row.size)); + } + }; + const showInstalled = (path) => { + action.textContent = ""; + const pill = el("span", "ausboss-note-pill"); + pill.append(document.createTextNode("✓ installed")); + if (row.url) { + const link = externalLink(row.url, "", "↗"); + link.title = `Source: ${hostLabel(row.url) || row.url}`; + pill.append(link); + } + pill.title = path ? `Found: ${path}` : "Found"; + action.append(pill); + }; + showDownload(); + state.rows.push({ row, dot, showDownload, showInstalled }); + return line; +} + +function packRow(state, row) { + const line = el("div", "ausboss-note-row"); + const dot = el("span", "ausboss-note-dot unknown"); + const file = el("div", "ausboss-note-file"); + const name = el("div", "ausboss-note-name", row.name); + name.style.cursor = "default"; + file.append(name); + const action = el("span"); + line.append(dot, file, action); + const installed = row.node ? nodeTypeRegistered(row.node) : null; + if (installed === true) { + setDot(dot, "ok", `${row.node} is registered on this install`); + const pill = el("span", "ausboss-note-pill", "✓ installed"); + if (row.url) { + const link = externalLink(row.url, "", "↗"); + link.title = row.url; + pill.append(link); + } + action.append(pill); + } else { + setDot(dot, installed === false ? "missing" : "unknown", + installed === false ? `${row.node} is not registered here - install the pack` : "No probe node set, so this row is not checked"); + if (row.url) { + const button = externalLink(row.url, "ausboss-note-dl", installed === false ? "Get it" : "Open"); + button.title = row.url; + action.append(button); + } else if (installed === false) { + action.append(el("span", "ausboss-note-pill missing", "missing")); + } + } + return line; +} + +function sectionHeading(text) { + const heading = el("div", "ausboss-note-h"); + heading.append(el("span", "", text), el("span", "spacer")); + return heading; +} + +// The frontend hangs a "which pack" badge above every custom node. On a +// banner - a title label for a column or stage - it floats in space, so +// the banner layout hides it and the card layout puts it back. +function setBadgesHidden(node, hidden) { + if (hidden === Boolean(node.__ausbossBadgesHidden)) return; + try { + if (hidden) { + node.__ausbossBadgeStash = node.badges; + Object.defineProperty(node, "badges", { configurable: true, get: () => [], set: () => {} }); + } else { + delete node.badges; + node.badges = node.__ausbossBadgeStash ?? []; + } + node.__ausbossBadgesHidden = hidden; + } catch { + // A frozen node keeps its badge; nothing else depends on this. + } +} + +function render(state) { + const { note } = state; + const root = state.root; + root.classList.toggle("is-banner", note.layout === "banner"); + setBadgesHidden(state.node, note.layout === "banner" && !noteIsEmpty(note)); + root.style.setProperty("--accent", note.accent || BRAND); + const scroll = state.scroll; + scroll.textContent = ""; + state.rows = []; + + const head = el("div", "ausboss-note-head"); + head.append(el("h1", "ausboss-note-title", note.title || "Untitled workflow")); + if (note.subtitle) head.append(el("p", "ausboss-note-subtitle", note.subtitle)); + if (note.author) head.append(el("p", "ausboss-note-author", `by ${note.author}`)); + const edit = el("button", "ausboss-note-edit", "✎"); + edit.type = "button"; + edit.title = "Edit this note"; + edit.addEventListener("pointerdown", (event) => event.stopPropagation()); + edit.addEventListener("click", () => openEditor(state)); + head.append(edit); + scroll.append(head); + + if (noteIsEmpty(note)) { + scroll.append(el("div", "ausboss-note-empty", + "Click ✎ to give this workflow a title, a how-to, the models it needs with download links, and your own links.")); + state.domWidget.options.getMinHeight = () => CARD_MIN_HEIGHT + WIDGET_FRAME; + return; + } + if (note.layout === "banner") { + state.domWidget.options.getMinHeight = () => BANNER_MIN_HEIGHT + WIDGET_FRAME; + return; + } + state.domWidget.options.getMinHeight = () => CARD_MIN_HEIGHT + WIDGET_FRAME; + + if (note.body.trim()) { + const section = el("div", "ausboss-note-section"); + const body = el("div", "ausboss-note-body"); + body.addEventListener("pointerdown", (event) => event.stopPropagation()); + markdownInto(body, note.body); + section.append(body); + scroll.append(section); + } + + if (note.models.length) { + const section = el("div", "ausboss-note-section"); + const heading = sectionHeading("Models"); + const recheck = el("button", "ausboss-note-mini", "↻ check files"); + recheck.type = "button"; + recheck.title = "Look for each file again (after moving files in, or pressing R)"; + recheck.addEventListener("pointerdown", (event) => event.stopPropagation()); + recheck.addEventListener("click", () => checkModels(state, true)); + heading.append(recheck); + section.append(heading); + for (const group of groupModels(note.models)) { + section.append(el("div", "ausboss-note-folder", `📂 ${group.path}`)); + for (const row of group.rows) section.append(modelRow(state, row)); + } + scroll.append(section); + } + + if (note.packs.length) { + const section = el("div", "ausboss-note-section"); + section.append(sectionHeading("Node packs")); + for (const row of note.packs) section.append(packRow(state, row)); + scroll.append(section); + } + + if (note.links.length) { + const section = el("div", "ausboss-note-section"); + section.append(sectionHeading("Links")); + const chips = el("div", "ausboss-note-chips"); + for (const row of note.links) { + if (row.url) chips.append(externalLink(row.url, "ausboss-note-chip", row.label)); + else chips.append(el("span", "ausboss-note-chip", row.label)); + } + section.append(chips); + scroll.append(section); + } + + checkModels(state, false); +} + +async function checkModels(state, fresh) { + const generation = (state.checkGeneration = (state.checkGeneration || 0) + 1); + const rows = state.rows; + for (const entry of rows) setDot(entry.dot, "checking", "checking…"); + const byDir = new Map(); + for (const entry of rows) { + const dir = entry.row.dir; + if (!byDir.has(dir)) byDir.set(dir, []); + byDir.get(dir).push(entry); + } + await Promise.all([...byDir.entries()].map(async ([dir, entries]) => { + if (!dir) { + for (const entry of entries) setDot(entry.dot, "unknown", "No folder set, so this file is not checked"); + return; + } + if (dir === INPUT_FOLDER) { + await Promise.all(entries.map(async (entry) => { + const found = await inputExists(entry.row.name); + if (generation !== state.checkGeneration) return; + if (found) { + setDot(entry.dot, "ok", "Found in ComfyUI/input"); + entry.showInstalled(`input/${entry.row.name}`); + } else { + setDot(entry.dot, "missing", "Not in ComfyUI/input yet"); + entry.showDownload(); + } + })); + return; + } + const files = await listFolder(dir, fresh); + if (generation !== state.checkGeneration) return; + for (const entry of entries) { + if (files === null) { + setDot(entry.dot, "unknown", `This install has no "${dir}" model folder, so the file is not checked`); + entry.showDownload(); + continue; + } + const match = matchInstalled(entry.row.name, files); + if (match.found) { + setDot(entry.dot, "ok", `Found: ${match.path}`); + entry.showInstalled(match.path); + } else { + setDot(entry.dot, "missing", `Not found in ComfyUI/models/${dir}`); + entry.showDownload(); + } + } + })); +} + +// --------------------------------------------------------------------------- +// The editor dialog +// --------------------------------------------------------------------------- + +let openDialog = null; + +function closeEditor() { + if (!openDialog) return; + openDialog.abort.abort(); + openDialog.element.remove(); + openDialog = null; +} + +function field(labelText, control, hint) { + const wrap = el("div", "ausboss-note-field"); + wrap.append(el("label", "", labelText), control); + if (hint) wrap.append(el("span", "hint", hint)); + return wrap; +} + +function input(value, placeholder) { + const box = el("input"); + box.type = "text"; + box.value = value ?? ""; + if (placeholder) box.placeholder = placeholder; + return box; +} + +function folderSelect(value, folders) { + const select = el("select"); + const options = [...folders]; + if (value && !options.includes(value)) options.push(value); + if (!options.includes("")) options.unshift(""); + for (const folder of options) { + const option = el("option", "", folder || "(folder)"); + option.value = folder; + select.append(option); + } + select.value = value ?? ""; + return select; +} + +// A small editable table. `columns` describes each cell; rows are plain +// objects and `read()` returns them back as edited. +function table(kind, columns, rows, makeRow) { + const box = el("div", "ausboss-note-table"); + const head = el("div", `ausboss-note-trow ${kind} head`); + for (const column of columns) head.append(el("span", "", column)); + head.append(el("span")); + box.append(head); + const body = el("div", "ausboss-note-table"); + box.append(body); + const entries = []; + const add = (row) => { + const line = el("div", `ausboss-note-trow ${kind}`); + const controls = makeRow(row ?? {}); + for (const control of controls) line.append(control); + const remove = el("button", "ausboss-note-x", "✕"); + remove.type = "button"; + remove.title = "Remove this row"; + const entry = { line, controls }; + remove.addEventListener("click", () => { + line.remove(); + entries.splice(entries.indexOf(entry), 1); + }); + line.append(remove); + body.append(line); + entries.push(entry); + return entry; + }; + for (const row of rows) add(row); + return { box, add, entries }; +} + +async function openEditor(state) { + closeEditor(); + ensureCss(); + const folders = await knownFolders(); + const folderOptions = [...new Set([...COMMON_FOLDERS, ...folders])]; + const draft = normalizeNote(state.note); + + const overlay = el("div", "ausboss-note-overlay"); + const dialog = el("div", "ausboss-note-dialog"); + const head = el("div", "ausboss-note-dhead"); + head.append(el("span", "", "Workflow Note"), el("span", "spacer")); + const jsonToggle = el("button", "ausboss-note-btn", "JSON"); + jsonToggle.type = "button"; + jsonToggle.title = "Edit the raw card JSON instead of the form"; + head.append(jsonToggle); + const body = el("div", "ausboss-note-dbody"); + const foot = el("div", "ausboss-note-dfoot"); + const error = el("span", "ausboss-note-error"); + const cancel = el("button", "ausboss-note-btn", "Cancel"); + cancel.type = "button"; + const save = el("button", "ausboss-note-btn primary", "Save"); + save.type = "button"; + foot.append(error, el("span", "spacer"), cancel, save); + dialog.append(head, body, foot); + overlay.append(dialog); + + // --- form --------------------------------------------------------------- + const form = el("div", "ausboss-note-dbody"); + form.style.padding = "0"; + form.style.overflow = "visible"; + const title = input(draft.title, "Krea 2 Outpaint"); + const subtitle = input(draft.subtitle, "One line on what this workflow does"); + const author = input(draft.author, "your name"); + const accent = input(draft.accent, "#00b4aa"); + let layout = draft.layout; + const seg = el("div", "ausboss-note-seg"); + const segButtons = LAYOUTS.map((option) => { + const button = el("button", option === layout ? "on" : "", option === "card" ? "Card" : "Banner (title only)"); + button.type = "button"; + button.addEventListener("click", () => { + layout = option; + segButtons.forEach((other, index) => other.classList.toggle("on", LAYOUTS[index] === option)); + }); + seg.append(button); + return button; + }); + const grid = el("div", "ausboss-note-grid"); + grid.append( + field("Title", title), + field("Author", author), + field("Subtitle", subtitle), + field("Layout", seg, "Banner shows just the title - a label for a column or stage."), + field("Accent color", accent, "Hex like #00b4aa; empty keeps the pack teal."), + ); + form.append(grid); + + const bodyBox = el("textarea"); + bodyBox.value = draft.body; + bodyBox.placeholder = "## How it works\n\n1. Load your image\n2. Drag the padding edges\n3. Queue\n\nMarkdown: # headings, **bold**, `code`, [links](https://...), - lists"; + form.append(field("How-to (Markdown)", bodyBox)); + + const models = table("models", ["File", "Folder", "Size", "Download URL"], draft.models, (row) => [ + input(row.name, "krea2_turbo_fp8_scaled.safetensors"), + folderSelect(row.dir, folderOptions), + input(row.size, "13.1 GB"), + input(row.url, "https://huggingface.co/.../resolve/main/file.safetensors"), + ]); + const modelTools = el("div"); + const addModel = el("button", "ausboss-note-btn", "+ model"); + addModel.type = "button"; + addModel.addEventListener("click", () => models.add({ dir: "diffusion_models" })); + modelTools.append(addModel); + form.append(field("Models", models.box, "Each row shows a Download button until the file is found in its folder (subfolders count)."), modelTools); + + const packs = table("packs", ["Pack", "URL", "Probe node class"], draft.packs, (row) => [ + input(row.name, "ComfyUI-AusBoss"), + input(row.url, "https://github.com/ausboss/ComfyUI-AusBoss"), + input(row.node, "AUSBOSS_NODES_LoraLoader"), + ]); + const packTools = el("div"); + packTools.style.display = "flex"; + packTools.style.gap = "6px"; + const addPack = el("button", "ausboss-note-btn", "+ pack"); + addPack.type = "button"; + addPack.addEventListener("click", () => packs.add({})); + const detect = el("button", "ausboss-note-btn", "Detect from this workflow"); + detect.type = "button"; + detect.title = "List every custom node pack the open graph uses"; + detect.addEventListener("click", () => { + const types = (app.graph?._nodes ?? app.graph?.nodes ?? []).map((node) => node.type); + const have = new Set(packs.entries.map((entry) => entry.controls[0].value.trim())); + for (const pack of packsFromGraph(types, moduleOfType)) { + if (have.has(pack.name)) continue; + packs.add(pack.name === "ComfyUI-AusBoss" + ? { ...pack, url: "https://github.com/ausboss/ComfyUI-AusBoss" } + : pack); + } + }); + packTools.append(addPack, detect); + form.append(field("Node packs", packs.box, "The probe node class is checked against this install; a missing one marks the pack missing."), packTools); + + const links = table("links", ["Label", "URL"], draft.links, (row) => [ + input(row.label, "GitHub"), + input(row.url, "https://github.com/ausboss"), + ]); + const linkTools = el("div"); + const addLink = el("button", "ausboss-note-btn", "+ link"); + addLink.type = "button"; + addLink.addEventListener("click", () => links.add({})); + linkTools.append(addLink); + form.append(field("Links", links.box, "Your pages: GitHub, Civitai, YouTube, Discord…"), linkTools); + + // --- raw JSON ------------------------------------------------------------- + const jsonBox = el("textarea", "json"); + let showingJson = false; + + const readForm = () => normalizeNote({ + title: title.value, + subtitle: subtitle.value, + author: author.value, + body: bodyBox.value, + layout, + accent: accent.value, + models: models.entries.map(({ controls }) => ({ + name: controls[0].value, dir: controls[1].value, size: controls[2].value, url: controls[3].value, + })), + packs: packs.entries.map(({ controls }) => ({ + name: controls[0].value, url: controls[1].value, node: controls[2].value, + })), + links: links.entries.map(({ controls }) => ({ label: controls[0].value, url: controls[1].value })), + }); + + body.append(form); + jsonToggle.addEventListener("click", () => { + if (!showingJson) { + jsonBox.value = JSON.stringify(readForm(), null, 2); + body.textContent = ""; + body.append(field("Card JSON", jsonBox, "The exact text stored in the workflow. Save applies it.")); + jsonToggle.textContent = "Form"; + } else { + // Going back to the form discards JSON edits that did not parse. + try { + const parsed = normalizeNote(JSON.parse(jsonBox.value)); + Object.assign(draft, parsed); + closeEditor(); + state.note = parsed; + openEditor(state); + return; + } catch { + error.textContent = "That JSON does not parse - fix it or Cancel."; + return; + } + } + showingJson = !showingJson; + }); + + const commit = () => { + let next; + if (showingJson) { + try { + next = normalizeNote(JSON.parse(jsonBox.value)); + } catch { + error.textContent = "That JSON does not parse - fix it or Cancel."; + return; + } + } else next = readForm(); + state.note = next; + state.valueWidget.value = serializeNote(next); + closeEditor(); + render(state); + state.node.setDirtyCanvas?.(true, true); + notifyAusbossChange(); + }; + save.addEventListener("click", commit); + cancel.addEventListener("click", closeEditor); + + const abort = new AbortController(); + // Keys typed into the dialog are the dialog's, not the canvas's shortcuts. + overlay.addEventListener("keydown", (event) => { + event.stopPropagation(); + if (event.key === "Escape") closeEditor(); + if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) commit(); + }, { signal: abort.signal }); + overlay.addEventListener("pointerdown", (event) => { + event.stopPropagation(); + if (event.target === overlay) closeEditor(); + }, { signal: abort.signal }); + overlay.addEventListener("wheel", (event) => event.stopPropagation(), { signal: abort.signal }); + document.body.append(overlay); + openDialog = { element: overlay, abort }; + title.focus(); +} + +// --------------------------------------------------------------------------- +// Node wiring +// --------------------------------------------------------------------------- + +function buildPanel(node) { + if (node.__ausbossNote) return node.__ausbossNote; + ensureCss(); + const widget = node.widgets?.find((item) => item.name === "note"); + if (!widget) return null; + hideWidget(widget); + + const root = el("div", "ausboss-note"); + const card = el("div", "ausboss-note-card"); + const scroll = el("div", "ausboss-note-scroll"); + card.append(scroll); + root.append(card); + // Selecting text or scrolling the card must not read as a node drag; a + // press on empty card space still falls through to the canvas. + scroll.addEventListener("pointerdown", (event) => { + if (event.target !== scroll) event.stopPropagation(); + }); + + const domWidget = node.addDOMWidget(WIDGET_NAME, "ausboss_workflow_note", root, { + serialize: false, + hideOnZoom: false, + getMinHeight: () => CARD_MIN_HEIGHT + WIDGET_FRAME, + }); + keepDomWidgetWidthAuto(domWidget); + fillNodeHeight(domWidget, { + minWidth: PANEL_MIN_WIDTH, + minHeight: () => (domWidget.options.getMinHeight?.() ?? CARD_MIN_HEIGHT + WIDGET_FRAME), + minNodeSize: [PANEL_MIN_WIDTH, 120], + }); + + // valueWidget is the hidden STRING (the storage the editor writes + // through); domWidget is the panel the layout sizes. + const state = (node.__ausbossNote = { + node, domWidget, valueWidget: widget, root, card, scroll, rows: [], + note: normalizeNote(widget.value), + }); + render(state); + node.setSize?.([ + Math.max(node.size?.[0] ?? 0, DEFAULT_SIZE[0]), + Math.max(node.size?.[1] ?? 0, DEFAULT_SIZE[1]), + ]); + return state; +} + +app.registerExtension({ + name: "AusBoss.WorkflowNote", + beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData?.name !== NODE_CLASS) return; + hideInputsInDef(nodeData, ["note"]); + chainCallback(nodeType.prototype, "onNodeCreated", function () { + buildPanel(this); + }); + chainCallback(nodeType.prototype, "onConfigure", function () { + // Saved widget values land after onNodeCreated: re-read them, and + // keep the saved size rather than the default the panel asked for. + queueMicrotask(() => { + const state = buildPanel(this); + if (!state) return; + state.note = normalizeNote(state.valueWidget.value); + render(state); + }); + }); + chainCallback(nodeType.prototype, "getExtraMenuOptions", function (_canvas, options) { + const state = this.__ausbossNote; + if (!state || !Array.isArray(options)) return; + options.unshift({ content: "✎ Edit Workflow Note", callback: () => openEditor(state) }, null); + }); + chainCallback(nodeType.prototype, "refreshComboInNode", function () { + const state = this.__ausbossNote; + if (state) checkModels(state, true); + }); + chainCallback(nodeType.prototype, "onRemoved", function () { + closeEditor(); + this.__ausbossNote = null; + }); + }, +}); diff --git a/nodes/_image_save_helpers.py b/nodes/_image_save_helpers.py index b3cb1a0..c94d600 100644 --- a/nodes/_image_save_helpers.py +++ b/nodes/_image_save_helpers.py @@ -1,13 +1,15 @@ """Naming, policy, and encoding for the AusBoss Save Image node. The naming and collision logic is pure so tests can drive it without a -filesystem; the two encoders (PNG, JPEG XL) sit at the bottom and are the -only functions that touch PIL. +filesystem; the three encoders (PNG, lossless WebP, JPEG XL) sit at the +bottom and are the only functions that touch PIL. """ from __future__ import annotations import json +import re +from datetime import datetime from pathlib import Path, PurePosixPath, PureWindowsPath import numpy as np @@ -22,10 +24,15 @@ IMAGE_EXTENSIONS = { ".png", ".tif", ".tiff", ".webp", } -IMAGE_FORMATS = ("png", "jxl lossless") +IMAGE_FORMATS = ("png", "webp lossless", "jxl lossless") EXISTING_POLICIES = ("overwrite", "skip", "error") -FORMAT_EXTENSIONS = {"png": "png", "jxl lossless": "jxl"} +FORMAT_EXTENSIONS = {"png": "png", "webp lossless": "webp", "jxl lossless": "jxl"} + +# The name modifiers of local mode, in the order they are appended. +NAME_MODIFIERS = ("date", "time", "size", "counter", "batch") +COUNTER_WIDTH = 5 +BATCH_WIDTH = 3 def metadata_disabled() -> bool: @@ -111,6 +118,76 @@ def plan_exact_names(base: str, extension: str, count: int) -> list[str]: return [f"{base}_{index:0{width}}.{extension}" for index in range(1, count + 1)] +def split_prefix(prefix: str) -> tuple[str, str]: + """A sanitized local prefix as (subfolder, basename); '' subfolder at root.""" + clean = sanitize_exact_name(prefix) or "image" + if "/" in clean: + folder, base = clean.rsplit("/", 1) + return folder, base + return "", clean + + +def name_stem(base: str, flags: dict, now: datetime, width: int, height: int) -> str: + """The local filename before its counter and batch tags. + + ``flags`` holds the modifier switches (date, time, size); the parts are + appended in that fixed order so files from one node sort together. + """ + parts = [base] + if flags.get("date"): + parts.append(now.strftime("%Y-%m-%d")) + if flags.get("time"): + parts.append(now.strftime("%H-%M-%S")) + if flags.get("size"): + parts.append(f"{int(width)}x{int(height)}") + return "_".join(parts) + + +def counter_pattern(stem: str) -> re.Pattern: + """Matches ``stem_00042.ext`` and ``stem_00042_b003.ext`` for one stem.""" + return re.compile( + rf"^{re.escape(stem)}_(\d{{{COUNTER_WIDTH},}})(?:_b\d{{{BATCH_WIDTH},}})?\.[A-Za-z0-9]+$" + ) + + +def next_free_counter(existing_names, stem: str) -> int: + """One past the highest counter already used by ``stem`` in a folder.""" + pattern = counter_pattern(stem) + highest = 0 + for name in existing_names: + match = pattern.match(str(name)) + if match: + highest = max(highest, int(match.group(1))) + return highest + 1 + + +def plan_local_names( + stem: str, extension: str, count: int, *, counter: bool, batch: bool, next_counter: int = 1 +) -> list[str]: + """Filenames for a local-mode save of ``count`` images. + + Counter on: the next free number; a batch shares it and adds b001… + when the batch tag is on, otherwise each frame takes the next number. + Counter off: the stem alone (a single image reuses its path on purpose); + a batch without the batch tag still numbers its frames, since frames + cannot share one file. + """ + if count <= 0: + return [] + names = [] + for index in range(count): + parts = [stem] + if counter: + number = next_counter if batch else next_counter + index + parts.append(f"{number:0{COUNTER_WIDTH}}") + if batch and count > 1: + parts.append(f"b{index + 1:0{BATCH_WIDTH}}") + elif not counter and count > 1: + parts.append(f"{index + 1:0{max(3, len(str(count)))}}") + names.append("_".join(parts) + f".{extension}") + return names + + def existing_action(exists: bool, policy: str) -> str: """'write' or 'skip'; the error policy raises so the run stops loudly.""" if policy not in EXISTING_POLICIES: @@ -145,9 +222,10 @@ def _png_info(metadata: dict | None): return info -def _jxl_exif(metadata: dict | None) -> bytes | None: +def _exif_bytes(metadata: dict | None) -> bytes | None: """Workflow JSON in EXIF, mirroring core's animated-webp convention - (prompt in Make, workflow and friends in ImageDescription).""" + (prompt in Make, workflow and friends in ImageDescription). Shared by + the WebP and JPEG XL encoders.""" if not metadata: return None exif = Image.Exif() @@ -166,14 +244,21 @@ def encode_image(path: Path, frame: torch.Tensor, format_name: str, metadata: di if format_name == "png": image.save(path, format="PNG", pnginfo=_png_info(metadata), compress_level=4) return + if format_name == "webp lossless": + kwargs = {"format": "WEBP", "lossless": True, "quality": 100, "method": 4} + exif = _exif_bytes(metadata) + if exif is not None: + kwargs["exif"] = exif + image.save(path, **kwargs) + return if format_name == "jxl lossless": if not jxl_available(): raise RuntimeError( "Save Image: JPEG XL needs the optional pillow-jxl-plugin " - "(pip install pillow-jxl-plugin into ComfyUI's python)." + "(add the pillow-jxl-plugin package to ComfyUI's python)." ) kwargs = {"format": "JXL", "lossless": True} - exif = _jxl_exif(metadata) + exif = _exif_bytes(metadata) if exif is not None: kwargs["exif"] = exif try: @@ -187,18 +272,26 @@ def encode_image(path: Path, frame: torch.Tensor, format_name: str, metadata: di __all__ = [ + "BATCH_WIDTH", + "COUNTER_WIDTH", "EXISTING_POLICIES", "FORMAT_EXTENSIONS", "IMAGE_EXTENSIONS", "IMAGE_FORMATS", + "NAME_MODIFIERS", + "counter_pattern", "encode_image", "existing_action", "frame_to_pil", "jxl_available", "metadata_disabled", + "name_stem", + "next_free_counter", "plan_exact_names", + "plan_local_names", "resolve_output_root", "sanitize_exact_name", "sidecar_path", + "split_prefix", "strip_image_extension", ] diff --git a/nodes/_inpaint_crop_helpers.py b/nodes/_inpaint_crop_helpers.py index cb01a0f..adb01fe 100644 --- a/nodes/_inpaint_crop_helpers.py +++ b/nodes/_inpaint_crop_helpers.py @@ -179,7 +179,7 @@ def _replicate_pad_image( _PYMATTING_HINT = ( "Stitch Inpaint: fix_edge_halo needs the optional 'pymatting' package " - "(pip install pymatting); pasting the edge pixels unchanged." + "(add the pymatting package); pasting the edge pixels unchanged." ) _warned: set[str] = set() @@ -401,11 +401,7 @@ def build_crop( ) canvas_mask[:, top : top + height, left : left + width] = mask - if blend_px > 0: - blend = grow_shrink_mask(canvas_mask, blend_px) - blend = blur_mask(blend, blend_px / 3.0).clamp(0.0, 1.0) - else: - blend = canvas_mask.clone() + blend = stitch_blend_from_mask(canvas_mask, blend_px) cx, cy, cw, ch = crop_to_canvas cropped = canvas[:, cy : cy + ch, cx : cx + cw, :].clone() @@ -437,6 +433,30 @@ def build_crop( return cropped, sampling, stitcher +def stitch_blend_from_mask( + mask: torch.Tensor, blend_pixels: int, grow_pixels: int = 0 +) -> torch.Tensor: + """The paste mask :func:`apply_stitch` blends with, from a generated-area mask. + + ``grow_pixels`` first moves the paste boundary: positive lets the + generation replace a strip of the source next to the seam, negative keeps + a strip of the generated area out. ``blend_pixels`` then ramps the mask + into the kept pixels - grown by that many pixels and blurred - so the + paste fades in instead of ending at a hard cut. Crop For Inpaint and the + padding producers all feather through here, so a stitch looks the same + whichever node built it. + """ + blend = mask + grow = int(grow_pixels) + if grow: + blend = grow_shrink_mask(blend, grow) + ramp = max(0, int(blend_pixels)) + if ramp > 0: + blend = grow_shrink_mask(blend, ramp) + blend = blur_mask(blend, ramp / 3.0) + return blend.clamp(0.0, 1.0) if (grow or ramp) else blend.clone() + + def build_canvas_stitcher( canvas: torch.Tensor, blend: torch.Tensor, @@ -489,20 +509,30 @@ def build_canvas_stitcher( def apply_stitch( - stitcher: dict, inpainted: torch.Tensor, fix_edge_halo: bool = False + stitcher: dict, + inpainted: torch.Tensor, + fix_edge_halo: bool = False, + color_match: float = 0.0, ) -> torch.Tensor: """Blend the inpainted crop back and return the original-size image. Guarantees: pixels where the blend mask is zero are bit-identical to the original image, and passing the crop back unchanged reproduces the original exactly. A stitcher built from a single image legally - broadcasts across an N-frame inpainted batch. + broadcasts across an N-frame inpainted batch, and a stitcher built + from more frames than came back is trimmed to the leading ``N`` + (video models return 8n+1 or 4n+1 frames and drop the tail). ``fix_edge_halo`` swaps the blended-in color for the spread foreground color from :func:`spread_edge_colors`; it never widens the blend, so the zero-weight guarantee is unaffected. Identity round trips are only exact with the toggle off, since the spread deliberately rewrites the feathered band. + + ``color_match`` (0..1) shifts the patch's tone by the offset measured + in the feathered band (:func:`estimate_tone_offset`) before blending, + so an outpaint whose new bands came out a touch lighter or warmer than + the picture lands on the picture's own tone. 0 leaves the patch alone. """ if not isinstance(stitcher, dict) or stitcher.get("kind") != STITCHER_KIND: raise ValueError( @@ -520,11 +550,25 @@ def apply_stitch( out = canvas.clone() elif batch == 1: out = canvas.expand(frames, -1, -1, -1).clone() + elif frames < batch: + # Video models hand back fewer frames than they were given (LTX + # keeps 8n+1, Wan 4n+1) and drop the tail. Paste what came back + # over the matching leading source frames and say so, rather than + # throw away a long run over the count. + print( + f"[AusBoss] Stitch Inpaint: {frames} inpainted frame(s) for a " + f"stitcher built from {batch}; stitching the first {frames} and " + f"dropping the last {batch - frames} source frame(s)." + ) + out = canvas[:frames].clone() + if blend.shape[0] == batch: + blend = blend[:frames] else: raise ValueError( f"Cannot stitch {frames} inpainted frame(s) into a stitcher " - f"built from {batch} image(s); batches must match or the " - "stitcher must come from a single image." + f"built from {batch} image(s); a stitcher built from one image " + "broadcasts, and a longer stitcher is trimmed to the frames " + "that came back, but it cannot invent frames it never had." ) if inpainted.shape[3] != canvas.shape[3]: raise ValueError( @@ -542,6 +586,22 @@ def apply_stitch( patch = _resize_image(patch, cw, ch, stitcher.get("algorithm", "bilinear")) alpha = blend[:, cy : cy + ch, cx : cx + cw].to(out.device) + if color_match > 0: + # Measured against the canvas region the patch lands on; the shift + # reaches every patch pixel but only the blended ones survive, so + # the zero-blend guarantee below is untouched. + bbox = stitcher.get("source_bbox") + if bbox is not None: + # The bbox is in canvas pixels; the crop window is the identity + # for a padded canvas, so it maps straight onto the patch. + bbox = (bbox[0] - cx, bbox[1] - cy, bbox[2] - cx, bbox[3] - cy) + field = tone_offset_field(patch, out[:, cy : cy + ch, cx : cx + cw, :], alpha, bbox) + # Weighted by the blend: a fully generated pixel drifted by the whole + # field, a band pixel the sampler mixed at alpha drifted by alpha of + # it, and the blend below scales the correction by alpha once more. + # Shifting the band by the full field would land it alpha*(1-alpha) + # of the drift below the picture - a faint dark line along the seam. + patch = shift_tone(patch, field * alpha.unsqueeze(-1), min(1.0, float(color_match))) if fix_edge_halo: patch = spread_edge_colors(patch, alpha) weights = alpha.unsqueeze(-1) @@ -555,6 +615,207 @@ def apply_stitch( return out[:, oy : oy + oh, ox : ox + ow, :].contiguous() +def estimate_tone_offset( + inpainted: torch.Tensor, canvas: torch.Tensor, blend: torch.Tensor +) -> torch.Tensor: + """Per-frame LAB mean offset of the inpainted patch against the canvas, + measured where both hold real content: the feathered band (0 < blend + < 1), inside the original picture but partly regenerated. + + The sampler mixes original and generated latent by the mask, so a band + pixel comes out roughly ``blend * generated + (1 - blend) * original``. + Dividing that difference back out is the weighted least-squares + estimate ``sum(blend * (patch - canvas)) / sum(blend**2)`` per channel - + the tone the model drifted by, read off pixels whose true color is + known. Returns [B, 3] in LAB; zeros when there is no band to measure. + """ + from ._color_helpers import rgb_to_lab + + diff = rgb_to_lab(inpainted[..., :3]) - rgb_to_lab(canvas[..., :3]) + band = _band_weights(blend) + weights = band.unsqueeze(-1) + numerator = (weights * diff).sum(dim=(1, 2)) + denominator = (weights * weights).sum(dim=(1, 2)).clamp_min(1e-6) + offset = numerator / denominator + measured = (band.sum(dim=(1, 2)) > 0).unsqueeze(-1) + return torch.where(measured, offset, torch.zeros_like(offset)) + + +def _band_weights(blend: torch.Tensor) -> torch.Tensor: + return torch.where((blend > 0.001) & (blend < 0.999), blend, torch.zeros_like(blend)) + + +def _fill_nearest(values: torch.Tensor, valid: torch.Tensor) -> torch.Tensor: + """values [N, C], valid [N] bool: copy each invalid entry from the + nearest valid one. All-invalid input comes back unchanged.""" + index = torch.nonzero(valid).flatten() + if index.numel() == 0 or index.numel() == valid.numel(): + return values + positions = torch.arange(values.shape[0], device=values.device) + # For every position, the nearest valid index by absolute distance. + distance = (positions.unsqueeze(1) - index.unsqueeze(0)).abs() + nearest = index[distance.argmin(dim=1)] + return values[nearest] + + +def _smooth_lines(curve: torch.Tensor, sigma: float) -> torch.Tensor: + """Gaussian-smooth [B, N, C] along N with reflect padding.""" + length = curve.shape[1] + if sigma <= 0 or length < 3: + return curve + radius = max(1, min(int(3 * sigma), length - 1)) + taps = torch.arange(-radius, radius + 1, dtype=curve.dtype, device=curve.device) + kernel = torch.exp(-(taps * taps) / (2 * sigma * sigma)) + kernel = kernel / kernel.sum() + channels = curve.shape[2] + x = curve.permute(0, 2, 1) # [B, C, N] + x = torch.nn.functional.pad(x, (radius, radius), mode="reflect") + weight = kernel.view(1, 1, -1).expand(channels, 1, -1) + return torch.nn.functional.conv1d(x, weight, groups=channels).permute(0, 2, 1) + + +# How far one line's drift may stray from its side's average, in LAB +# units. A line whose seam holds different content on its two sides (a +# pier post leaving the frame, a boat's reflection under open water) +# would otherwise print its content difference onto the band as a tone +# shift. +LINE_DRIFT_CLAMP = 6.0 + +# The drift is read across the seam: this many generated pixels just +# outside the source against this many original pixels just inside it. +# Inside the feathered band the sampler mixed generated and original +# tone, so the band itself is the one place a clean reading cannot come +# from; the pixels either side of it can. +SEAM_BAND_PX = 24 + +# Softening of the inverse-square distance blend between sides, in +# pixels: at a corner the two nearest sides' curves mix smoothly instead +# of switching on a diagonal. +SEAM_BLEND_SOFT_PX = 8.0 + + +def _finish_lines(lines: torch.Tensor, cover: torch.Tensor) -> torch.Tensor: + """Turn raw per-line drifts [B, N, 3] into a curve fit to print: the + side's own average, plus each line's deviation from it clamped to + LINE_DRIFT_CLAMP, uncovered lines (cover [B, N] <= 0) filled from + their nearest covered neighbour, and the curve smoothed so a single + line cannot print a stripe.""" + weight = (cover > 0).to(lines.dtype).unsqueeze(-1) + side_average = (lines * weight).sum(dim=1, keepdim=True) / weight.sum(dim=1, keepdim=True).clamp_min(1e-6) + lines = side_average + (lines - side_average).clamp(-LINE_DRIFT_CLAMP, LINE_DRIFT_CLAMP) + out = torch.zeros_like(lines) + for b in range(lines.shape[0]): + valid = cover[b] > 0 + filled = torch.where(valid.unsqueeze(-1), lines[b], side_average[b].expand_as(lines[b])) + out[b] = _fill_nearest(filled, valid) if valid.any() else filled + sigma = max(6.0, 0.03 * lines.shape[1]) + return _smooth_lines(out, sigma) + + +def _seam_lines( + outside: torch.Tensor, inside: torch.Tensor, along_rows: bool +) -> tuple[torch.Tensor, torch.Tensor]: + """Per-line drift across one seam: the mean LAB of the generated strip + just outside it minus the mean of the original strip just inside it, + per row (along_rows) or per column. ``outside`` and ``inside`` are the + two strips as [B, H, w, 3] / [B, h, W, 3] slices. Returns the finished + curve [B, N, 3] and its cover [B, N].""" + reduce_dim = 2 if along_rows else 1 + lines = outside.mean(dim=reduce_dim) - inside.mean(dim=reduce_dim) + cover = torch.ones(lines.shape[:2], dtype=lines.dtype, device=lines.device) + return _finish_lines(lines, cover), cover + + +def tone_offset_field( + inpainted: torch.Tensor, + canvas: torch.Tensor, + blend: torch.Tensor, + source_bbox: tuple[int, int, int, int] | None, +) -> torch.Tensor: + """A per-pixel LAB drift field [B, H, W, 3] for the patch. + + With a source rectangle (an outpaint), each padded side's drift is read + ACROSS its seam, line by line - per row for a left or right side, per + column for a top or bottom one: the generated pixels just outside the + source against the original pixels just inside it (SEAM_BAND_PX each). + Sky and water on the same seam drift by different amounts, and one + number for the whole picture leaves one of them showing. Every pixel + then takes an inverse-square-distance blend of the sides' curves, so + where two padded sides meet the correction turns the corner without + a crease. Without a rectangle, or with nothing to measure, the field is + the single global offset. + """ + from ._color_helpers import rgb_to_lab + + lab_patch = rgb_to_lab(inpainted[..., :3]) + lab_canvas = rgb_to_lab(canvas[..., :3]) + batch, height, width = lab_patch.shape[:3] + global_offset = estimate_tone_offset(inpainted, canvas, blend) + field = global_offset.view(batch, 1, 1, 3).expand(batch, height, width, 3).clone() + if source_bbox is None: + return field + x0, y0, x1, y1 = (int(v) for v in source_bbox) + x0, y0 = max(0, x0), max(0, y0) + x1, y1 = min(width, x1), min(height, y1) + if x1 - x0 <= 0 or y1 - y0 <= 0: + return field + device = lab_patch.device + cols = torch.arange(width, device=device, dtype=lab_patch.dtype).view(1, 1, width) + rows = torch.arange(height, device=device, dtype=lab_patch.dtype).view(1, height, 1) + row_index = torch.arange(height, device=device).clamp(y0, y1 - 1) - y0 + col_index = torch.arange(width, device=device).clamp(x0, x1 - 1) - x0 + sides = [] # (distance to the seam [1,H,W], drift field [B,H,W,3]) + + def expand_rows(curve: torch.Tensor) -> torch.Tensor: + # curve [B, y1-y0, 3] -> every row of the canvas, padding rows + # taking the nearest source row's value. + return curve[:, row_index].unsqueeze(2).expand(batch, height, width, 3) + + def expand_cols(curve: torch.Tensor) -> torch.Tensor: + return curve[:, col_index].unsqueeze(1).expand(batch, height, width, 3) + + if x0 > 0: + wo, wi = min(SEAM_BAND_PX, x0), min(SEAM_BAND_PX, x1 - x0) + curve, _ = _seam_lines(lab_patch[:, y0:y1, x0 - wo : x0], lab_canvas[:, y0:y1, x0 : x0 + wi], along_rows=True) + sides.append(((cols - x0).abs().expand(1, height, width), expand_rows(curve))) + if x1 < width: + wo, wi = min(SEAM_BAND_PX, width - x1), min(SEAM_BAND_PX, x1 - x0) + curve, _ = _seam_lines(lab_patch[:, y0:y1, x1 : x1 + wo], lab_canvas[:, y0:y1, x1 - wi : x1], along_rows=True) + sides.append(((cols - (x1 - 1)).abs().expand(1, height, width), expand_rows(curve))) + if y0 > 0: + wo, wi = min(SEAM_BAND_PX, y0), min(SEAM_BAND_PX, y1 - y0) + curve, _ = _seam_lines(lab_patch[:, y0 - wo : y0, x0:x1], lab_canvas[:, y0 : y0 + wi, x0:x1], along_rows=False) + sides.append(((rows - y0).abs().expand(1, height, width), expand_cols(curve))) + if y1 < height: + wo, wi = min(SEAM_BAND_PX, height - y1), min(SEAM_BAND_PX, y1 - y0) + curve, _ = _seam_lines(lab_patch[:, y1 : y1 + wo, x0:x1], lab_canvas[:, y1 - wi : y1, x0:x1], along_rows=False) + sides.append(((rows - (y1 - 1)).abs().expand(1, height, width), expand_cols(curve))) + if not sides: + return field + numerator = torch.zeros_like(field) + denominator = torch.zeros((1, height, width, 1), dtype=field.dtype, device=device) + for distance, side_field in sides: + weight = (1.0 / (distance + SEAM_BLEND_SOFT_PX) ** 2).unsqueeze(-1) + numerator = numerator + weight * side_field + denominator = denominator + weight + return numerator / denominator + + +def shift_tone(image: torch.Tensor, offset: torch.Tensor, strength: float) -> torch.Tensor: + """Subtract ``strength * offset`` from every pixel, in LAB. ``offset`` + is [B, 3] (one shift per frame) or a [B, H, W, 3] field.""" + from ._color_helpers import lab_to_rgb, rgb_to_lab + + if strength <= 0: + return image + lab = rgb_to_lab(image[..., :3]) + shift = offset.view(-1, 1, 1, 3) if offset.dim() == 2 else offset + rgb = lab_to_rgb(lab - shift * float(strength)).clamp(0.0, 1.0) + if image.shape[3] > 3: + return torch.cat([rgb, image[..., 3:]], dim=-1) + return rgb + + def stitch_blend_mask(stitcher: dict, frames: int = 1) -> torch.Tensor: """The feathered blend mask in original-image coordinates (BHW). @@ -587,6 +848,7 @@ __all__ = [ "RESIZE_ALGORITHMS", "STITCHER_KIND", "build_canvas_stitcher", + "stitch_blend_from_mask", "STITCHER_VERSION", "apply_stitch", "build_crop", diff --git a/nodes/_lmstudio_helpers.py b/nodes/_lmstudio_helpers.py deleted file mode 100644 index 841f8c4..0000000 --- a/nodes/_lmstudio_helpers.py +++ /dev/null @@ -1,486 +0,0 @@ -"""LM Studio chat plumbing: URL, payload, image encoding, response parsing. - -Everything except the actual HTTP POST is pure and covered by offline tests. -The server speaks the OpenAI-compatible chat completions API, so this also -works against any endpoint that does (Ollama's compat mode, llama.cpp's -server, a remote box) — LM Studio is just the default address and the error -text's vocabulary. - -Stdlib urllib only: the pack adds no pip dependencies for one POST. -""" - -from __future__ import annotations - -import base64 -import io -import json -import re -import urllib.error -import urllib.request - -import torch - -DEFAULT_ENDPOINT = "http://127.0.0.1:1234/v1" - -_CONNECT_HINT = ( - "could not reach {url} - is the LM Studio server running? Start it from " - "LM Studio's Developer tab (or `lms server start`), and check the port." -) - -# ... blocks from reasoning models (DeepSeek R1 family and -# friends). Pasting them into a prompt is never what a workflow wants, so -# they are split onto their own output. An unclosed block - the model hit -# max_tokens mid-thought - counts as reasoning to the end. Custom tag pairs -# (Granite's <|start_of_thought|>, and friends) compile on demand and cache. -_THINK_PATTERNS: dict[tuple[str, str], re.Pattern] = {} - - -def _think_pattern(open_tag: str, close_tag: str) -> re.Pattern: - key = (open_tag, close_tag) - pattern = _THINK_PATTERNS.get(key) - if pattern is None: - pattern = re.compile( - re.escape(open_tag) + r"(.*?)(?:" + re.escape(close_tag) + r"|\Z)", - re.IGNORECASE | re.DOTALL, - ) - _THINK_PATTERNS[key] = pattern - return pattern - - -def chat_completions_url(endpoint: str) -> str: - """The chat completions URL for whatever spelling of the endpoint we got. - - Accepts a bare host:port, an .../v1 base (LM Studio's own display), or a - full .../chat/completions and normalizes to the full route. No scheme - means http, which is what a local server is.""" - text = str(endpoint or "").strip().rstrip("/") - if not text: - text = DEFAULT_ENDPOINT.rstrip("/") - if "://" not in text: - text = f"http://{text}" - if text.endswith("/chat/completions"): - return text - if text.endswith("/v1"): - return f"{text}/chat/completions" - return f"{text}/v1/chat/completions" - - -def models_url(endpoint: str) -> str: - """The /v1/models URL for whatever spelling of the endpoint we got.""" - text = str(endpoint or "").strip().rstrip("/") - if not text: - text = DEFAULT_ENDPOINT.rstrip("/") - if "://" not in text: - text = f"http://{text}" - if text.endswith("/chat/completions"): - text = text[: -len("/chat/completions")] - if text.endswith("/v1"): - return f"{text}/models" - return f"{text}/v1/models" - - -def list_models(endpoint: str, timeout_seconds: int = 6) -> list[str]: - """Model ids the server reports at /v1/models, sorted. - - Same error vocabulary as the chat call: what failed, at which URL, and - what to do about it.""" - url = models_url(endpoint) - request = urllib.request.Request(url, method="GET") - try: - with urllib.request.urlopen(request, timeout=max(1, int(timeout_seconds))) as reply: - raw = reply.read() - except urllib.error.HTTPError as exc: - raise RuntimeError(f"LM Studio: HTTP {exc.code} from {url}") from None - except (urllib.error.URLError, TimeoutError): - raise RuntimeError("LM Studio: " + _CONNECT_HINT.format(url=url)) from None - try: - data = json.loads(raw.decode("utf-8")) - except Exception: - raise RuntimeError( - f"LM Studio: {url} answered, but not with JSON - check that the " - "endpoint points at an OpenAI-compatible server." - ) from None - rows = data.get("data") if isinstance(data, dict) else data - names = [] - for row in rows if isinstance(rows, list) else []: - if isinstance(row, dict) and row.get("id"): - names.append(str(row["id"])) - elif isinstance(row, str): - names.append(row) - return sorted(set(names)) - - -def register_lmstudio_routes() -> None: - """The endpoint-test / model-list route behind the node's toolbar.""" - try: - import asyncio as _asyncio - - from aiohttp import web - from server import PromptServer - except ImportError: - return - prompt_server = getattr(PromptServer, "instance", None) - if prompt_server is None or getattr(prompt_server, "_ausboss_lmstudio_routes", False): - return - prompt_server._ausboss_lmstudio_routes = True - - @prompt_server.routes.post("/ausboss/lmstudio/models") - async def ausboss_lmstudio_models(request): - try: - payload = await request.json() - except Exception: - payload = {} - endpoint = str(payload.get("endpoint", "") or "") - try: - names = await _asyncio.to_thread(list_models, endpoint) - return web.json_response({"ok": True, "models": names}) - except Exception as exc: - return web.json_response({"ok": False, "error": str(exc)}, status=400) - - -def image_data_url(image: torch.Tensor, max_edge: int = 0) -> str: - """The first frame of a BHWC batch as a base64 PNG data URL. - - ``max_edge`` > 0 downscales so the longest edge fits it - vision models - resize internally anyway, and a smaller upload is dramatically faster to - encode and to prefill. One frame only: a video batch means "describe - this shot", and Select Frame picks which one. - """ - from PIL import Image - - if not isinstance(image, torch.Tensor) or image.ndim != 4: - raise ValueError("LM Studio Chat expected a BHWC IMAGE batch.") - frame = ( - (image[0].detach().clamp(0.0, 1.0) * 255.0).round().to(torch.uint8).cpu().numpy() - ) - if frame.ndim == 3 and frame.shape[-1] >= 3: - picture = Image.fromarray(frame[..., :3], "RGB") - else: - picture = Image.fromarray(frame.reshape(frame.shape[0], frame.shape[1]), "L") - limit = int(max_edge) - if limit > 0 and max(picture.size) > limit: - scale = limit / max(picture.size) - new_size = (max(1, round(picture.width * scale)), max(1, round(picture.height * scale))) - picture = picture.resize(new_size, Image.LANCZOS) - buffer = io.BytesIO() - picture.save(buffer, format="PNG") - encoded = base64.b64encode(buffer.getvalue()).decode("ascii") - return f"data:image/png;base64,{encoded}" - - -def normalize_history(value) -> list[dict]: - """Prior turns as clean [{role, content}] pairs; anything else is []. - - Only user/assistant string turns survive: system prompts belong to the - node's own widget, and image parts are deliberately not replayed - the - tokens they cost per turn dwarf their value as context. - """ - if not isinstance(value, list): - return [] - cleaned: list[dict] = [] - for entry in value: - if not isinstance(entry, dict): - continue - role = str(entry.get("role") or "") - content = entry.get("content") - if role in ("user", "assistant") and isinstance(content, str) and content.strip(): - cleaned.append({"role": role, "content": content}) - return cleaned - - -def history_with_turn(history, prompt: str, had_image: bool, reply: str) -> list[dict]: - """The chat history grown by this exchange, ready for the next node. - - The stored user turn is the text prompt (with a marker when an image - rode along); the assistant turn is the clean reply text - reasoning - blocks are stripped before this is called, so a thinking model's - context stays lean. - """ - turns = normalize_history(history) - text = str(prompt or "").strip() - if had_image: - text = f"{text} [image attached]".strip() - if text: - turns.append({"role": "user", "content": text}) - if str(reply or "").strip(): - turns.append({"role": "assistant", "content": reply}) - return turns - - -def parse_response_schema(raw: str) -> dict | None: - """The response_format body for a JSON-schema string; None when empty. - - The schema must parse to a JSON object; anything else raises with the - fix spelled out. The permissive schema {"type": "object"} is the way - to ask for free-form JSON. - """ - text = str(raw or "").strip() - if not text: - return None - try: - schema = json.loads(text) - except json.JSONDecodeError as exc: - raise ValueError( - f"LM Studio Chat: json_schema is not valid JSON ({exc.msg} at " - f"line {exc.lineno}). Leave it empty for plain text." - ) from None - if not isinstance(schema, dict): - raise ValueError( - "LM Studio Chat: json_schema must be a JSON object like " - '{"type": "object", ...}, not a bare value.' - ) - return { - "type": "json_schema", - "json_schema": {"name": "ausboss_output", "strict": True, "schema": schema}, - } - - -def build_chat_payload( - model: str, - system_prompt: str, - prompt: str, - image_url: str | None, - temperature: float, - max_tokens: int, - seed: int, - advanced: dict | None = None, - history: list[dict] | None = None, - response_format: dict | None = None, -) -> dict: - """The OpenAI-compatible request body. - - An empty model name is omitted so the server answers with whatever it - has loaded - the reason there is no fetched model dropdown: a dropdown - populated at ComfyUI startup goes stale (or empty) whenever LM Studio - was not running yet. max_tokens -1 is omitted, leaving the length to - the server. The seed is always sent; whether the server honors it, a - changed seed re-rolls this node's cached result either way. ``history`` - turns are replayed between the system prompt and the new user message. - """ - messages: list[dict] = [] - system = str(system_prompt or "").strip() - if system: - messages.append({"role": "system", "content": system}) - messages.extend(normalize_history(history)) - text = str(prompt or "").strip() - if image_url: - content: list[dict] = [] - if text: - content.append({"type": "text", "text": text}) - content.append({"type": "image_url", "image_url": {"url": image_url}}) - messages.append({"role": "user", "content": content}) - else: - messages.append({"role": "user", "content": text}) - payload: dict = { - "messages": messages, - "temperature": float(temperature), - "seed": int(seed), - "stream": False, - } - name = str(model or "").strip() - if name: - payload["model"] = name - if int(max_tokens) > 0: - payload["max_tokens"] = int(max_tokens) - if response_format: - payload["response_format"] = response_format - merge_advanced_payload(payload, advanced) - return payload - - -def merge_advanced_payload(payload: dict, advanced: dict | None) -> dict: - """Fold the gear-menu sampling knobs into the request body. - - Values sitting on their neutral defaults are omitted so the everyday - request stays exactly what it always was and the server's own defaults - keep applying. ``thinking_mode`` on/off rides the chat_template_kwargs - convention (honored by Qwen-style templates, ignored elsewhere); - ``idle_unload_seconds`` becomes LM Studio's JIT ``ttl``. - """ - if not advanced: - return payload - top_p = float(advanced.get("top_p", 1.0)) - if top_p < 1.0: - payload["top_p"] = top_p - top_k = int(advanced.get("top_k", 0)) - if top_k > 0: - payload["top_k"] = top_k - repeat_penalty = float(advanced.get("repeat_penalty", 1.0)) - if repeat_penalty != 1.0: - payload["repeat_penalty"] = repeat_penalty - min_p = float(advanced.get("min_p", 0.0)) - if min_p > 0.0: - payload["min_p"] = min_p - presence_penalty = float(advanced.get("presence_penalty", 0.0)) - if presence_penalty != 0.0: - payload["presence_penalty"] = presence_penalty - ttl = int(advanced.get("idle_unload_seconds", 0)) - if ttl > 0: - payload["ttl"] = ttl - mode = str(advanced.get("thinking_mode", "model default")) - if mode in ("on", "off"): - payload["chat_template_kwargs"] = {"enable_thinking": mode == "on"} - return payload - - -def parse_chat_text(data: object) -> str: - """The assistant text out of a chat completions response. - - Tolerates content arriving as a list of typed parts (some compatible - servers do that even for plain text); anything else malformed raises - with the shape it actually saw.""" - if isinstance(data, dict) and isinstance(data.get("error"), (dict, str)): - error = data["error"] - message = error.get("message") if isinstance(error, dict) else error - raise RuntimeError(f"LM Studio: the server reported an error: {message}") - try: - content = data["choices"][0]["message"]["content"] - except (KeyError, IndexError, TypeError): - raise RuntimeError( - "LM Studio: unexpected response shape - no choices[0].message.content. " - "Is the endpoint an OpenAI-compatible chat completions server?" - ) from None - if isinstance(content, list): - content = "".join( - part.get("text", "") for part in content if isinstance(part, dict) - ) - return str(content or "") - - -def parse_reasoning_content(data: object) -> str: - """Reasoning the server returned in its own field, '' when there is none. - - Reasoning models reach us two ways: inline ```` tags inside the - content (handled by split_reasoning), or a sibling ``reasoning_content`` - field, which is what LM Studio sends for gemma/qwen-style hybrids. Without - this the whole answer can land in a field we never read, and the node - hands back a blank string. - """ - try: - message = data["choices"][0]["message"] - except (KeyError, IndexError, TypeError): - return "" - if not isinstance(message, dict): - return "" - for key in ("reasoning_content", "reasoning"): - value = message.get(key) - if isinstance(value, str) and value.strip(): - return value.strip() - return "" - - -def parse_finish_reason(data: object) -> str: - """choices[0].finish_reason, '' when the server did not say.""" - try: - reason = data["choices"][0].get("finish_reason") - except (KeyError, IndexError, TypeError, AttributeError): - return "" - return str(reason or "") - - -def describe_empty_reply(reasoning: str, finish_reason: str, max_tokens: int) -> str: - """Why the answer came back blank, in words the user can act on. - - Returns '' when the reply was not blank-with-reasoning, so callers can - treat a non-empty result as "raise this". - """ - if not reasoning: - return "" - if finish_reason == "length": - budget = f"all {max_tokens} tokens" if max_tokens and max_tokens > 0 else "its whole budget" - return ( - f"LM Studio: the model spent {budget} thinking and never reached an " - "answer, so 'text' is empty (the reasoning is on the 'thinking' " - "output). Raise max_tokens, or set thinking to 'off' in the gear " - "menu for models that support it." - ) - return ( - "LM Studio: the model returned reasoning but no answer, so 'text' is " - "empty (the reasoning is on the 'thinking' output). Try setting " - "thinking to 'off' in the gear menu, or a plainer system prompt." - ) - - -def split_reasoning( - text: str, open_tag: str = "", close_tag: str = "" -) -> tuple[str, str]: - """(answer, reasoning): thinking blocks pulled out of the reply. - - Non-reasoning models pass through untouched with an empty second half. - Blank or mismatched custom tags fall back to the defaults.""" - raw = str(text or "") - opener = str(open_tag or "").strip() or "" - closer = str(close_tag or "").strip() or "" - pattern = _think_pattern(opener, closer) - blocks = [match.strip() for match in pattern.findall(raw) if match.strip()] - answer = pattern.sub("", raw).strip() - return answer, "\n\n".join(blocks) - - -def request_chat(url: str, payload: dict, timeout_seconds: int) -> dict: - """POST the payload and return the parsed JSON response. - - Errors come back as RuntimeError with a message a user can act on - - what failed, at which URL, and the server's own words when it had any. - """ - body = json.dumps(payload).encode("utf-8") - request = urllib.request.Request( - url, - data=body, - headers={"Content-Type": "application/json"}, - method="POST", - ) - try: - with urllib.request.urlopen(request, timeout=max(1, int(timeout_seconds))) as reply: - raw = reply.read() - except urllib.error.HTTPError as exc: - detail = "" - try: - served = json.loads(exc.read().decode("utf-8", "replace")) - error = served.get("error") if isinstance(served, dict) else None - detail = error.get("message") if isinstance(error, dict) else str(error or "") - except Exception: - pass - suffix = f": {detail}" if detail else "" - raise RuntimeError(f"LM Studio: HTTP {exc.code} from {url}{suffix}") from None - except urllib.error.URLError as exc: - reason = getattr(exc, "reason", exc) - if isinstance(reason, TimeoutError) or "timed out" in str(reason).lower(): - raise RuntimeError( - f"LM Studio: no reply from {url} within {timeout_seconds}s - " - "a large model can need a longer timeout for its first token." - ) from None - raise RuntimeError("LM Studio: " + _CONNECT_HINT.format(url=url)) from None - except TimeoutError: - raise RuntimeError( - f"LM Studio: no reply from {url} within {timeout_seconds}s - " - "a large model can need a longer timeout for its first token." - ) from None - try: - return json.loads(raw.decode("utf-8")) - except Exception: - raise RuntimeError( - f"LM Studio: {url} answered, but not with JSON - check that the " - "endpoint points at an OpenAI-compatible server, not a web page." - ) from None - - -__all__ = [ - "DEFAULT_ENDPOINT", - "build_chat_payload", - "chat_completions_url", - "history_with_turn", - "image_data_url", - "list_models", - "merge_advanced_payload", - "models_url", - "normalize_history", - "describe_empty_reply", - "parse_chat_text", - "parse_finish_reason", - "parse_reasoning_content", - "parse_response_schema", - "register_lmstudio_routes", - "request_chat", - "split_reasoning", -] diff --git a/nodes/_lora_helpers.py b/nodes/_lora_helpers.py index ae323c8..cc0a0c4 100644 --- a/nodes/_lora_helpers.py +++ b/nodes/_lora_helpers.py @@ -242,8 +242,9 @@ _missing_warned: set[str] = set() def _warn_missing(row_name: str, reason: str) -> None: """Warn once per LoRA that could not be found anywhere in models/loras. - A missing file skips its row instead of killing the run: the rest of the - stack still applies, and the panel marks the row so the miss is visible. + Only reached in skip mode (on_missing="skip"): the row is dropped, the rest + of the stack still applies, and the panel marks the row so the miss is + visible. The default, "error", stops the run before sampling instead. """ if row_name in _missing_warned: return @@ -254,7 +255,29 @@ def _warn_missing(row_name: str, reason: str) -> None: ) -def apply_lora_stack(model, clip, rows: list[dict[str, Any]]): +MISSING_MODES = ("skip", "error") + + +def missing_lora_rows(rows: list[dict[str, Any]]) -> list[tuple[str, str]]: + """(name, reason) for every enabled, non-zero row whose file does not resolve. + + Shared by validation and execution so a strict stack fails before sampling + with the same message it would otherwise have failed with mid-run. + """ + missing: list[tuple[str, str]] = [] + for row in rows: + if not row.get("enabled", True): + continue + if row.get("strength", 0) == 0 and row.get("strength_clip", 0) == 0: + continue + try: + resolve_lora_path(row["name"]) + except ValueError as exc: + missing.append((row["name"], str(exc))) + return missing + + +def apply_lora_stack(model, clip, rows: list[dict[str, Any]], on_missing: str = "error"): import comfy.sd for row in rows: @@ -266,6 +289,17 @@ def apply_lora_stack(model, clip, rows: list[dict[str, Any]]): try: path = resolve_lora_path(row["name"]) except ValueError as exc: + if on_missing != "skip": + # Strict is the default: rendering without a LoRA the graph + # asked for is worse than not rendering, above all for scripted + # and agent-driven runs that would happily judge the result. + raise ValueError( + f"LoRA Loader: '{row['name']}' - {exc} Stop on missing LoRA is on " + "(the default), so the run stopped instead of rendering without it. " + "Fix or remove the row, or turn 'Stop on missing LoRA' off in the " + "node's settings menu (the gear icon on the node) to warn and skip " + "instead; in API graphs that is on_missing: skip." + ) _warn_missing(row["name"], str(exc)) continue lora_sd = _load_lora_file(path) diff --git a/nodes/_mask_helpers.py b/nodes/_mask_helpers.py index 31e4ea6..5d9af00 100644 --- a/nodes/_mask_helpers.py +++ b/nodes/_mask_helpers.py @@ -123,15 +123,14 @@ EDGE_REFINE_MODES = ("off", "guided filter", "matting") _GUIDED_FILTER_HINT = ( "Mask Refine edge_refine 'guided filter' needs the optional " - "opencv-contrib dependency. Install it into ComfyUI's python with: " - "pip install opencv-contrib-python (the pack's 'guided-filter' " - "optional-dependencies group), then restart ComfyUI." + "opencv-contrib dependency. Add the opencv-contrib-python package to " + "ComfyUI's python (the pack's 'guided-filter' optional-dependencies " + "group), then restart ComfyUI." ) _MATTING_HINT = ( "Mask Refine edge_refine 'matting' needs the optional pymatting " - "dependency. Install it into ComfyUI's python with: pip install " - "pymatting (the pack's 'matting' optional-dependencies group), then " - "restart ComfyUI." + "dependency. Add the pymatting package to ComfyUI's python (the pack's " + "'matting' optional-dependencies group), then restart ComfyUI." ) diff --git a/nodes/_pad_helpers.py b/nodes/_pad_helpers.py index 3ac0306..acdd7a4 100644 --- a/nodes/_pad_helpers.py +++ b/nodes/_pad_helpers.py @@ -266,8 +266,15 @@ def feather_pad_mask( return values if inward else values.flip(0) def merge(y0: int, y1: int, x0: int, x1: int, values: torch.Tensor) -> None: + # Union of the ramps, not their maximum: where two padded sides meet, + # max() creases the iso-lines at 45 degrees from the inner corner + # and any tone difference across the band prints that crease as a + # diagonal line. 1 - (1 - a)(1 - b) is identical along each side + # (the other ramp is 0 there) and bilinear in the corner, so the + # blend has no crease. The padding itself stays exactly 1. region = result[:, y0:y1, x0:x1] - result[:, y0:y1, x0:x1] = torch.maximum(region, values.expand(batch, y1 - y0, x1 - x0)) + ramp = values.expand(batch, y1 - y0, x1 - x0) + result[:, y0:y1, x0:x1] = 1.0 - (1.0 - region) * (1.0 - ramp) x0, x1 = left, left + width y0, y1 = top, top + height diff --git a/nodes/_transform_engine.py b/nodes/_transform_engine.py index 36c8d7c..639383f 100644 --- a/nodes/_transform_engine.py +++ b/nodes/_transform_engine.py @@ -11,6 +11,7 @@ from pathlib import Path from typing import Iterable import numpy as np +from ._execution_helpers import advance_progress, frame_progress, raise_if_interrupted from PIL import Image, ImageFilter import torch @@ -286,6 +287,41 @@ def transform_tensor_batch( return output.to(device=device, dtype=dtype), mask.to(device=device, dtype=dtype), geometry +def transform_tensor_batch_chunked( + image: torch.Tensor, spec: TransformSpec, chunk_size: int = 16 +) -> tuple[torch.Tensor, torch.Tensor, TransformGeometry]: + """transform_tensor_batch over a long batch, one chunk of frames at a time. + + Every chunk goes through the same PIL round trip, but peak memory stays + at the finished output plus one chunk instead of every frame twice, and + the queue's interrupt and progress bar are serviced between chunks - the + video node feeds whole clips through here. + """ + if not isinstance(image, torch.Tensor) or image.ndim != 4: + received = tuple(image.shape) if isinstance(image, torch.Tensor) else type(image).__name__ + raise ValueError(f"Transform: input 'image' expected a BHWC batch, received {received}.") + total = int(image.shape[0]) + size = max(1, int(chunk_size)) + output = mask = geometry = None + progress = frame_progress(total) + for start in range(0, total, size): + raise_if_interrupted() + chunk_output, chunk_mask, chunk_geometry = transform_tensor_batch(image[start : start + size], spec) + if output is None: + geometry = chunk_geometry + output = torch.empty((total, *chunk_output.shape[1:]), dtype=chunk_output.dtype, device=chunk_output.device) + mask = torch.empty((total, *chunk_mask.shape[1:]), dtype=chunk_mask.dtype, device=chunk_mask.device) + elif chunk_output.shape[1:] != output.shape[1:]: + raise ValueError(f"Transform: frame {start} produced dimensions that differ from frame 0.") + count = int(chunk_output.shape[0]) + output[start : start + count] = chunk_output + mask[start : start + count] = chunk_mask + advance_progress(progress, min(start + count, total), total) + if output is None or mask is None or geometry is None: + raise ValueError("Transform: source contained no decodable frames.") + return output, mask, geometry + + def stable_file_fingerprint(path: str | os.PathLike[str], inputs: dict[str, object]) -> str: normalized_path = str(Path(path).resolve()).casefold() try: diff --git a/nodes/node_align_image.py b/nodes/node_align_image.py index 61f9e55..1bfc944 100644 --- a/nodes/node_align_image.py +++ b/nodes/node_align_image.py @@ -121,17 +121,12 @@ class AusBossAlignImage: }, } - RETURN_TYPES = ("IMAGE", "INT", "INT", "INT", "INT") - RETURN_NAMES = ("image", "width", "height", "offset_x", "offset_y") + RETURN_TYPES = ("IMAGE", "INT", "INT") + RETURN_NAMES = ("image", "width", "height") OUTPUT_TOOLTIPS = ( "The aligned batch; both sides are multiples of the chosen number.", "Aligned width in pixels.", "Aligned height in pixels.", - "Where the original's left edge sits in the output: positive after " - "padding, negative after cropping, 0 after a resize — wire into a " - "crop to un-align after sampling.", - "Where the original's top edge sits in the output: positive after " - "padding, negative after cropping, 0 after a resize.", ) FUNCTION = "align" @@ -145,7 +140,9 @@ class AusBossAlignImage: pad_fill="replicate", pad_color="#000000", ): - return align_image( + # The helper also reports where the original landed; the node keeps + # its face to the three outputs a graph actually wires. + aligned, width, height, _offset_x, _offset_y = align_image( image, int(multiple), str(mode), @@ -154,6 +151,7 @@ class AusBossAlignImage: str(pad_fill), pad_color, ) + return (aligned, width, height) NODE_CLASS_MAPPINGS = {"AUSBOSS_NODES_AlignImage": AusBossAlignImage} diff --git a/nodes/node_inpaint_crop_stitch.py b/nodes/node_inpaint_crop_stitch.py index df01d56..5617f0f 100644 --- a/nodes/node_inpaint_crop_stitch.py +++ b/nodes/node_inpaint_crop_stitch.py @@ -324,7 +324,10 @@ class AusBossStitchInpaint: "the stitcher. Pixels outside the blend region are bit-identical to " "the original — they never pass through a resize. A stitcher built " "from one image broadcasts across an inpainted frame batch. Turn on " - "fix_edge_halo when the seam shows a dark or light rim. The " + "fix_edge_halo when the seam shows a dark or light rim; raise " + "color_match when the new region reads lighter or warmer than the " + "picture — it measures the drift in the feathered overlap and " + "shifts the paste onto the original's tone. The " "blend_mask output is the feathered paste mask in the stitched " "image's own coordinates, ready for a downstream color match." ) @@ -369,6 +372,26 @@ class AusBossStitchInpaint: ), }, ), + "color_match": ( + "FLOAT", + { + "default": 0.0, + "min": 0.0, + "max": 1.0, + "step": 0.05, + "tooltip": ( + "Pull the inpainted region's tone onto the " + "original's before pasting. The shift is measured " + "in the feathered band, where the sampler's " + "version and the true pixels overlap, so it " + "reads the model's own drift - the lighter or " + "warmer bands an outpaint often comes back " + "with. 1 applies the full measured shift, 0 is " + "off. Needs a feather (an overlap) to measure; " + "with none it does nothing." + ), + }, + ), }, } @@ -384,8 +407,8 @@ class AusBossStitchInpaint: ) FUNCTION = "stitch" - def stitch(self, stitcher, inpainted, fix_edge_halo=False): - image = apply_stitch(stitcher, inpainted, bool(fix_edge_halo)) + def stitch(self, stitcher, inpainted, fix_edge_halo=False, color_match=0.0): + image = apply_stitch(stitcher, inpainted, bool(fix_edge_halo), float(color_match)) return (image, stitch_blend_mask(stitcher, image.shape[0])) diff --git a/nodes/node_krea2_model_patch.py b/nodes/node_krea2_model_patch.py index dd09e25..4410c48 100644 --- a/nodes/node_krea2_model_patch.py +++ b/nodes/node_krea2_model_patch.py @@ -63,6 +63,25 @@ class AusBossKrea2OutpaintModelPatch: }, ), }, + "optional": { + "placement": ( + ["source rectangle", "whole canvas"], + { + "default": "source rectangle", + "tooltip": ( + "Where the reference tokens are registered. " + "'source rectangle' pins the unpadded source to the " + "rectangle the stitcher reports - the Registered " + "Outpaint LoRA's contract, one padded axis per pass. " + "'whole canvas' spreads the reference over the full " + "frame - for the AnyPaint LoRA, where the reference " + "IS the padded canvas and the sampler's noise mask " + "keeps the known pixels, so every side can be padded " + "at once." + ), + }, + ), + }, } RETURN_TYPES = ("MODEL",) @@ -72,16 +91,18 @@ class AusBossKrea2OutpaintModelPatch: ) FUNCTION = "patch" - def patch(self, model, stitcher, kv_cache=True): + def patch(self, model, stitcher, kv_cache=True, placement="source rectangle"): import comfy.conds from . import _krea2_core as core - complaint = placement_warning(stitcher) - if complaint: - print(complaint) - - bbox_norm = extract_bbox_norm(stitcher) + if placement == "whole canvas": + bbox_norm = [0.0, 0.0, 1.0, 1.0] + else: + complaint = placement_warning(stitcher) + if complaint: + print(complaint) + bbox_norm = extract_bbox_norm(stitcher) patched = model.clone() base_model = patched.model diff --git a/nodes/node_lama_inpaint.py b/nodes/node_lama_inpaint.py index 8c0886e..76f354f 100644 --- a/nodes/node_lama_inpaint.py +++ b/nodes/node_lama_inpaint.py @@ -49,6 +49,18 @@ class AusBossLaMaInpaint: # badge. Hidden entries never become widgets or sockets, so the # visible inputs and the saved widgets_values are unchanged; # declaring UNIQUE_ID does add the node id to the cache signature. + "optional": { + "preview": ( + "BOOLEAN", + { + "default": True, + "tooltip": ( + "Show this node's result on its face. Off skips " + "writing the preview file to ComfyUI's temp folder." + ), + }, + ), + }, "hidden": {"unique_id": "UNIQUE_ID"}, } @@ -62,11 +74,14 @@ class AusBossLaMaInpaint: def __init__(self): self._prefix = temp_prefix("lama_inpaint") - def inpaint(self, image, mask, model, unique_id=None): + def inpaint(self, image, mask, model, unique_id=None, preview=True): # A video inpaint streams each finished frame to the panel as it goes; # this is the still that stays there afterwards, and the only preview a # single-image run ever had - it used to finish with a blank panel. - inpainted = run_lama_inpaint(image, mask, model, node_id=unique_id) + # With the preview off neither the stream nor the still is written. + inpainted = run_lama_inpaint(image, mask, model, node_id=unique_id if preview else None) + if not preview: + return (inpainted,) return preview_payload(inpainted, self._prefix, "LaMa Inpaint", (inpainted,)) @classmethod diff --git a/nodes/node_lmstudio_chat.py b/nodes/node_lmstudio_chat.py deleted file mode 100644 index dde6750..0000000 --- a/nodes/node_lmstudio_chat.py +++ /dev/null @@ -1,419 +0,0 @@ -"""LM Studio Chat 🆎.""" - -from __future__ import annotations - -import asyncio - -from ._lmstudio_helpers import ( - DEFAULT_ENDPOINT, - build_chat_payload, - chat_completions_url, - describe_empty_reply, - history_with_turn, - image_data_url, - parse_chat_text, - parse_finish_reason, - parse_reasoning_content, - parse_response_schema, - register_lmstudio_routes, - request_chat, - split_reasoning, -) - - -class AusBossLmStudioChat: - CATEGORY = "🆎 AusBoss/Text" - DESCRIPTION = ( - "Sends a prompt - and optionally an image - to a local LM Studio " - "server and returns the reply as text. Works with any OpenAI-" - "compatible endpoint. Reasoning-model blocks come out on " - "their own output, so the text output stays clean for wiring into " - "a conditioning prompt. Chain the history output into another chat " - "node's history input for a multi-turn conversation, and fill " - "json_schema to force a structured JSON reply. Caches like any " - "node; change the seed to re-roll the same inputs." - ) - SEARCH_ALIASES = [ - "lm studio", - "llm", - "chat", - "vision", - "caption", - "describe image", - "prompt enhance", - "openai compatible", - "ausboss", - ] - - @classmethod - def INPUT_TYPES(cls): - return { - "required": { - "prompt": ( - "STRING", - { - "multiline": True, - "default": "", - "tooltip": ( - "What to ask. Required unless an image is " - "connected - an image with an empty prompt asks " - "for a plain description." - ), - }, - ), - "system_prompt": ( - "STRING", - { - "multiline": True, - "default": "", - "tooltip": ( - "Optional role and rules for the model, e.g. " - "'You write single-paragraph Stable Diffusion " - "prompts, no preamble.' Empty sends no system " - "message." - ), - }, - ), - "endpoint": ( - "STRING", - { - "default": DEFAULT_ENDPOINT, - "tooltip": ( - "The server's base URL - LM Studio shows it on " - "its Developer tab. A bare host:port or a full " - "/v1 path both work; any OpenAI-compatible " - "server is fine." - ), - }, - ), - "model": ( - "STRING", - { - "default": "", - "tooltip": ( - "Model identifier as LM Studio lists it. Empty " - "uses whatever the server has loaded, which is " - "the everyday case." - ), - }, - ), - "temperature": ( - "FLOAT", - { - "default": 0.7, - "min": 0.0, - "max": 2.0, - "step": 0.05, - "tooltip": ( - "Sampling randomness: 0 is near-deterministic, " - "0.7 balanced, higher wanders. Captioning likes " - "0.2-0.5." - ), - }, - ), - "max_tokens": ( - "INT", - { - "default": 512, - "min": -1, - "max": 131072, - "step": 1, - "tooltip": ( - "Longest allowed reply, in tokens. -1 leaves " - "the length to the server. Reasoning models " - "spend tokens thinking before answering, so " - "give them room." - ), - }, - ), - "seed": ( - "INT", - { - "default": 0, - "min": 0, - "max": 0xFFFFFFFFFFFFFFFF, - "tooltip": ( - "Sent to the server, and either way a changed " - "seed re-runs the node instead of replaying the " - "cached reply - the re-roll knob." - ), - }, - ), - "timeout_seconds": ( - "INT", - { - "default": 120, - "min": 5, - "max": 3600, - "step": 5, - "tooltip": ( - "How long to wait for the reply. Cold-loading a " - "big model or a long think can need minutes." - ), - }, - ), - "image_max_edge": ( - "INT", - { - "default": 1024, - "min": 0, - "max": 8192, - "step": 64, - "tooltip": ( - "Downscale a connected image so its longest edge " - "fits this before sending; 0 sends full size. " - "Vision models resize internally anyway, and a " - "smaller upload prefills much faster." - ), - }, - ), - }, - "optional": { - "image": ( - "IMAGE", - { - "tooltip": ( - "Optional image for vision models. Only the " - "FIRST frame of a batch is sent - pick one with " - "Select Frame 🆎 for video. A text-only " - "model errors server-side if an image arrives." - ) - }, - ), - # Everything below is set from the node's gear menu and - # hidden on the canvas; neutral defaults are omitted from - # the request so the plain node behaves exactly as before. - "top_p": ( - "FLOAT", - { - "default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, - "tooltip": ( - "Nucleus sampling cap; 1 sends nothing and the " - "server default applies." - ), - }, - ), - "top_k": ( - "INT", - { - "default": 0, "min": 0, "max": 1000, "step": 1, - "tooltip": "Top-k sampling cutoff; 0 sends nothing.", - }, - ), - "repeat_penalty": ( - "FLOAT", - { - "default": 1.0, "min": 0.0, "max": 4.0, "step": 0.01, - "tooltip": "Repetition penalty; 1 sends nothing.", - }, - ), - "min_p": ( - "FLOAT", - { - "default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01, - "tooltip": "Minimum token probability; 0 sends nothing.", - }, - ), - "presence_penalty": ( - "FLOAT", - { - "default": 0.0, "min": -2.0, "max": 2.0, "step": 0.01, - "tooltip": "Presence penalty; 0 sends nothing.", - }, - ), - "thinking_mode": ( - ["model default", "on", "off"], - { - "default": "model default", - "tooltip": ( - "Force a hybrid reasoning model to think or not " - "via chat_template_kwargs (Qwen-style templates " - "honor it; others ignore it)." - ), - }, - ), - "reasoning_open_tag": ( - "STRING", - { - "default": "", - "tooltip": ( - "Tag that opens a reasoning block in the reply; " - "the block moves to the thinking output." - ), - }, - ), - "reasoning_close_tag": ( - "STRING", - { - "default": "", - "tooltip": "Tag that closes a reasoning block.", - }, - ), - "idle_unload_seconds": ( - "INT", - { - "default": 0, "min": 0, "max": 86400, "step": 5, - "tooltip": ( - "LM Studio JIT TTL: unload the model after " - "idling this many seconds. 0 sends nothing." - ), - }, - ), - "free_comfy_vram": ( - "BOOLEAN", - { - "default": False, - "tooltip": ( - "Unload ComfyUI's cached models before the " - "request so a big LLM fits alongside a big " - "diffusion model on one GPU." - ), - }, - ), - "unload_llm": ( - "BOOLEAN", - { - "default": False, - "tooltip": ( - "Unload the language model right after the reply " - "(LM Studio JIT ttl) so its VRAM is free for the " - "diffusion models and text encoders that run " - "next. Overrides the gear menu's idle-unload " - "timer." - ), - }, - ), - "history": ( - "AUSBOSS_CHAT_HISTORY", - { - "tooltip": ( - "Prior turns from another LM Studio Chat 🆎 " - "node's history output; they replay before this " - "prompt so the model remembers the conversation. " - "Images are not carried between turns." - ) - }, - ), - "json_schema": ( - "STRING", - { - "multiline": True, - "default": "", - "tooltip": ( - "Paste a JSON Schema to force the reply into " - "that exact structure (LM Studio structured " - "output). {\"type\": \"object\"} allows any " - "JSON; empty replies as plain text." - ), - }, - ), - }, - } - - RETURN_TYPES = ("STRING", "STRING", "AUSBOSS_CHAT_HISTORY") - RETURN_NAMES = ("text", "thinking", "history") - OUTPUT_TOOLTIPS = ( - "The model's reply with any blocks removed - safe to wire " - "straight into a text encoder.", - "The reasoning a thinking model emitted, empty for ordinary models. " - "Useful for a preview node while tuning prompts.", - "The conversation including this exchange - wire into another chat " - "node's history input to continue the thread.", - ) - FUNCTION = "chat" - - async def chat( - self, - prompt, - system_prompt, - endpoint, - model, - temperature, - max_tokens, - seed, - timeout_seconds, - image_max_edge, - image=None, - top_p=1.0, - top_k=0, - repeat_penalty=1.0, - min_p=0.0, - presence_penalty=0.0, - thinking_mode="model default", - reasoning_open_tag="", - reasoning_close_tag="", - idle_unload_seconds=0, - free_comfy_vram=False, - unload_llm=False, - history=None, - json_schema="", - ): - if image is None and not str(prompt).strip(): - raise ValueError( - "LM Studio Chat: type a prompt or connect an image (or both)." - ) - response_format = parse_response_schema(json_schema) - if free_comfy_vram: - try: - import comfy.model_management as model_management - - model_management.unload_all_models() - model_management.soft_empty_cache() - except Exception: - pass # Standalone tests run without ComfyUI; the chat still works. - picture = ( - image_data_url(image, int(image_max_edge)) if image is not None else None - ) - payload = build_chat_payload( - model, - system_prompt, - prompt, - picture, - temperature, - max_tokens, - seed, - advanced={ - "top_p": top_p, - "top_k": top_k, - "repeat_penalty": repeat_penalty, - "min_p": min_p, - "presence_penalty": presence_penalty, - "thinking_mode": thinking_mode, - # The visible unload switch wins over the gear's idle timer. - "idle_unload_seconds": 1 if unload_llm else idle_unload_seconds, - }, - history=history, - response_format=response_format, - ) - url = chat_completions_url(endpoint) - # The POST blocks for as long as the model generates; a worker thread - # keeps the executor's event loop answering the UI meanwhile. - data = await asyncio.to_thread(request_chat, url, payload, int(timeout_seconds)) - text, thinking = split_reasoning( - parse_chat_text(data), str(reasoning_open_tag), str(reasoning_close_tag) - ) - # Reasoning reaches us either inline (split above) or in the server's - # own reasoning_content field; keep both so the thinking output is - # complete whichever way the model reports it. - field_reasoning = parse_reasoning_content(data) - if field_reasoning: - thinking = f"{thinking}\n\n{field_reasoning}".strip() if thinking else field_reasoning - if not text.strip(): - complaint = describe_empty_reply( - thinking, parse_finish_reason(data), int(max_tokens) - ) - if complaint: - raise RuntimeError(complaint) - return ( - text, - thinking, - history_with_turn(history, prompt, image is not None, text), - ) - - -NODE_CLASS_MAPPINGS = {"AUSBOSS_NODES_LmStudioChat": AusBossLmStudioChat} -NODE_DISPLAY_NAME_MAPPINGS = {"AUSBOSS_NODES_LmStudioChat": "LM Studio Chat 🆎"} - -register_lmstudio_routes() - -__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/nodes/node_lora_loader.py b/nodes/node_lora_loader.py index d5edd10..c770d52 100644 --- a/nodes/node_lora_loader.py +++ b/nodes/node_lora_loader.py @@ -5,6 +5,7 @@ from __future__ import annotations from ._lora_helpers import ( apply_lora_stack, collect_trigger_words, + missing_lora_rows, parse_lora_stack, register_lora_routes, stack_fingerprint, @@ -22,8 +23,9 @@ class AusBossLoraLoader: "templates of the whole stack, and a gear-menu action absorbs every " "LoRA loader wired into the model chain - upstream and downstream - " "into this stack and bypasses the originals. Moved LoRA files " - "resolve by name at run time; a missing one warns and skips instead " - "of failing the whole run." + "resolve by name at run time; a genuinely missing one fails validation " + "before anything is sampled and the error names the file - turn the " + "gear menu's Stop on missing LoRA switch off to warn and skip it instead." ) SEARCH_ALIASES = [ "lora", "lora stack", "lora loader", "trigger words", @@ -60,6 +62,21 @@ class AusBossLoraLoader: ), }, ), + "on_missing": ( + ["skip", "error"], + { + "default": "error", + "tooltip": ( + "What happens when an enabled row's file cannot be " + "found. error (the default) fails validation before " + "sampling and names the file, so an unattended or " + "scripted run never renders without a LoRA it asked " + "for; skip warns once in the console and applies the " + "rest of the stack. Set from the node's gear menu " + "(Stop on missing LoRA); hidden on the canvas." + ), + }, + ), }, } @@ -75,20 +92,32 @@ class AusBossLoraLoader: ) FUNCTION = "load_loras" - def load_loras(self, model, loras: str, clip=None, trigger_separator=", "): + def load_loras(self, model, loras: str, clip=None, trigger_separator=", ", on_missing="error"): rows = parse_lora_stack(loras) - model, clip = apply_lora_stack(model, clip, rows) + model, clip = apply_lora_stack(model, clip, rows, on_missing=on_missing) return model, clip, collect_trigger_words(rows, str(trigger_separator)) @classmethod - def VALIDATE_INPUTS(cls, loras, **_values): - # Structural validation only. A row whose FILE is gone must not block - # the queue: apply_lora_stack resolves moved files by basename, and a - # genuinely missing one warns and skips so the rest of the run stands. + def VALIDATE_INPUTS(cls, loras, on_missing="error", **_values): + # Structural validation always. A row whose FILE is gone blocks the + # queue unless the node was switched to 'skip': apply_lora_stack + # resolves moved files by basename either way, so only a genuinely + # missing file gets this far. Old graphs saved without the input get + # the default, which is strict. try: - parse_lora_stack(loras) + rows = parse_lora_stack(loras) except ValueError as exc: return f"LoRA Loader: {exc}" + if on_missing != "skip": + missing = missing_lora_rows(rows) + if missing: + detail = "; ".join(f"'{name}' - {reason}" for name, reason in missing) + return ( + f"LoRA Loader: missing LoRA file, so the run stopped before sampling: {detail} " + "Stop on missing LoRA is on (the default). Fix or remove the row, or turn " + "'Stop on missing LoRA' off in the node's settings menu (the gear icon on " + "the node) to warn and skip instead; in API graphs that is on_missing: skip." + ) return True @classmethod diff --git a/nodes/node_math.py b/nodes/node_math.py index 6795b02..53e1077 100644 --- a/nodes/node_math.py +++ b/nodes/node_math.py @@ -34,33 +34,33 @@ class AusBossMathExpression: }, "optional": { "a": ( - "FLOAT", + "FLOAT,INT", { - "default": 0.0, - "min": -1.0e15, - "max": 1.0e15, - "step": 0.001, - "tooltip": "The value of a in the expression.", + "forceInput": True, + "tooltip": ( + "The value of a in the expression - wire a FLOAT or " + "an INT; unwired reads as 0." + ), }, ), "b": ( - "FLOAT", + "FLOAT,INT", { - "default": 0.0, - "min": -1.0e15, - "max": 1.0e15, - "step": 0.001, - "tooltip": "The value of b in the expression.", + "forceInput": True, + "tooltip": ( + "The value of b in the expression - wire a FLOAT or " + "an INT; unwired reads as 0." + ), }, ), "c": ( - "FLOAT", + "FLOAT,INT", { - "default": 0.0, - "min": -1.0e15, - "max": 1.0e15, - "step": 0.001, - "tooltip": "The value of c in the expression.", + "forceInput": True, + "tooltip": ( + "The value of c in the expression - wire a FLOAT or " + "an INT; unwired reads as 0." + ), }, ), }, diff --git a/nodes/node_refine_mask.py b/nodes/node_refine_mask.py index aaebe01..524388c 100644 --- a/nodes/node_refine_mask.py +++ b/nodes/node_refine_mask.py @@ -107,6 +107,16 @@ class AusBossRefineMask: ), }, "optional": { + "preview": ( + "BOOLEAN", + { + "default": True, + "tooltip": ( + "Show this node's result on its face. Off skips " + "writing the preview file to ComfyUI's temp folder." + ), + }, + ), "guide_image": ( "IMAGE", { @@ -141,6 +151,7 @@ class AusBossRefineMask: white_point, edge_refine, guide_image=None, + preview=True, ): refined, inverted = refine_mask( mask, @@ -157,6 +168,8 @@ class AusBossRefineMask: # whatever fed the mask input, and a segmentation node upstream has no # picture to show, so the panel stayed empty however the mask turned # out - exactly the case where seeing the result matters most. + if not preview: + return (refined, inverted) return preview_payload( mask_to_preview_batch(refined, "Mask Refine"), self._prefix, diff --git a/nodes/node_resolution.py b/nodes/node_resolution.py new file mode 100644 index 0000000..edf22ef --- /dev/null +++ b/nodes/node_resolution.py @@ -0,0 +1,158 @@ +"""Resolution Master 🆎 — pick a canvas size by ratio chips or by stretching it. + +The node face is a DOM panel (js/resolution/index.js): a ratio rail picks +the SHAPE, a live stage lets you stretch the actual rectangle, and the +size ladder offers on-grid rungs — width and height are always the +visible, derived result. This backend is deliberately small: the panel +drives ordinary hidden widgets (the pack's widgets-as-the-single-source- +of-truth rule), so save/load, undo, the API format, and headless runs all +ride LiteGraph's default path — no state blob, no queue-time patching. + +Besides the two INTs it emits an empty LATENT of that size, so the node +replaces the Empty*LatentImage node that used to sit beside it. The latent +family (channel count and downsample) is a hidden combo the panel's gear +sets; the constants are architecture facts, not anyone's model card. +""" + +from __future__ import annotations + +try: # Offline tests import this module without ComfyUI. + import torch + import comfy.model_management as model_management +except ImportError: # pragma: no cover - exercised only outside ComfyUI + torch = None + model_management = None + +DIM_MIN = 64 +DIM_MAX = 8192 + +# label -> (channels, downsample). The label is the combo value, kept +# short so the gear's segmented control fits; the tooltip names families. +LATENT_FAMILIES = { + "16ch": (16, 8), # SD3, Flux 1, Krea 2, Qwen Image, Z-Image + "4ch": (4, 8), # SD 1.5, SDXL + "128ch": (128, 16), # Flux 2 (Klein) +} +DEFAULT_FAMILY = "16ch" + + +def clamp_dimension(value, fallback: int = 1024) -> int: + """A defensive clamp; the panel already snaps, this just refuses junk.""" + try: + number = int(round(float(value))) + except (TypeError, ValueError, OverflowError): + return fallback + return max(DIM_MIN, min(DIM_MAX, number)) + + +def latent_shape(width: int, height: int, family: str, batch_size: int) -> tuple[int, int, int, int]: + """The zeros tensor shape an Empty*Latent node would build for this size.""" + channels, down = LATENT_FAMILIES.get(family, LATENT_FAMILIES[DEFAULT_FAMILY]) + batch = max(1, min(64, int(batch_size) if str(batch_size).lstrip("-").isdigit() else 1)) + return (batch, channels, max(1, height // down), max(1, width // down)) + + +def empty_latent(width: int, height: int, family: str, batch_size: int): + shape = latent_shape(width, height, family, batch_size) + if torch is None: + raise RuntimeError("Resolution latent output requires ComfyUI and PyTorch.") + kwargs = {} + if model_management is not None: + kwargs["device"] = model_management.intermediate_device() + dtype = getattr(model_management, "intermediate_dtype", None) + if callable(dtype): + kwargs["dtype"] = dtype() + return {"samples": torch.zeros(list(shape), **kwargs)} + + +class AusBossResolution: + CATEGORY = "🆎 AusBoss/Utility" + DESCRIPTION = ( + "Canvas size picker: ratio chips pick the shape, a live stage lets " + "you drag the rectangle's edges and corner, and a size ladder offers " + "on-grid rungs for the current ratio — snapped to VAE-friendly " + "multiples with a ratio lock that stays exact AND on-grid. Outputs " + "the width and height as INTs plus an empty LATENT of that size, so " + "it feeds the sampler directly; the latent family (16ch for SD3 / " + "Flux / Krea 2 / Qwen / Z-Image, 4ch for SD1.5 / SDXL, 128ch for " + "Flux 2 Klein) and the batch size live behind the panel's gear." + ) + SEARCH_ALIASES = [ + "resolution", "aspect ratio", "size", "width height", "canvas", + "empty latent", "ausboss", + ] + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "width": ( + "INT", + { + "default": 1024, + "min": DIM_MIN, + "max": DIM_MAX, + "step": 8, + "tooltip": ( + "Output width in pixels. Set from the panel; " + "typed values are never re-snapped." + ), + }, + ), + "height": ( + "INT", + { + "default": 1024, + "min": DIM_MIN, + "max": DIM_MAX, + "step": 8, + "tooltip": ( + "Output height in pixels. Set from the panel; " + "typed values are never re-snapped." + ), + }, + ), + }, + "optional": { + "latent": ( + list(LATENT_FAMILIES), + { + "default": DEFAULT_FAMILY, + "tooltip": ( + "Latent family for the LATENT output. 16ch: SD3, " + "Flux 1, Krea 2, Qwen Image, Z-Image. 4ch: SD 1.5 " + "and SDXL. 128ch: Flux 2 (Klein). Set from the " + "panel's gear." + ), + }, + ), + "batch_size": ( + "INT", + { + "default": 1, + "min": 1, + "max": 64, + "tooltip": "How many latents to make. Set from the panel's gear.", + }, + ), + }, + } + + RETURN_TYPES = ("INT", "INT", "LATENT") + RETURN_NAMES = ("width", "height", "latent") + OUTPUT_TOOLTIPS = ( + "The picked width in pixels.", + "The picked height in pixels.", + "An empty latent of this size in the chosen family — wire it straight " + "into the sampler in place of an Empty*LatentImage node.", + ) + FUNCTION = "resolve" + + def resolve(self, width, height, latent=DEFAULT_FAMILY, batch_size=1): + w = clamp_dimension(width) + h = clamp_dimension(height) + return (w, h, empty_latent(w, h, latent, batch_size)) + + +NODE_CLASS_MAPPINGS = {"AUSBOSS_NODES_Resolution": AusBossResolution} +NODE_DISPLAY_NAME_MAPPINGS = {"AUSBOSS_NODES_Resolution": "Resolution Master 🆎"} diff --git a/nodes/node_run_timer.py b/nodes/node_run_timer.py new file mode 100644 index 0000000..6336d73 --- /dev/null +++ b/nodes/node_run_timer.py @@ -0,0 +1,34 @@ +"""Run Timer 🆎 — how long the last queue took, on the canvas.""" + +from __future__ import annotations + + +class AusBossRunTimer: + CATEGORY = "🆎 AusBoss/Utility" + DESCRIPTION = ( + "A stopwatch for the whole run. It starts when a queued prompt " + "begins executing, ticks on the node while it runs, and holds the " + "total when it finishes — with the previous few runs kept underneath " + "so a settings change can be judged against the last one. The times " + "are saved with the workflow, so a shared graph carries its author's " + "run time as a reference. No inputs, no outputs; it listens to the " + "queue rather than sitting on a wire." + ) + SEARCH_ALIASES = ["timer", "run time", "stopwatch", "elapsed", "benchmark", "ausboss"] + + @classmethod + def INPUT_TYPES(cls): + return {"required": {}} + + RETURN_TYPES = () + FUNCTION = "noop" + + def noop(self): + # Never reached: with no outputs the node is not part of any prompt. + return () + + +NODE_CLASS_MAPPINGS = {"AUSBOSS_NODES_RunTimer": AusBossRunTimer} +NODE_DISPLAY_NAME_MAPPINGS = {"AUSBOSS_NODES_RunTimer": "Run Timer 🆎"} + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/nodes/node_save_image.py b/nodes/node_save_image.py index 67bf66e..2aae933 100644 --- a/nodes/node_save_image.py +++ b/nodes/node_save_image.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import datetime from pathlib import Path from ._image_save_helpers import ( @@ -11,10 +12,14 @@ from ._image_save_helpers import ( encode_image, existing_action, metadata_disabled, + name_stem, + next_free_counter, plan_exact_names, + plan_local_names, resolve_output_root, sanitize_exact_name, sidecar_path, + split_prefix, strip_image_extension, ) from ._video_save_helpers import workflow_metadata @@ -25,24 +30,60 @@ except ImportError: # offline tests folder_paths = None +def _register_folder_route() -> None: + """GET /ausboss/save_image/folders?path= lists the subfolders + under ComfyUI's output folder, for the node's Browse button. Paths are + attacker-controlled: anything that resolves outside the output folder + answers 400, and nothing outside it is ever listed.""" + try: + from aiohttp import web + from server import PromptServer + except Exception: + return + if getattr(PromptServer.instance, "_ausboss_save_image_folders", False): + return + PromptServer.instance._ausboss_save_image_folders = True + + @PromptServer.instance.routes.get("/ausboss/save_image/folders") + async def list_folders(request): + root = Path(folder_paths.get_output_directory()).resolve() + relative = str(request.rel_url.query.get("path", "")).strip().replace("\\", "/") + try: + target = (root / relative).resolve() if relative else root + except Exception: + return web.json_response({"error": "bad path"}, status=400) + if target != root and root not in target.parents: + return web.json_response({"error": "outside the output folder"}, status=400) + if not target.is_dir(): + return web.json_response({"error": "not a folder"}, status=404) + folders = sorted( + entry.name for entry in target.iterdir() + if entry.is_dir() and not entry.name.startswith(".") + ) + shown = "" if target == root else target.relative_to(root).as_posix() + return web.json_response({"path": shown, "folders": folders}) + + class AusBossSaveImage: CATEGORY = "🆎 AusBoss/Image" DESCRIPTION = ( - "Saves an IMAGE batch as PNG or lossless JPEG XL, with the workflow " - "embedded or deliberately left out. exact_name saves under exactly " - "that filename — no counter suffix — so an edit or caption pass can " - "keep the source file's name; a non-empty caption writes a .txt " - "sidecar with the same basename, the dataset-captioning pair. " - "Leave exact_name empty for classic prefix_00001_ saving that " - "never overwrites." + "Saves an IMAGE batch as PNG, lossless WebP or lossless JPEG XL, with " + "the workflow embedded or deliberately left out. The name is either " + "composed here - a prefix plus the tags you switch on (date, time, " + "size, a collision-safe counter, a batch number) - or supplied " + "exactly by the filename input, so an edit or caption pass keeps the " + "source file's name. A non-empty caption writes a .txt sidecar with " + "the same basename, the dataset-captioning pair." ) SEARCH_ALIASES = [ "save image", "png", + "webp", "jpeg xl", "jxl", "exact filename", "caption", + "dataset", "ausboss", ] @@ -59,10 +100,10 @@ class AusBossSaveImage: { "default": "AusBoss/image", "tooltip": ( - "Classic naming: prefix plus a counter that " - "never overwrites, in ComfyUI's output folder. " - "Subfolders are allowed. Ignored while " - "exact_name is set." + "The local filename, with optional subfolders: the " + "name tags (date, time, size, counter, batch) are " + "appended to it. Ignored while filename is linked " + "or exact_name is set." ), }, ), @@ -71,10 +112,11 @@ class AusBossSaveImage: { "default": "png", "tooltip": ( - "png saves everywhere. jxl lossless keeps every " - "pixel bit-identical in a smaller file; it needs " - "the optional pillow-jxl-plugin in ComfyUI's " - "python and says so if it is missing." + "png saves everywhere and preserves alpha. webp " + "lossless is usually smaller than png, alpha kept. " + "jxl lossless is the most compact; it needs the " + "optional pillow-jxl-plugin in ComfyUI's python and " + "few browsers preview it." ), }, ), @@ -84,10 +126,9 @@ class AusBossSaveImage: "default": True, "tooltip": ( "On embeds the prompt and workflow (PNG text " - "chunks; EXIF in jxl), so the file drags back " - "into ComfyUI. Off writes a clean file with no " - "workflow inside — for sharing without " - "shipping the recipe." + "chunks; EXIF in webp and jxl), so the file drags " + "back into ComfyUI. Off writes a clean file with no " + "workflow inside - for sharing or datasets." ), }, ), @@ -98,14 +139,12 @@ class AusBossSaveImage: { "default": "", "tooltip": ( - "Save under exactly this filename — no counter " - "suffix. An image extension on the value is " - "replaced by the chosen format's own, so " - "'photo123.jpg' saves as photo123.png and " - "pairs with photo123.txt. Subfolders are " - "allowed; a batch appends _001, _002… since " - "frames cannot share one name. Empty uses " - "filename_prefix instead." + "Save under exactly this filename - no tags. An " + "image extension on the value is replaced by the " + "chosen format's own. Subfolders are allowed; a " + "batch appends _001, _002... Empty composes the " + "name from filename_prefix instead. A linked " + "filename input takes precedence." ), }, ), @@ -114,11 +153,11 @@ class AusBossSaveImage: { "default": "overwrite", "tooltip": ( - "What an exact_name save does when the file is " - "already there: overwrite replaces it (the " - "usual reason to want an exact name), skip " - "leaves it and moves on, error stops the run. " - "Classic prefix saving never collides." + "What a save that lands on an existing file does: " + "overwrite replaces it, skip leaves it, error stops " + "the run. Only reachable with the counter off, an " + "exact name, or a linked filename - the counter " + "never collides." ), }, ), @@ -127,12 +166,10 @@ class AusBossSaveImage: { "default": "", "tooltip": ( - "Where to save. Empty is ComfyUI's output " - "folder; a relative path is a subfolder of it; " - "an absolute path saves anywhere you can write " - "— e.g. straight into a dataset folder. The " - "node preview only shows files inside the " - "output folder." + "Where to save. Empty is ComfyUI's output folder; " + "a relative path is a subfolder of it; an absolute " + "path saves anywhere you can write. The node " + "preview only shows files inside the output folder." ), }, ), @@ -142,10 +179,71 @@ class AusBossSaveImage: "default": "", "multiline": True, "tooltip": ( - "When not empty, writes this text as a UTF-8 " - ".txt sidecar with the same basename as each " - "saved image — the image/caption pair training " - "tools expect. Empty writes no sidecar." + "When not empty, writes this text as a UTF-8 .txt " + "sidecar with the same basename as each saved " + "image. The caption_text input takes precedence." + ), + }, + ), + "filename": ( + "STRING", + { + "forceInput": True, + "tooltip": ( + "Exact filename from upstream (a Load Image name, " + "a caption tool's id). Its image extension is " + "replaced by the chosen format's; no tags are " + "added; a batch appends _001, _002... A linked but " + "empty value stops the run rather than saving " + "under a made-up name." + ), + }, + ), + "caption_text": ( + "STRING", + { + "forceInput": True, + "tooltip": ( + "Caption from upstream: saved as a .txt in the same " + "folder with the same name as the image (portrait.png " + "-> portrait.txt). Empty writes no sidecar." + ), + }, + ), + "name_counter": ( + "BOOLEAN", + { + "default": True, + "tooltip": ( + "Append the next free five-digit number. On is " + "collision-safe; off reuses the same path and " + "replaces an existing file." + ), + }, + ), + "name_date": ( + "BOOLEAN", + {"default": False, "tooltip": "Append the local save date as YYYY-MM-DD."}, + ), + "name_time": ( + "BOOLEAN", + {"default": False, "tooltip": "Append the local save time as HH-mm-ss."}, + ), + "name_size": ( + "BOOLEAN", + { + "default": False, + "tooltip": "Append the image dimensions as WIDTHxHEIGHT, such as 1024x1024.", + }, + ), + "name_batch": ( + "BOOLEAN", + { + "default": False, + "tooltip": ( + "For an image batch, append b001, b002... so each " + "image's position in this run is visible. A single " + "image is unchanged." ), }, ), @@ -158,7 +256,7 @@ class AusBossSaveImage: OUTPUT_TOOLTIPS = ( "Absolute path of the first file this run saved (empty when every " "file was skipped), for downstream nodes that want the file itself.", - "The input batch, unchanged — save first, keep wiring.", + "The input batch, unchanged - save first, keep wiring.", ) OUTPUT_NODE = True FUNCTION = "save" @@ -173,6 +271,13 @@ class AusBossSaveImage: on_existing="overwrite", output_dir="", caption="", + filename=None, + caption_text=None, + name_counter=True, + name_date=False, + name_time=False, + name_size=False, + name_batch=False, prompt=None, extra_pnginfo=None, ): @@ -182,56 +287,82 @@ class AusBossSaveImage: # --disable-metadata is the server owner's call and beats the widget. embed = save_metadata and not metadata_disabled() metadata = workflow_metadata(prompt, extra_pnginfo) if embed else None - caption_text = str(caption or "") + # A linked caption wins over the legacy text box; blank means no sidecar. + linked_caption = str(caption_text or "") if caption_text is not None else "" + caption_value = linked_caption if linked_caption.strip() else str(caption or "") output_root = Path(folder_paths.get_output_directory()) + root = resolve_output_root(output_dir, output_root) count = int(images.shape[0]) + height, width = int(images.shape[1]), int(images.shape[2]) - exact = sanitize_exact_name(exact_name) - saved: list[Path] = [] - previews: list[dict] = [] - if exact: - root = resolve_output_root(output_dir, output_root) - base = strip_image_extension(exact) - for name, frame in zip(plan_exact_names(base, extension, count), images): - path = root / name - if existing_action(path.exists(), on_existing) == "skip": - continue - path.parent.mkdir(parents=True, exist_ok=True) - encode_image(path, frame, format, metadata) - if caption_text.strip(): - sidecar_path(path).write_text(caption_text, encoding="utf-8") - saved.append(path) - else: - # Classic mode: core's counter naming, never a collision. A - # custom output_dir still applies by re-rooting the prefix walk. - root = resolve_output_root(output_dir, output_root) - full_output_folder, filename, counter, subfolder, filename_prefix = ( - folder_paths.get_save_image_path( - filename_prefix, str(root), int(images.shape[2]), int(images.shape[1]) + # Exact naming: the linked filename first, then the legacy widget. + if filename is not None: + text = str(filename).strip() + if not text: + raise ValueError( + "Save Image: the linked filename is empty. Feed it a name " + "or unlink it to compose the name here." ) + exact = sanitize_exact_name(text) + else: + exact = sanitize_exact_name(exact_name) + + planned: list[tuple[Path, str]] = [] # (path, collision policy) + if exact: + base = strip_image_extension(exact) + for name in plan_exact_names(base, extension, count): + planned.append((root / name, on_existing)) + else: + subfolder, base = split_prefix(filename_prefix) + folder = root / subfolder if subfolder else root + stem = name_stem( + base, + {"date": bool(name_date), "time": bool(name_time), "size": bool(name_size)}, + datetime.now(), + width, + height, ) - for index, frame in enumerate(images): - file = f"{filename}_{counter + index:05}_.{extension}" - path = Path(full_output_folder) / file - encode_image(path, frame, format, metadata) - if caption_text.strip(): - sidecar_path(path).write_text(caption_text, encoding="utf-8") - saved.append(path) + counter = bool(name_counter) + existing = [entry.name for entry in folder.iterdir()] if folder.is_dir() else [] + names = plan_local_names( + stem, extension, count, counter=counter, batch=bool(name_batch), + next_counter=next_free_counter(existing, stem), + ) + # The counter is collision-safe by construction; without it the + # legacy policy decides, and its default deliberately overwrites. + policy = "overwrite" if counter else on_existing + planned.extend((folder / name, policy) for name in names) + + saved: list[Path] = [] + for (path, policy), frame in zip(planned, images): + if existing_action(path.exists(), policy) == "skip": + continue + path.parent.mkdir(parents=True, exist_ok=True) + encode_image(path, frame, format, metadata) + if caption_value.strip(): + sidecar_path(path).write_text(caption_value, encoding="utf-8") + saved.append(path) # The frontend can only serve previews from inside the output # folder; anything saved elsewhere is reported by path instead. + previews: list[dict] = [] resolved_root = output_root.resolve() + shown_paths: list[str] = [] for path in saved: resolved = path.resolve() - if resolved_root == resolved.parent or resolved_root in resolved.parents: + inside = resolved_root == resolved.parent or resolved_root in resolved.parents + if inside: relative = resolved.parent.relative_to(resolved_root) previews.append({ "filename": resolved.name, "subfolder": "" if relative == Path(".") else str(relative), "type": "output", }) + shown_paths.append(resolved.relative_to(resolved_root).as_posix()) + else: + shown_paths.append(str(resolved)) return { - "ui": {"images": previews}, + "ui": {"images": previews, "ausboss_saved_path": shown_paths[:1]}, "result": (str(saved[0]) if saved else "", images), } @@ -242,16 +373,19 @@ class AusBossSaveImage: return float("nan") @classmethod - def VALIDATE_INPUTS(cls, exact_name="", **_values): - # Surface a bad exact name before the run instead of mid-save. - if isinstance(exact_name, str) and exact_name.strip(): - try: - sanitize_exact_name(exact_name) - except ValueError as exc: - return str(exc) + def VALIDATE_INPUTS(cls, exact_name="", filename_prefix="", **_values): + # Surface a bad name before the run instead of mid-save. + for label, value in (("exact_name", exact_name), ("filename_prefix", filename_prefix)): + if isinstance(value, str) and value.strip(): + try: + sanitize_exact_name(value) + except ValueError as exc: + return str(exc).replace("exact_name", label) return True +_register_folder_route() + NODE_CLASS_MAPPINGS = {"AUSBOSS_NODES_SaveImage": AusBossSaveImage} NODE_DISPLAY_NAME_MAPPINGS = {"AUSBOSS_NODES_SaveImage": "Save Image 🆎"} diff --git a/nodes/node_seed.py b/nodes/node_seed.py new file mode 100644 index 0000000..978a3bd --- /dev/null +++ b/nodes/node_seed.py @@ -0,0 +1,58 @@ +"""Seed 🆎 — one seed on its own wire, with a memory of what actually ran.""" + +from __future__ import annotations + +MAX_SEED = 0xFFFFFFFFFFFFFFFF + + +class AusBossSeed: + CATEGORY = "🆎 AusBoss/Utility" + DESCRIPTION = ( + "A seed on its own INT wire, so several samplers share one number " + "and a shared workflow can pin the exact seed that made its example. " + "The control under the value works like a sampler's: fixed, " + "increment, decrement or randomize after each queue. Because " + "randomize replaces the value the moment a run is queued, the node " + "reports the seed it actually ran with — the row under the box shows " + "it, ↺ puts it back in the box and pins it, and the history menu " + "keeps the last few so a good result is never lost to the next click." + ) + SEARCH_ALIASES = ["seed", "random seed", "noise seed", "last seed", "ausboss"] + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "seed": ( + "INT", + { + "default": 0, + "min": 0, + "max": MAX_SEED, + "control_after_generate": True, + "tooltip": ( + "The seed sent down the wire. The control beneath " + "it decides what happens after each queue: fixed " + "keeps it, randomize picks a new one." + ), + }, + ), + }, + } + + RETURN_TYPES = ("INT",) + RETURN_NAMES = ("seed",) + OUTPUT_TOOLTIPS = ("The seed, unchanged — wire it to every sampler that should share it.",) + FUNCTION = "emit" + + def emit(self, seed): + # The ui payload is what the frontend records as "last used": the + # value this run was queued with, even if randomize has already + # moved the widget on to the next one. + return {"ui": {"seed": [seed]}, "result": (seed,)} + + +NODE_CLASS_MAPPINGS = {"AUSBOSS_NODES_Seed": AusBossSeed} +NODE_DISPLAY_NAME_MAPPINGS = {"AUSBOSS_NODES_Seed": "Seed 🆎"} + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/nodes/node_select_frame.py b/nodes/node_select_frame.py index 8c0e6b0..b9a1459 100644 --- a/nodes/node_select_frame.py +++ b/nodes/node_select_frame.py @@ -44,7 +44,19 @@ class AusBossSelectFrame: ), }, ), - } + }, + "optional": { + "preview": ( + "BOOLEAN", + { + "default": True, + "tooltip": ( + "Show the selected frame on the node. Off skips " + "writing the preview file to ComfyUI's temp folder." + ), + }, + ), + }, } RETURN_TYPES = ("IMAGE",) @@ -55,8 +67,10 @@ class AusBossSelectFrame: def __init__(self): self._prefix = temp_prefix("select_frame") - def select_frame(self, frames, frame_number): + def select_frame(self, frames, frame_number, preview=True): selected = select_one_based_frame(frames, frame_number) + if not preview: + return (selected,) return preview_payload(selected, self._prefix, "Select Frame", (selected,)) diff --git a/nodes/node_video_crop_rotate_pad_clip.py b/nodes/node_video_crop_rotate_pad_clip.py new file mode 100644 index 0000000..99ca170 --- /dev/null +++ b/nodes/node_video_crop_rotate_pad_clip.py @@ -0,0 +1,367 @@ +"""Video Crop + Rotate + Pad -> Clip 🆎.""" + +from __future__ import annotations + +import asyncio + +from ._inpaint_crop_helpers import build_canvas_stitcher, stitch_blend_from_mask +from ._media_helpers import list_input_videos, register_video_routes, resolve_video_path +from ._transform_engine import ( + resize_batch_to_megapixels, + stable_file_fingerprint, + transform_tensor_batch_chunked, +) +from ._transform_inputs import resize_inputs, spec_from_values, transform_inputs +from ._video_load_helpers import decode_video_range, lazy_audio_range + + +FRAME_SNAP_RULES = ("free", "8n+1", "4n+1") + + +def snap_frame_count(count: int, rule: str) -> int: + """The largest ``step*n + 1`` count not above ``count`` for a rule like + ``"8n+1"``; ``"free"`` (or anything unparseable) keeps ``count``. + + Video models keep only such counts - LTX 8n+1, Wan 4n+1 - and drop the + tail, so trimming here keeps frames, audio, and duration honest and the + stitcher the same length as the clip that comes back. + """ + count = max(0, int(count)) + text = str(rule or "free").strip().lower() + if not text.endswith("n+1"): + return count + try: + step = int(text[: -len("n+1")]) + except ValueError: + return count + if step <= 0 or count <= 1: + return count + return ((count - 1) // step) * step + 1 + + +def clip_stitcher(frames, mask, geometry, blend_pixels: int, grow_pixels: int = 0) -> dict: + """A full-frame stitcher for the finished clip. + + The generated clip is the whole canvas, so the crop is the identity + rectangle and the paste mask is the transform's generated-area mask - + padding and rotation voids - ramped by the stitch settings. It is built + from the final frames, after any resize, so the paste lines up with what + the sampler actually returns; the source bbox rides along scaled the + same way. + """ + blend = stitch_blend_from_mask(mask, blend_pixels, grow_pixels) + scale_x = frames.shape[2] / float(geometry.output_width) + scale_y = frames.shape[1] / float(geometry.output_height) + bbox = ( + int(round(geometry.pad_left * scale_x)), + int(round(geometry.pad_top * scale_y)), + int(round((geometry.pad_left + geometry.crop_width) * scale_x)), + int(round((geometry.pad_top + geometry.crop_height) * scale_y)), + ) + return build_canvas_stitcher(frames, blend, bbox=bbox) + + +class AusBossVideoCropRotatePadClip: + CATEGORY = "🆎 AusBoss/Video" + DESCRIPTION = ( + "Loads a trimmed video and applies one rotate, crop, and pad transform " + "to every frame: cut a border or a corner off, straighten a tilt, or " + "grow fill-color bands for a video outpaint. Outputs the frames, the " + "mask of the generated area (rotation corners and padding), the audio " + "for the same window, the frame count, fps, size, and duration, and a " + "stitcher so Stitch Inpaint can paste the source frames back over the " + "generated clip. The editor's timeline only chooses the preview " + "frame; the whole start/end window is processed." + ) + SEARCH_ALIASES = [ + "video crop", + "crop video", + "rotate video", + "pad video", + "video outpaint", + "trim video", + "straighten video", + "ausboss", + ] + + @classmethod + def INPUT_TYPES(cls): + required = { + "video": ( + list_input_videos(), + {"tooltip": "Choose a video in ComfyUI's input folder or use the upload button."}, + ), + "source_mode": ( + ["input folder", "local path"], + {"default": "input folder", "tooltip": "Local path mode avoids copying large videos."}, + ), + "local_path": ( + "STRING", + { + "default": "", + "tooltip": ( + "Absolute local video path used only in local path mode. Queued runs " + "always read it; editor previews need AUSBOSS_TRANSFORM_LOCAL_PREVIEW=1." + ), + }, + ), + "start_seconds": ( + "FLOAT", + { + "default": 0.0, + "min": 0.0, + "max": 86400.0, + "step": 0.01, + "tooltip": "Skip everything before this time.", + }, + ), + "end_seconds": ( + "FLOAT", + { + "default": 0.0, + "min": 0.0, + "max": 86400.0, + "step": 0.01, + "tooltip": "Stop at this time; 0 runs to the end of the video.", + }, + ), + "seek_mode": ( + ["frame index", "time seconds"], + { + "default": "frame index", + "tooltip": ( + "Editor preview position only: frame_index or frame_time picks the " + "frame the editor shows. The output covers the whole trim window." + ), + }, + ), + "frame_index": ( + "INT", + { + "default": 0, + "min": 0, + "max": 100000000, + "step": 1, + "tooltip": "Zero-based preview frame for the editor; does not affect the output.", + }, + ), + "frame_time": ( + "FLOAT", + { + "default": 0.0, + "min": 0.0, + "max": 86400.0, + "step": 0.001, + "tooltip": "Preview seconds for the editor in time mode; does not affect the output.", + }, + ), + } + required.update(transform_inputs()) + required.update(resize_inputs()) + optional = { + "every_nth": ( + "INT", + { + "default": 1, + "min": 1, + "max": 512, + "step": 1, + "tooltip": ( + "Keep one frame in this many; 2 halves the frame count and the " + "fps output divides to match, so the clip still plays at real speed." + ), + }, + ), + "max_frames": ( + "INT", + { + "default": 0, + "min": 0, + "max": 100000, + "step": 1, + "tooltip": ( + "Stop after this many kept frames; 0 processes the whole trim " + "window. Video models want 8n+1 counts: 49, 97, 121." + ), + }, + ), + "frame_snap": ( + list(FRAME_SNAP_RULES), + { + "default": "free", + "tooltip": ( + "Drop trailing frames so the count is one a video model " + "keeps: 8n+1 for LTX (49, 97, 121), 4n+1 for Wan. Free keeps " + "every frame in the window." + ), + }, + ), + "stitch_blend": ( + "INT", + { + "default": 32, + "min": 0, + "max": 512, + "step": 1, + "tooltip": ( + "Ramp of the stitcher's paste, in pixels, into the kept " + "frames: where generated pixels fade over the source. " + "Separate from feather, which shapes the mask itself." + ), + }, + ), + "stitch_grow": ( + "INT", + { + "default": 0, + "min": -256, + "max": 256, + "step": 1, + "tooltip": ( + "Moves the paste boundary before the ramp: positive lets " + "the generation replace a strip of the source next to " + "the seam, negative keeps more of the source." + ), + }, + ), + } + return {"required": required, "optional": optional} + + # Appended outputs only: saved workflows address these by index. + RETURN_TYPES = ("IMAGE", "MASK", "AUDIO", "INT", "FLOAT", "INT", "INT", "FLOAT", "AUSBOSS_STITCHER") + RETURN_NAMES = ("frames", "mask", "audio", "frame_count", "fps", "width", "height", "duration", "stitcher") + OUTPUT_TOOLTIPS = ( + "Every frame of the trim window, transformed, as a BHWC batch.", + "BHW generated-area mask per frame: rotation corners and padding, feathered.", + "Audio for the same window; silent when the video has no audio track.", + "Number of frames returned.", + "Frames per second of the returned batch: the source rate divided by every_nth.", + "Frame width after the transform and any resize.", + "Frame height after the transform and any resize.", + "Duration in seconds of the returned frames.", + "For Stitch Inpaint: pastes the source frames back over a generated clip " + "of this size, blending only across the padded and rotated-in area.", + ) + FUNCTION = "load_transform" + + async def load_transform( + self, + video: str, + source_mode: str, + local_path: str, + start_seconds: float, + end_seconds: float, + seek_mode: str = "frame index", + frame_index: int = 0, + frame_time: float = 0.0, + resize_to_megapixels=False, + megapixels=1.0, + resize_method="lanczos", + resolution_steps=1, + every_nth=1, + max_frames=0, + frame_snap="free", + stitch_blend=32, + stitch_grow=0, + **values, + ): + path = resolve_video_path(source_mode, video, local_path) + nth = max(1, int(every_nth)) + cap = max(0, int(max_frames)) + # The decode blocks for as long as the trim is; off the loop so the + # executor keeps answering, with the context ComfyUI's progress and + # interrupt hooks need carried along. + frames, source_fps = await asyncio.to_thread( + decode_video_range, path, float(start_seconds), float(end_seconds), 0, 0, nth, cap + ) + keep = snap_frame_count(int(frames.shape[0]), frame_snap) + if keep < frames.shape[0]: + frames = frames[:keep] + spec = spec_from_values(**values) + output, mask, geometry = await asyncio.to_thread(transform_tensor_batch_chunked, frames, spec) + del frames + if resize_to_megapixels: + output, mask = resize_batch_to_megapixels( + output, mask, float(megapixels), str(resize_method), int(resolution_steps) + ) + stitcher = clip_stitcher(output, mask, geometry, int(stitch_blend), int(stitch_grow)) + fps = source_fps / nth + frame_count = int(output.shape[0]) + duration = frame_count / fps if fps > 0 else 0.0 + # Deferred: the track is decoded only if something reads the AUDIO + # output, and the window ends where the kept frames end. + audio = lazy_audio_range(path, float(start_seconds), float(start_seconds) + duration) + return ( + output, + mask, + audio, + frame_count, + float(fps), + int(output.shape[2]), + int(output.shape[1]), + float(duration), + stitcher, + ) + + @classmethod + def VALIDATE_INPUTS(cls, video, source_mode, local_path, start_seconds, end_seconds, **_values): + try: + resolve_video_path(source_mode, video, local_path) + except Exception as exc: + return f"Video Crop + Rotate + Pad: {exc}" + if float(end_seconds) > 0.0 and float(start_seconds) >= float(end_seconds): + return "Video Crop + Rotate + Pad: start_seconds must be smaller than end_seconds." + return True + + @classmethod + def IS_CHANGED( + cls, + video, + source_mode, + local_path, + start_seconds, + end_seconds, + seek_mode="frame index", + frame_index=0, + frame_time=0.0, + every_nth=1, + max_frames=0, + frame_snap="free", + stitch_blend=32, + stitch_grow=0, + **values, + ): + # The preview position (seek_mode, frame_index, frame_time) is left + # out on purpose: scrubbing the editor must not re-run a whole clip. + try: + path = resolve_video_path(source_mode, video, local_path) + except Exception: + path = local_path if source_mode == "local path" else video + spec = spec_from_values(**values) + resize = {name: values.get(name) for name in resize_inputs()} + return stable_file_fingerprint( + path, + { + "video": video, + "source_mode": source_mode, + "start_seconds": round(float(start_seconds), 6), + "end_seconds": round(float(end_seconds), 6), + "every_nth": max(1, int(every_nth)), + "max_frames": max(0, int(max_frames)), + "frame_snap": str(frame_snap), + "stitch_blend": int(stitch_blend), + "stitch_grow": int(stitch_grow), + **spec.__dict__, + **resize, + }, + ) + + +register_video_routes() + +NODE_CLASS_MAPPINGS = {"AUSBOSS_NODES_VideoCropRotatePadClip": AusBossVideoCropRotatePadClip} +NODE_DISPLAY_NAME_MAPPINGS = { + "AUSBOSS_NODES_VideoCropRotatePadClip": "Video Crop + Rotate + Pad → Clip 🆎" +} + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/nodes/node_workflow_note.py b/nodes/node_workflow_note.py new file mode 100644 index 0000000..18ba216 --- /dev/null +++ b/nodes/node_workflow_note.py @@ -0,0 +1,75 @@ +"""Workflow Note 🆎 — the card that makes a shared workflow self-explanatory. + +The whole node is a presentation surface: a title banner, a short how-to in +Markdown, the models it needs with download buttons and the folder each one +goes in, the node packs it depends on, and the author's links. The frontend +checks the model files and node packs against this install and marks each +row installed or missing, so a downloaded workflow tells its new owner what +is left to do before the first run. + +The backend keeps one hidden STRING widget holding the card as JSON, so +save/load, undo and the API format all ride LiteGraph's default path. Nothing +executes: the node has no outputs and is never part of the prompt graph. +""" + +from __future__ import annotations + + +class AusBossWorkflowNote: + CATEGORY = "🆎 AusBoss/Utility" + DESCRIPTION = ( + "A workflow card for sharing: a big title, a Markdown how-to, the " + "models the workflow needs with download buttons and the folder " + "each goes in, the node packs it depends on, and your links. Each " + "model and pack row is checked against this install and marked " + "installed or missing, so whoever opens the workflow sees exactly " + "what is left to set up. Click the pencil to edit; switch to the " + "banner layout for a plain title label. Nothing runs — the node has " + "no outputs." + ) + SEARCH_ALIASES = [ + "note", + "readme", + "workflow note", + "model links", + "download models", + "label", + "title", + "banner", + "credits", + "ausboss", + ] + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "note": ( + "STRING", + { + "default": "{}", + "multiline": True, + "tooltip": ( + "The card as JSON: title, subtitle, author, " + "Markdown body, model rows, node-pack rows and " + "links. The pencil on the card edits it; this " + "field is the storage behind that editor." + ), + }, + ), + }, + } + + RETURN_TYPES = () + FUNCTION = "noop" + + def noop(self, note): + # Never reached: with no outputs and OUTPUT_NODE unset the node is + # not part of any prompt. Kept so the class is a complete node. + return () + + +NODE_CLASS_MAPPINGS = {"AUSBOSS_NODES_WorkflowNote": AusBossWorkflowNote} +NODE_DISPLAY_NAME_MAPPINGS = {"AUSBOSS_NODES_WorkflowNote": "Workflow Note 🆎"} + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/pyproject.toml b/pyproject.toml index 92faa20..5ab009a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,8 +2,8 @@ name = "ausboss-nodes" # Manager/registry search matches against this text, so it names the actual # nodes instead of describing the collection abstractly. -description = "Polished image and video nodes: Load Video with trim and single-frame picking, Save Video, Save Image (PNG or lossless JPEG XL, exact filenames, caption sidecars), Image Resize, Inpaint Crop and Stitch, LaMa object removal, Mask Refine, Color Match, Frame Interpolate, A/B Image Compare, crop/rotate/pad editors, LoRA Loader with trigger words, LM Studio chat, Select Frame, batch utilities, Math Expression, Show Text, text/number literals, Free Memory, and a Replace Missing Nodes button that swaps known third-party nodes for these." -version = "1.3.0" +description = "Image and video nodes with compact controls: Image and Video Crop + Rotate + Pad (frame or clip), Load Image + Pad, Image Resize, Align Image, Image Size, Color Match, Image Compare A/B, Save Image (PNG, lossless WebP/JXL, exact names, captions), Load Video, Save Video, Select Frame, Frame Interpolate, Mask Refine, LaMa Inpaint, Crop For Inpaint, Stitch Inpaint, LoRA Loader, Krea 2 Encode and Outpaint Model Patch, Resolution Master, Seed, Select Every Nth, Split Batch, Merge Batches, Text, Integer, Float, Math Expression, Show Text, Free Memory, Workflow Note, and Run Timer." +version = "2.0.0" # comfy-cli serializes this to JSON verbatim, so `file` publishes the literal # string {"file": "LICENSE"} to the registry listing. `text` puts a readable # name there instead. Both forms are supported; the LICENSE file is unchanged. @@ -14,6 +14,7 @@ keywords = [ "ausboss", "save image", "jpeg xl", "jxl", "caption", "dataset", "inpaint", "lama", "mask", "color match", "frame interpolation", "lora", "resize", "trim video", + "resolution", "seed", "workflow notes", "timer", "krea2", "batch", ] # Read by comfy-cli into the registry's supported_os field. Nothing in the pack diff --git a/scripts/dev/audit_workflows.mjs b/scripts/dev/audit_workflows.mjs new file mode 100644 index 0000000..0930731 --- /dev/null +++ b/scripts/dev/audit_workflows.mjs @@ -0,0 +1,91 @@ +// Load each example on a real canvas; export the actual API graph and a +// screenshot, and report missing classes, overlaps and exposed number widgets. +// node scripts/dev/audit_workflows.mjs [workflow.json ...] +import fs from "node:fs"; +import path from "node:path"; + +const [port, outDir, ...requested] = process.argv.slice(2); +if (!port || !outDir) throw new Error("Expected CDP port and output directory"); +fs.mkdirSync(outDir, { recursive: true }); +const root = "http://127.0.0.1:8188/"; +const tabs = await (await fetch(`http://127.0.0.1:${port}/json`)).json(); +const tab = tabs.find(t => t.type === "page" && t.url.startsWith(root)); +if (!tab) throw new Error("Open ComfyUI with cdp.mjs first"); +const ws = new WebSocket(tab.webSocketDebuggerUrl); +await new Promise(resolve => { ws.onopen = resolve; }); +let serial = 0; +const pending = new Map(); +const exceptions = []; +ws.onmessage = event => { + const message = JSON.parse(event.data); + if (pending.has(message.id)) { + const { resolve, reject, timer } = pending.get(message.id); + clearTimeout(timer); pending.delete(message.id); + message.error ? reject(new Error(JSON.stringify(message.error))) : resolve(message.result); + } else if (message.method === "Runtime.exceptionThrown") { + exceptions.push(message.params.exceptionDetails.exception?.description ?? message.params.exceptionDetails.text); + } else if (message.method === "Page.javascriptDialogOpening") { + void send("Page.handleJavaScriptDialog", { accept: true }); + } +}; +function send(method, params = {}) { + return new Promise((resolve, reject) => { + const id = ++serial; + const timer = setTimeout(() => { pending.delete(id); reject(new Error(`Timeout: ${method}`)); }, 45000); + pending.set(id, { resolve, reject, timer }); + ws.send(JSON.stringify({ id, method, params })); + }); +} +async function evaluate(expression) { + const result = await send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }); + if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description ?? result.exceptionDetails.text); + return result.result?.value; +} +await send("Page.enable"); await send("Runtime.enable"); +await send("Emulation.setDeviceMetricsOverride", { width: 3200, height: 1800, deviceScaleFactor: 1, mobile: false }); +const files = requested.length ? requested : fs.readdirSync("example_workflows").filter(p => p.endsWith(".json")).sort().map(p => path.join("example_workflows", p)); +const reports = []; +try { + for (const file of files) { + const data = JSON.parse(fs.readFileSync(file, "utf8")); + exceptions.length = 0; + const report = await evaluate(`(async () => { + const app = window.app; + app.canvas.linkConnector?.reset(); + await app.loadGraphData(${JSON.stringify(data)}, true, true, null, {showMissingNodesDialog:false,showMissingModelsDialog:false}); + await new Promise(r => setTimeout(r, 900)); + const nodes = app.graph._nodes; + const overlaps = []; + for (let i=0;i Math.max(a.pos[0],b.pos[0])+1 && + Math.min(a.pos[1]+a.size[1],b.pos[1]+b.size[1]) > Math.max(a.pos[1]-30,b.pos[1]-30)+1) overlaps.push([a.id,b.id]); + } + const x0=Math.min(...nodes.map(n=>n.pos[0]))-40, y0=Math.min(...nodes.map(n=>n.pos[1]))-100; + const x1=Math.max(...nodes.map(n=>n.pos[0]+n.size[0]))+40, y1=Math.max(...nodes.map(n=>n.pos[1]+n.size[1]))+40; + const canvas=app.canvas.canvas; + const scale=Math.min((canvas.clientWidth-180)/(x1-x0),(canvas.clientHeight-280)/(y1-y0),1); + app.canvas.ds.scale=scale; app.canvas.ds.offset=[-x0+80/scale,-y0+180/scale];app.canvas.setDirty(true,true); + await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); + return {overlaps, missing:nodes.filter(n=>n.has_errors).map(n=>({id:n.id,type:n.type})), + nodes:nodes.map(n=>({id:n.id,type:n.type,pos:[...n.pos],size:[...n.size], + classicNumbers:n.type.startsWith('AUSBOSS_NODES_') ? (n.widgets??[]).filter(w=>w.type==='number' && !w.hidden).map(w=>w.name):[]})), + prompt:await app.graphToPrompt()}; + })()`); + const stem = path.basename(file, ".json"); + fs.writeFileSync(path.join(outDir, `${stem}.api.json`), JSON.stringify(report.prompt.output, null, 2)+"\n"); + fs.writeFileSync(path.join(outDir, `${stem}.loaded.json`), JSON.stringify(report.prompt.workflow, null, 2)+"\n"); + delete report.prompt; + const shot = await send("Page.captureScreenshot", { format: "png" }); + fs.writeFileSync(path.join(outDir, `${stem}.png`), Buffer.from(shot.data, "base64")); + reports.push({ file, ...report, exceptions: [...exceptions] }); + console.log(JSON.stringify({file,overlaps:report.overlaps,missing:report.missing,exceptions})); + } +} finally { + fs.writeFileSync(path.join(outDir, "canvas-audit.json"), JSON.stringify(reports,null,2)+"\n"); + ws.close(); +} +const failures = reports.filter(report => report.overlaps.length || report.missing.length || + report.exceptions.length || report.nodes.some(node => node.classicNumbers.length)); +console.log(`${reports.length - failures.length}/${reports.length} canvas audits passed.`); +if (failures.length) process.exitCode = 1; diff --git a/scripts/dev/cdp.mjs b/scripts/dev/cdp.mjs new file mode 100644 index 0000000..a64dabc --- /dev/null +++ b/scripts/dev/cdp.mjs @@ -0,0 +1,52 @@ +// cdp.mjs [evalFile] [waitMs] +// Opens/reuses a ComfyUI tab on the headless Chrome at , optionally +// loads a workflow JSON into the graph, evaluates a JS snippet file, and +// screenshots the page. Prints the eval result as JSON. +import fs from "node:fs"; +const [port, wfPath, outPng, evalFile, waitArg] = process.argv.slice(2); +const waitMs = Number(waitArg ?? 1500); +const COMFY = "http://127.0.0.1:8188/"; +const list = await (await fetch(`http://127.0.0.1:${port}/json`)).json(); +let page = list.find((t) => t.type === "page" && t.url.startsWith(COMFY)); +if (page && process.env.CDP_RELOAD === "1") { + // A fresh tab rather than an in-place navigation: after synthetic mouse + // input the old tab stops answering on navigate (see cdp_nav_debug.mjs). + await fetch(`http://127.0.0.1:${port}/json/close/${page.id}`); + page = null; +} +if (!page) page = await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: "PUT" })).json(); +const ws = new WebSocket(page.webSocketDebuggerUrl); +await new Promise((r) => (ws.onopen = r)); +let id = 0; const pending = new Map(); +ws.onmessage = (m) => { const d = JSON.parse(m.data); if (d.id && pending.has(d.id)) { pending.get(d.id)(d); pending.delete(d.id); } else if (d.method === "Page.javascriptDialogOpening") { ws.send(JSON.stringify({ id: ++id, method: "Page.handleJavaScriptDialog", params: { accept: true } })); } }; +const send = (method, params = {}) => new Promise((r, reject) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method, params })); setTimeout(() => { if (pending.has(i)) { pending.delete(i); reject(new Error(`CDP timeout: ${method}`)); } }, 25000); }); +const evalJs = async (expr) => { + const r = await send("Runtime.evaluate", { expression: expr, awaitPromise: true, returnByValue: true }); + if (r.result?.exceptionDetails) throw new Error(JSON.stringify(r.result.exceptionDetails.exception?.description ?? r.result.exceptionDetails)); + return r.result?.result?.value; +}; +await send("Page.enable"); await send("Runtime.enable"); +await send("Emulation.setDeviceMetricsOverride", { width: 1800, height: 1100, deviceScaleFactor: 1, mobile: false }); +if (process.env.CDP_RELOAD === "1" || !page.url.startsWith(COMFY)) { + await send("Network.enable"); await send("Network.clearBrowserCache"); await send("Network.setCacheDisabled", { cacheDisabled: true }); + await send("Page.navigate", { url: COMFY }); + for (let i = 0; i < 60; i++) { await new Promise((r) => setTimeout(r, 500)); if (await evalJs("Boolean(window.app?.graph && window.app?.canvas)")) break; } + await new Promise((r) => setTimeout(r, 1500)); +} +if (wfPath && wfPath !== "-") { + const wf = fs.readFileSync(wfPath, "utf-8"); + await evalJs(`(async () => { await window.app.loadGraphData(${wf}, true, true, null, { showMissingNodesDialog: false, showMissingModelsDialog: false }); return true; })()`); + await new Promise((r) => setTimeout(r, waitMs)); +} +let result = null; +if (evalFile && evalFile !== "-") { + result = await evalJs(fs.readFileSync(evalFile, "utf-8")); + await new Promise((r) => setTimeout(r, waitMs)); +} +if (outPng && outPng !== "-") { + const clip = result && typeof result === "object" && result.__clip ? { ...result.__clip, scale: 1 } : undefined; + const shot = await send("Page.captureScreenshot", clip ? { format: "png", clip } : { format: "png" }); + fs.writeFileSync(outPng, Buffer.from(shot.result.data, "base64")); +} +console.log(JSON.stringify(result, null, 1)); +ws.close(); diff --git a/scripts/dev/cdp_drag.mjs b/scripts/dev/cdp_drag.mjs new file mode 100644 index 0000000..1731ebd --- /dev/null +++ b/scripts/dev/cdp_drag.mjs @@ -0,0 +1,42 @@ +// cdp_drag.mjs [checkEval] +// setupEval must return { from:{x,y}, to:{x,y}, __clip }. Presses the mouse +// at `from`, moves to `to` in steps, screenshots mid-drag, releases, then +// evaluates checkEval and screenshots again. +import fs from "node:fs"; +const [port, setupFile, midPng, afterPng, checkFile] = process.argv.slice(2); +const COMFY = "http://127.0.0.1:8188/"; +const list = await (await fetch(`http://127.0.0.1:${port}/json`)).json(); +const page = list.find((t) => t.type === "page" && t.url.startsWith(COMFY)); +if (!page) throw new Error("no comfy tab"); +const ws = new WebSocket(page.webSocketDebuggerUrl); +await new Promise((r) => (ws.onopen = r)); +let id = 0; const pending = new Map(); +ws.onmessage = (m) => { const d = JSON.parse(m.data); if (d.id && pending.has(d.id)) { pending.get(d.id)(d); pending.delete(d.id); } else if (d.method === "Page.javascriptDialogOpening") { ws.send(JSON.stringify({ id: ++id, method: "Page.handleJavaScriptDialog", params: { accept: true } })); } }; +const send = (method, params = {}) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method, params })); }); +const evalJs = async (expr) => { + const r = await send("Runtime.evaluate", { expression: expr, awaitPromise: true, returnByValue: true }); + if (r.result?.exceptionDetails) throw new Error(JSON.stringify(r.result.exceptionDetails.exception?.description ?? r.result.exceptionDetails)); + return r.result?.result?.value; +}; +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); +const shot = async (file, clip) => { if (!file || file === "-") return; const s = await send("Page.captureScreenshot", clip ? { format: "png", clip: { ...clip, scale: 1 } } : { format: "png" }); fs.writeFileSync(file, Buffer.from(s.result.data, "base64")); }; +await evalJs("(() => { try { app.canvas.linkConnector?.reset?.(); } catch {} return true; })()"); +await send("Page.enable"); +const setup = await evalJs(fs.readFileSync(setupFile, "utf-8")); +const { from, to, __clip } = setup; +const mouse = (type, x, y, extra = {}) => send("Input.dispatchMouseEvent", { type, x, y, button: "left", buttons: type === "mouseReleased" ? 0 : 1, pointerType: "mouse", ...extra }); +await mouse("mouseMoved", from.x, from.y, { buttons: 0 }); +await wait(80); +await mouse("mousePressed", from.x, from.y, { clickCount: 1 }); +await wait(80); +const steps = 12; +for (let i = 1; i <= steps; i++) { await mouse("mouseMoved", from.x + (to.x - from.x) * i / steps, from.y + (to.y - from.y) * i / steps); await wait(30); } +await wait(250); +try { await shot(midPng, __clip); } finally { await mouse("mouseReleased", to.x, to.y, { clickCount: 1 }); } +await wait(400); +let check = null; +if (checkFile && checkFile !== "-") check = await evalJs(fs.readFileSync(checkFile, "utf-8")); +await wait(300); +await shot(afterPng, __clip); +console.log(JSON.stringify({ setup: { from, to }, check }, null, 1)); +ws.close(); diff --git a/scripts/dev/cdp_hang.mjs b/scripts/dev/cdp_hang.mjs new file mode 100644 index 0000000..41a6dec --- /dev/null +++ b/scripts/dev/cdp_hang.mjs @@ -0,0 +1,38 @@ +// Pre-arm the debugger on a blank tab, navigate to ComfyUI, and if the page +// stops answering, pause it and print where it is spinning. +const port = process.argv[2]; +const COMFY = "http://127.0.0.1:8188/"; +const page = await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: "PUT" })).json(); +const ws = new WebSocket(page.webSocketDebuggerUrl); +await new Promise((r) => (ws.onopen = r)); +let id = 0; const pending = new Map(); const events = []; +ws.onmessage = (m) => { const d = JSON.parse(m.data); if (d.id && pending.has(d.id)) { pending.get(d.id)(d); pending.delete(d.id); } else if (d.method) events.push(d); }; +const send = (method, params = {}, ms = 5000) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method, params })); setTimeout(() => { if (pending.has(i)) { pending.delete(i); r({ timeout: method }); } }, ms); }); +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); +console.log("enable:", JSON.stringify(await send("Debugger.enable")).slice(0, 60), JSON.stringify(await send("Runtime.enable")).slice(0, 40), JSON.stringify(await send("Page.enable")).slice(0, 40)); +await send("Emulation.setDeviceMetricsOverride", { width: 1800, height: 1100, deviceScaleFactor: 1, mobile: false }); +await send("Network.enable"); await send("Network.clearBrowserCache"); await send("Network.setCacheDisabled", { cacheDisabled: true }); +await send("Page.navigate", { url: COMFY }); +let hung = false; +for (let i = 0; i < 40; i++) { + await wait(1000); + const r = await send("Runtime.evaluate", { expression: "Boolean(window.app?.graph?._nodes?.length)", returnByValue: true }, 3000); + if (r.timeout) { hung = true; console.log("no answer at", i, "s"); break; } + if (r.result?.result?.value) { console.log("graph up at", i, "s, nodes:", r.result.result.value); } +} +if (!hung) { console.log("page never hung"); ws.close(); process.exit(0); } +const p = await send("Debugger.pause", {}, 3000); +console.log("pause:", JSON.stringify(p).slice(0, 80)); +for (let i = 0; i < 20 && !events.some((e) => e.method === "Debugger.paused"); i++) await wait(250); +const paused = events.find((e) => e.method === "Debugger.paused"); +if (!paused) { console.log("no paused event; events:", [...new Set(events.map((e) => e.method))].join(",")); ws.close(); process.exit(0); } +console.log("PAUSED, reason", paused.params.reason); +for (const f of paused.params.callFrames.slice(0, 20)) console.log(" ", (f.functionName || "(anon)").padEnd(34), f.url.split("/").slice(-2).join("/"), f.location.lineNumber + ":" + f.location.columnNumber); +const top = paused.params.callFrames[0]; +const probe = await send("Debugger.evaluateOnCallFrame", { callFrameId: top.callFrameId, expression: "(() => { try { return JSON.stringify({ this: this?.constructor?.name, title: this?.title, name: this?.name, keys: Object.keys(this ?? {}).slice(0, 12) }); } catch (e) { return String(e); } })()", returnByValue: true }, 4000); +console.log("top this:", JSON.stringify(probe.result?.result?.value ?? probe).slice(0, 400)); +for (const f of paused.params.callFrames.slice(0, 6)) { + const scope = await send("Debugger.evaluateOnCallFrame", { callFrameId: f.callFrameId, expression: "(() => { try { const o = {}; for (const k of ['name','widget','w','i','index','y','height','h','size','count']) { try { o[k] = typeof eval(k) === 'object' ? (eval(k)?.name ?? eval(k)?.constructor?.name) : eval(k); } catch {} } return JSON.stringify(o); } catch (e) { return String(e); } })()", returnByValue: true }, 4000); + console.log(" locals@" + (f.functionName || "anon") + ":", JSON.stringify(scope.result?.result?.value ?? scope).slice(0, 300)); +} +ws.close(); diff --git a/scripts/dev/cdp_nav_debug.mjs b/scripts/dev/cdp_nav_debug.mjs new file mode 100644 index 0000000..8e0f26b --- /dev/null +++ b/scripts/dev/cdp_nav_debug.mjs @@ -0,0 +1,20 @@ +// Navigate the Comfy tab in place and log every CDP event for 25 s, so a +// crash (Inspector.targetCrashed) or a dialog (Page.javascriptDialogOpening) +// shows itself. +const port = process.argv[2]; +const COMFY = "http://127.0.0.1:8188/"; +const list = await (await fetch(`http://127.0.0.1:${port}/json`)).json(); +const page = list.find((t) => t.type === "page" && t.url.startsWith(COMFY)); +const ws = new WebSocket(page.webSocketDebuggerUrl); +await new Promise((r) => (ws.onopen = r)); +let id = 0; const pending = new Map(); const events = []; +ws.onmessage = (m) => { const d = JSON.parse(m.data); if (d.id && pending.has(d.id)) { pending.get(d.id)(d); pending.delete(d.id); } else if (d.method) { events.push(d.method); if (d.method === "Page.javascriptDialogOpening") { console.log("DIALOG:", JSON.stringify(d.params).slice(0, 200)); ws.send(JSON.stringify({ id: ++id, method: "Page.handleJavaScriptDialog", params: { accept: true } })); } if (d.method === "Inspector.targetCrashed") console.log("TARGET CRASHED"); } }; +const send = (method, params = {}, ms = 8000) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method, params })); setTimeout(() => { if (pending.has(i)) { pending.delete(i); r({ timeout: method }); } }, ms); }); +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); +console.log("enable:", JSON.stringify(await send("Page.enable")).slice(0, 40), JSON.stringify(await send("Runtime.enable")).slice(0, 40), JSON.stringify(await send("Inspector.enable")).slice(0, 40)); +console.log("modified?", JSON.stringify(await send("Runtime.evaluate", { expression: "String(window.app?.workflowManager?.activeWorkflow?.isModified ?? window.app?.workflowManager?.activeWorkflow?.modified ?? 'n/a')", returnByValue: true })).slice(0, 120)); +const nav = send("Page.navigate", { url: COMFY }, 20000); +for (let i = 0; i < 25; i++) { await wait(1000); const r = await send("Runtime.evaluate", { expression: "1+1", returnByValue: true }, 1500); if (!r.timeout) { console.log("alive at", i, "s"); break; } } +console.log("navigate:", JSON.stringify(await nav).slice(0, 160)); +console.log("events:", [...new Set(events)].join(", ")); +ws.close(); diff --git a/scripts/generate_example_thumbnails.py b/scripts/generate_example_thumbnails.py deleted file mode 100644 index f946281..0000000 --- a/scripts/generate_example_thumbnails.py +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env python -"""Regenerate the matching workflow-template thumbnails.""" - -import os -from pathlib import Path - -from PIL import Image, ImageDraw, ImageFont - - -ROOT = Path(__file__).resolve().parent.parent / "example_workflows" - - -def font(size: int, bold: bool = False): - windows = Path(os.environ.get("WINDIR", "")) / "Fonts" - candidates = [ - windows / ("segoeuib.ttf" if bold else "segoeui.ttf"), - Path("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"), - Path("/System/Library/Fonts/Supplemental/Arial Bold.ttf" if bold else "/System/Library/Fonts/Supplemental/Arial.ttf"), - ] - for path in candidates: - if path.is_file(): - return ImageFont.truetype(str(path), size) - return ImageFont.load_default() - - -def render(filename: str, kind: str, subtitle: str) -> None: - image = Image.new("RGB", (600, 338), "#0f1214") - draw = ImageDraw.Draw(image) - draw.rounded_rectangle((18, 18, 582, 320), 24, fill="#171b1f", outline="#344047", width=2) - draw.rounded_rectangle((42, 46, 160, 86), 18, fill="#123d3b") - draw.text((62, 55), kind, font=font(20, bold=True), fill="#77e2da") - draw.text((42, 126), "Crop + Rotate + Pad", font=font(36, bold=True), fill="#f4f6f7") - draw.text((44, 181), subtitle, font=font(21), fill="#b7bec4") - draw.line((44, 248, 555, 248), fill="#39444b", width=2) - draw.rectangle((44, 273, 164, 282), fill="#4bd8ef") - draw.polygon(((220, 268), (230, 278), (220, 288), (210, 278)), fill="#ff9d42") - draw.ellipse((274, 268, 294, 288), fill="#73e36a") - image.save(ROOT / filename, quality=92) - - -render("image_crop_rotate_pad.jpg", "IMAGE", "Full-screen precision controls") -render("video_crop_rotate_pad.jpg", "VIDEO", "Exact-frame timeline and transform") diff --git a/scripts/registry_status.py b/scripts/registry_status.py new file mode 100644 index 0000000..01854c9 --- /dev/null +++ b/scripts/registry_status.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Read the exact release's Registry status without publishing anything. + +Exit 0 means Active, 2 means not approved, and 1 means the +status could not be established. A successful upload is not approval. +""" + +import argparse +import json +import os +from pathlib import Path +import re +import sys +import tomllib +import urllib.error +import urllib.request + +NODE_ID = "ausboss-nodes" +STATUS_URL = f"https://api.comfy.org/nodes/{NODE_ID}/versions?include_status_reason=true" +KNOWN_STATUSES = { + "NodeVersionStatusActive", "NodeVersionStatusPending", + "NodeVersionStatusFlagged", "NodeVersionStatusBanned", + "NodeVersionStatusDeleted", +} + + +def version_status(versions, version): + if not isinstance(versions, list) or any(not isinstance(v, dict) for v in versions): + raise ValueError("Registry returned an unexpected version list") + matches = [v for v in versions if v.get("version") == version] + if not matches: + return "NotPublished", "The requested version is not listed." + if len(matches) != 1: + raise ValueError("Registry returned duplicate versions") + entry = matches[0] + if entry.get("node_id") != NODE_ID: + raise ValueError("Registry returned a different node pack") + status = entry.get("status") + if status not in KNOWN_STATUSES: + raise ValueError(f"Registry returned an unknown status: {status!r}") + reason = entry.get("status_reason") or "" + if isinstance(reason, str): + try: + reason = json.loads(reason) + except ValueError: + pass + if isinstance(reason, dict): + reason = reason.get("message", "") + return status, str(reason)[:4000] + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--version", help="Defaults to the checked-out pyproject version") + args = parser.parse_args(argv) + try: + version = args.version or tomllib.loads( + (Path(__file__).resolve().parent.parent / "pyproject.toml").read_text() + )["project"]["version"] + if not re.fullmatch(r"\d+\.\d+\.\d+", version): + raise ValueError("Version must use X.Y.Z format") + request = urllib.request.Request(STATUS_URL, headers={"User-Agent": "AusBoss-release-check"}) + with urllib.request.urlopen(request, timeout=20) as response: + payload = response.read(8 * 1024 * 1024 + 1) + if len(payload) > 8 * 1024 * 1024: + raise ValueError("Registry response exceeds the size limit") + status, reason = version_status(json.loads(payload), version) + except (OSError, ValueError, KeyError, TypeError, urllib.error.URLError) as exc: + print(f"Registry status UNKNOWN: {exc}", file=sys.stderr) + return 1 + + print(f"{NODE_ID} {version}: {status}") + if reason: + print(f"Reason: {reason}") + active = status == "NodeVersionStatusActive" + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if summary: + with open(summary, "a", encoding="utf-8") as handle: + handle.write(f"## Registry: {version}\n\nStatus: **{status}**\n\n") + handle.write("Release approved.\n" if active else "Release is not approved; review the status before publishing again.\n") + if reason: + handle.write("\n" + "\n".join("> " + line for line in reason.splitlines()) + "\n") + handle.write(f"\n[Registry response]({STATUS_URL})\n") + return 0 if active else 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/release_preflight.py b/scripts/release_preflight.py index 1452ad5..a18dc53 100644 --- a/scripts/release_preflight.py +++ b/scripts/release_preflight.py @@ -13,6 +13,14 @@ Checks: the version out of pyproject.toml on main at view time, so it can never go stale (a hardcoded badge sat at 1.0.0 through two releases). The check guards against someone swapping a static badge back in. + 4. .comfyignore keeps development-only paths (tests, scripts, CI, agent + instructions, root docs) out of the Registry archive and never + swallows a runtime path (nodes/, js/ including js/docs/, the example + workflows, README, LICENSE, pyproject). comfy-cli's packer honours + the file with gitignore semantics; this check reads it the same way. + 5. example_workflows/: every UI graph has a matching thumbnail, consistent + links, setup instructions, and stage groups containing its nodes without + overlaps (including title bars). Exit code 0 = ready to release, 1 = problems printed below. """ @@ -21,6 +29,8 @@ import pathlib import re import sys +from workflow_contract import example_problems + ROOT = pathlib.Path(__file__).resolve().parent.parent errors = [] @@ -80,6 +90,96 @@ try: except OSError as exc: errors.append(f"could not read README.md: {exc}") +# --- 4. .comfyignore covers the development-only paths ----------------------- +import fnmatch + + +def comfyignore_patterns(text): + patterns = [] + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + patterns.append(line) + return patterns + + +def ignored(path, patterns): + """gitignore-style match, the subset .comfyignore uses: a trailing slash + names a directory anywhere in the tree, a leading slash anchors to the + root, anything else matches a path segment or the full path.""" + parts = path.split("/") + for pattern in patterns: + anchored = pattern.startswith("/") + body = pattern.lstrip("/") + directory = body.endswith("/") + body = body.rstrip("/") + if anchored: + if directory and parts[0] == body and len(parts) > 1: + return True + if not directory and fnmatch.fnmatch(path, body): + return True + continue + if directory: + if body in parts[:-1]: + return True + continue + if fnmatch.fnmatch(path, body) or any(fnmatch.fnmatch(part, body) for part in parts): + return True + return False + + +DEV_ONLY = [ + "tests/test_math_helpers.py", "tests/panel_guards.test.mjs", + "scripts/validate_nodes.py", "scripts/release_preflight.py", + ".github/workflows/publish_action.yml", "AGENTS.md", "CLAUDE.md", + ".claude/skills/ausboss-node-brand/SKILL.md", ".agents/skills/ausboss-node-brand/SKILL.md", + "docs/adding_a_node.md", "todo.md", "todo", "workflow_ideas.md", +] +RUNTIME = [ + "__init__.py", "nodes/node_seed.py", "nodes/_lora_helpers.py", + "js/seed/index.js", "js/shared/index.mjs", "js/docs/AUSBOSS_NODES_Seed.md", + "example_workflows/Krea 2 Outpaint (AusBoss).json", + "example_workflows/inputs/ausboss_pier_sunrise.png", + "README.md", "LICENSE", "pyproject.toml", "ausboss_presets_example.json", +] +try: + patterns = comfyignore_patterns((ROOT / ".comfyignore").read_text(encoding="utf-8")) +except OSError as exc: + patterns = None + errors.append(f"could not read .comfyignore: {exc}") +if patterns is not None: + for path in DEV_ONLY: + if not ignored(path, patterns): + errors.append(f".comfyignore does not exclude development-only path {path}") + for path in RUNTIME: + if ignored(path, patterns): + errors.append(f".comfyignore would drop runtime path {path} from the archive") + +# --- 5. example workflows parse and pair with thumbnails --------------------- +import json + +examples = ROOT / "example_workflows" +if examples.is_dir(): + workflows = {path.stem: path for path in examples.glob("*.json")} + for stem, path in sorted(workflows.items()): + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + errors.append(f"example_workflows/{path.name} is not valid JSON: {exc}") + continue + if not isinstance(data, dict) or not isinstance(data.get("nodes"), list) or not data["nodes"]: + errors.append( + f"example_workflows/{path.name} is not a UI workflow (no nodes list) - " + "the template browser needs the saved-workflow format, not API JSON" + ) + if not (examples / f"{stem}.jpg").is_file(): + errors.append(f"example_workflows/{path.name} has no {stem}.jpg - it shows a blank template card") + for path in sorted(examples.glob("*.jpg")): + if path.stem not in workflows: + errors.append(f"example_workflows/{path.name} has no matching {path.stem}.json") + errors.extend(example_problems(examples)) + # --- report ------------------------------------------------------------------ for error in errors: print(f"ERROR {error}") diff --git a/scripts/run_python_tests.py b/scripts/run_python_tests.py new file mode 100644 index 0000000..0f1fe1c --- /dev/null +++ b/scripts/run_python_tests.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Run backend test files in fresh processes with the active interpreter. + +Several offline tests install small ComfyUI stubs in sys.modules. Isolating +files prevents one test's fake runtime from changing another test's imports. +""" + +from pathlib import Path +import os +import subprocess +import sys + + +def main(): + root = Path(__file__).resolve().parent.parent + paths = [root / name for name in sys.argv[1:]] or sorted((root / "tests").glob("test_*.py")) + env = {**os.environ, "OMP_NUM_THREADS": "2", "MKL_NUM_THREADS": "2", "PYTHONDONTWRITEBYTECODE": "1"} + failed = [] + for path in paths: + print(f"\n--- {path.name} ---", flush=True) + try: + result = subprocess.run([sys.executable, str(path)], cwd=root, env=env, timeout=180) + if result.returncode: + failed.append(path.name) + except subprocess.TimeoutExpired: + print("Timed out after 180 seconds.", flush=True) + failed.append(path.name) + print(f"\n{len(paths) - len(failed)}/{len(paths)} test files passed.", flush=True) + if failed: + print("Failed: " + ", ".join(failed), flush=True) + return bool(failed) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/validate_nodes.py b/scripts/validate_nodes.py index dcbefea..53e79bf 100644 --- a/scripts/validate_nodes.py +++ b/scripts/validate_nodes.py @@ -57,15 +57,17 @@ PUBLIC_NODE_IDS = { "AUSBOSS_NODES_Krea2Encode", "AUSBOSS_NODES_Krea2OutpaintModelPatch", "AUSBOSS_NODES_LaMaInpaint", - "AUSBOSS_NODES_LmStudioChat", "AUSBOSS_NODES_LoadImagePad", "AUSBOSS_NODES_LoadVideo", "AUSBOSS_NODES_LoraLoader", "AUSBOSS_NODES_MathExpression", "AUSBOSS_NODES_MergeBatches", "AUSBOSS_NODES_RefineMask", + "AUSBOSS_NODES_Resolution", + "AUSBOSS_NODES_RunTimer", "AUSBOSS_NODES_SaveImage", "AUSBOSS_NODES_SaveVideo", + "AUSBOSS_NODES_Seed", "AUSBOSS_NODES_SelectEveryNth", "AUSBOSS_NODES_SelectFrame", "AUSBOSS_NODES_ShowText", @@ -73,6 +75,8 @@ PUBLIC_NODE_IDS = { "AUSBOSS_NODES_StitchInpaint", "AUSBOSS_NODES_Text", "AUSBOSS_NODES_VideoCropRotatePad", + "AUSBOSS_NODES_VideoCropRotatePadClip", + "AUSBOSS_NODES_WorkflowNote", } # Ids from before the AUSBOSS_NODES_ convention. They stay registered forever diff --git a/scripts/workflow_contract.py b/scripts/workflow_contract.py new file mode 100644 index 0000000..9a1f282 --- /dev/null +++ b/scripts/workflow_contract.py @@ -0,0 +1,97 @@ +"""Offline checks for the saved example graphs (no ComfyUI dependency).""" + +from __future__ import annotations + +import json +from pathlib import Path + + +def workflow_problems(data: dict) -> list[str]: + problems = [] + nodes = data.get("nodes", []) + if not isinstance(nodes, list) or not nodes: + return ["expected a nonempty UI workflow nodes list"] + by_id = {n["id"]: n for n in nodes} + if len(by_id) != len(nodes): + problems.append("duplicate node ids") + links = data.get("links", []) + by_link = {link[0]: link for link in links if isinstance(link, list) and len(link) == 6} + if len(by_link) != len(links): + problems.append("duplicate or malformed links (expected six fields)") + for link in by_link.values(): + ident, source_id, source_slot, target_id, target_slot, _kind = link + source, target = by_id.get(source_id), by_id.get(target_id) + if source is None or target is None: + problems.append(f"link {ident} refers to a missing node") + continue + outputs, inputs = source.get("outputs", []), target.get("inputs", []) + if not isinstance(source_slot, int) or not 0 <= source_slot < len(outputs): + problems.append(f"link {ident} has an invalid source slot") + elif ident not in (outputs[source_slot].get("links") or []): + problems.append(f"link {ident} is missing from its source output") + if not isinstance(target_slot, int) or not 0 <= target_slot < len(inputs): + problems.append(f"link {ident} has an invalid target slot") + elif inputs[target_slot].get("link") != ident: + problems.append(f"link {ident} is missing from its target input") + for n in nodes: + for slot, inp in enumerate(n.get("inputs", [])): + ident = inp.get("link") + if ident is not None and (ident not in by_link or by_link[ident][3:5] != [n["id"], slot]): + problems.append(f"node {n['id']} input {slot} has a stale link") + for slot, output in enumerate(n.get("outputs", [])): + for ident in output.get("links") or []: + if ident not in by_link or by_link[ident][1:3] != [n["id"], slot]: + problems.append(f"node {n['id']} output {slot} has a stale link") + if data.get("last_node_id", -1) < max(by_id): + problems.append("last_node_id is behind the graph") + if by_link and data.get("last_link_id", -1) < max(by_link): + problems.append("last_link_id is behind the graph") + + groups = data.get("groups", []) + if not groups: + problems.append("no stage groups") + notes = [n for n in nodes if n["type"] == "AUSBOSS_NODES_WorkflowNote"] + if not notes: + problems.append("no Workflow Note with setup instructions") + for n in notes: + try: + note = json.loads(n["widgets_values"][0]) + if not note.get("title") or not note.get("body"): + problems.append(f"note {n['id']} needs a title and instructions") + if "\\n" in note.get("body", ""): + problems.append(f"note {n['id']} contains escaped line breaks instead of Markdown lines") + except (ValueError, KeyError, IndexError, TypeError, AttributeError): + problems.append(f"note {n['id']} is not valid note JSON") + + rectangles = [] + for n in nodes: + size = n.get("size", [0, 0]) + if isinstance(size, dict): + size = [size.get("0", 0), size.get("1", 0)] + x, y = n.get("pos", [0, 0]) + width, height = size + if width <= 0 or height <= 0: + problems.append(f"node {n['id']} has no usable saved size") + rectangles.append((n["id"], x, y - 30, x + width, y + height)) + if n not in notes and not any( + gx <= x and gy <= y - 30 and x + width <= gx + gw + 1 and y + height <= gy + gh + 1 + for gx, gy, gw, gh in (g["bounding"] for g in groups) + ): + problems.append(f"node {n['id']} is outside its stage group") + for i, (ident, x0, y0, x1, y1) in enumerate(rectangles): + for other, ox0, oy0, ox1, oy1 in rectangles[i + 1:]: + if min(x1, ox1) > max(x0, ox0) + 1 and min(y1, oy1) > max(y0, oy0) + 1: + problems.append(f"nodes {ident} and {other} overlap (including title bars)") + return problems + + +def example_problems(directory: Path) -> list[str]: + problems = [] + for path in sorted(directory.glob("*.json")): + try: + data = json.loads(path.read_text(encoding="utf-8")) + issues = workflow_problems(data) + except (OSError, ValueError, KeyError, TypeError, AttributeError) as exc: + issues = [f"malformed saved workflow: {exc}"] + problems.extend(f"{path.name}: {issue}" for issue in issues) + return problems diff --git a/tests/fixtures/stitch_blend_parity.json b/tests/fixtures/stitch_blend_parity.json new file mode 100644 index 0000000..bf2c3ee --- /dev/null +++ b/tests/fixtures/stitch_blend_parity.json @@ -0,0 +1 @@ +{"width": 40, "height": 24, "mask": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], "cases": [{"blend": 5, "grow": 0, "values": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.622441, 0.396329, 0.243641, 0.243641, 0.396329, 0.622441, 0.819877, 0.936483, 0.983891, 0.997339, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.61978, 0.38022, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.622441, 0.396329, 0.243641, 0.243641, 0.396329, 0.622441, 0.819877, 0.936483, 0.983891, 0.997339, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.61978, 0.38022, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.622441, 0.396329, 0.243641, 0.243641, 0.396329, 0.622441, 0.819877, 0.936483, 0.983891, 0.997339, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.61978, 0.38022, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.622441, 0.396329, 0.243641, 0.243641, 0.396329, 0.622441, 0.819877, 0.936483, 0.983891, 0.997339, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.61978, 0.38022, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.622441, 0.396329, 0.243641, 0.243641, 0.396329, 0.622441, 0.819877, 0.936483, 0.983891, 0.997339, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.61978, 0.38022, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.622441, 0.396329, 0.243641, 0.243641, 0.396329, 0.622441, 0.819877, 0.936483, 0.983891, 0.997339, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.61978, 0.38022, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.622441, 0.396329, 0.243641, 0.243641, 0.396329, 0.622441, 0.819877, 0.936483, 0.983891, 0.997339, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.61978, 0.38022, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.622441, 0.396329, 0.243641, 0.243641, 0.396329, 0.622441, 0.819877, 0.936483, 0.983891, 0.997339, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.61978, 0.38022, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.622434, 0.396286, 0.243472, 0.243162, 0.395317, 0.620792, 0.817695, 0.93399, 0.981273, 0.994685, 0.997339, 0.997339, 0.997339, 0.997339, 0.994685, 0.981273, 0.93399, 0.817695, 0.61813, 0.379208, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.622398, 0.39607, 0.242618, 0.240739, 0.390204, 0.612457, 0.806669, 0.921397, 0.968042, 0.981273, 0.983891, 0.983891, 0.983891, 0.983891, 0.981273, 0.968042, 0.921397, 0.806669, 0.609796, 0.374095, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.622272, 0.395306, 0.239606, 0.2322, 0.372179, 0.583074, 0.7678, 0.877, 0.921397, 0.93399, 0.936483, 0.936483, 0.936483, 0.936483, 0.93399, 0.921397, 0.877, 0.7678, 0.580413, 0.35607, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.621962, 0.393428, 0.2322, 0.211196, 0.327843, 0.510804, 0.672198, 0.7678, 0.806669, 0.817695, 0.819877, 0.819877, 0.819877, 0.819877, 0.817695, 0.806669, 0.7678, 0.672198, 0.508143, 0.311734, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.621429, 0.390204, 0.21949, 0.175154, 0.251762, 0.386788, 0.508143, 0.580413, 0.609796, 0.61813, 0.61978, 0.61978, 0.61978, 0.61978, 0.61813, 0.609796, 0.580413, 0.508143, 0.384127, 0.235653, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.620792, 0.386345, 0.204274, 0.132004, 0.160676, 0.238314, 0.311734, 0.35607, 0.374095, 0.379208, 0.38022, 0.38022, 0.38022, 0.38022, 0.379208, 0.374095, 0.35607, 0.311734, 0.235653, 0.144567, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.620259, 0.383122, 0.191564, 0.095962, 0.084595, 0.114298, 0.147679, 0.168683, 0.177222, 0.179644, 0.180123, 0.180123, 0.180123, 0.180123, 0.179644, 0.177222, 0.168683, 0.147679, 0.111637, 0.068487, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.619949, 0.381243, 0.184158, 0.074958, 0.040259, 0.042028, 0.052076, 0.059483, 0.062494, 0.063348, 0.063517, 0.063517, 0.063517, 0.063517, 0.063348, 0.062494, 0.059483, 0.052076, 0.039367, 0.024151, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.619823, 0.38048, 0.181147, 0.066419, 0.022234, 0.012645, 0.013207, 0.015086, 0.015849, 0.016066, 0.016109, 0.016109, 0.016109, 0.016109, 0.016066, 0.015849, 0.015086, 0.013207, 0.009984, 0.006125, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.619787, 0.380263, 0.180292, 0.063997, 0.017121, 0.004311, 0.002182, 0.002492, 0.002618, 0.002654, 0.002661, 0.002661, 0.002661, 0.002661, 0.002654, 0.002618, 0.002492, 0.002182, 0.001649, 0.001012, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.61978, 0.38022, 0.180123, 0.063517, 0.016109, 0.002661, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.61978, 0.38022, 0.180123, 0.063517, 0.016109, 0.002661, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.61978, 0.38022, 0.180123, 0.063517, 0.016109, 0.002661, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.61978, 0.38022, 0.180123, 0.063517, 0.016109, 0.002661, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.61978, 0.38022, 0.180123, 0.063517, 0.016109, 0.002661, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.61978, 0.38022, 0.180123, 0.063517, 0.016109, 0.002661, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]}, {"blend": 5, "grow": 2, "values": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.999993, 0.999957, 0.999831, 0.999521, 0.998988, 0.998351, 0.997818, 0.997508, 0.997382, 0.997346, 0.997339, 0.997339, 0.997339, 0.997339, 0.997339, 0.997339, 0.997339, 0.997339, 0.997339, 0.997339, 0.997339, 0.997339, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.999957, 0.999741, 0.998977, 0.997098, 0.993875, 0.990016, 0.986793, 0.984914, 0.984151, 0.983934, 0.983891, 0.983891, 0.983891, 0.983891, 0.983891, 0.983891, 0.983891, 0.983891, 0.983891, 0.983891, 0.983891, 0.983891, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.999831, 0.998977, 0.995966, 0.988559, 0.975849, 0.960633, 0.947924, 0.940517, 0.937506, 0.936652, 0.936483, 0.936483, 0.936483, 0.936483, 0.936483, 0.936483, 0.936483, 0.936483, 0.936483, 0.936483, 0.936483, 0.936483, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.999521, 0.997098, 0.988559, 0.967556, 0.931513, 0.888363, 0.852321, 0.831318, 0.822778, 0.820356, 0.819877, 0.819877, 0.819877, 0.819877, 0.819877, 0.819877, 0.819877, 0.819877, 0.819877, 0.819877, 0.819877, 0.819877, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.998988, 0.993875, 0.975849, 0.931513, 0.855433, 0.764347, 0.688266, 0.64393, 0.625905, 0.620792, 0.61978, 0.61978, 0.61978, 0.61978, 0.61978, 0.61978, 0.61978, 0.61978, 0.61978, 0.61978, 0.61978, 0.61978, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.998351, 0.990016, 0.960633, 0.888363, 0.764347, 0.615873, 0.491857, 0.419587, 0.390204, 0.38187, 0.38022, 0.38022, 0.38022, 0.38022, 0.38022, 0.38022, 0.38022, 0.38022, 0.38022, 0.38022, 0.38022, 0.38022, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997818, 0.986793, 0.947924, 0.852321, 0.688266, 0.491857, 0.327802, 0.2322, 0.193331, 0.182305, 0.180123, 0.180123, 0.180123, 0.180123, 0.180123, 0.180123, 0.180123, 0.180123, 0.180123, 0.180123, 0.180123, 0.180123, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997508, 0.984914, 0.940517, 0.831317, 0.64393, 0.419587, 0.2322, 0.123, 0.078603, 0.06601, 0.063517, 0.063517, 0.063517, 0.063517, 0.063517, 0.063517, 0.063517, 0.063517, 0.063517, 0.063517, 0.063517, 0.063517, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997382, 0.984151, 0.937506, 0.822778, 0.625905, 0.390204, 0.193331, 0.078603, 0.031958, 0.018727, 0.016109, 0.016109, 0.016109, 0.016109, 0.016109, 0.016109, 0.016109, 0.016109, 0.016109, 0.016109, 0.016109, 0.016109, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997346, 0.983934, 0.936652, 0.820356, 0.620792, 0.38187, 0.182305, 0.06601, 0.018727, 0.005315, 0.002661, 0.002661, 0.002661, 0.002661, 0.002661, 0.002661, 0.002661, 0.002661, 0.002661, 0.002661, 0.002661, 0.002661, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.61978, 0.38022, 0.180123, 0.063517, 0.016109, 0.002661, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.61978, 0.38022, 0.180123, 0.063517, 0.016109, 0.002661, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.61978, 0.38022, 0.180123, 0.063517, 0.016109, 0.002661, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997339, 0.983891, 0.936483, 0.819877, 0.61978, 0.38022, 0.180123, 0.063517, 0.016109, 0.002661, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]}, {"blend": 3, "grow": -2, "values": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.995567, 0.941561, 0.699525, 0.300475, 0.058439, 0.004433, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.995567, 0.941561, 0.699525, 0.300475, 0.058439, 0.004433, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.995567, 0.941561, 0.699525, 0.300475, 0.058439, 0.004433, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.995567, 0.941561, 0.699525, 0.300475, 0.058439, 0.004433, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.995567, 0.941561, 0.699525, 0.300475, 0.058439, 0.004433, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.995567, 0.941561, 0.699525, 0.300475, 0.058439, 0.004433, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.995567, 0.941561, 0.699525, 0.300475, 0.058439, 0.004433, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.995567, 0.941561, 0.699525, 0.300475, 0.058439, 0.004433, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.995567, 0.941561, 0.699525, 0.300475, 0.058439, 0.004433, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.995567, 0.941561, 0.699525, 0.300475, 0.058439, 0.004433, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.995567, 0.941561, 0.699525, 0.300475, 0.058439, 0.004433, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.995567, 0.941561, 0.699525, 0.300475, 0.058439, 0.004433, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.995567, 0.941561, 0.699525, 0.300475, 0.058439, 0.004433, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.995567, 0.941561, 0.699525, 0.300475, 0.058439, 0.004433, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.995567, 0.941561, 0.699525, 0.300475, 0.058439, 0.004433, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.995567, 0.941561, 0.699525, 0.300475, 0.058439, 0.004433, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.995567, 0.941561, 0.699525, 0.300475, 0.058439, 0.004433, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.995567, 0.941561, 0.699525, 0.300475, 0.058439, 0.004433, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.995567, 0.941561, 0.699525, 0.300475, 0.058439, 0.004433, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.995567, 0.941561, 0.699525, 0.300475, 0.058439, 0.004433, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.995567, 0.941561, 0.699525, 0.300475, 0.058439, 0.004433, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.995567, 0.941561, 0.699525, 0.300475, 0.058439, 0.004433, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.995567, 0.941561, 0.699525, 0.300475, 0.058439, 0.004433, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.995567, 0.941561, 0.699525, 0.300475, 0.058439, 0.004433, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]}, {"blend": 0, "grow": 3, "values": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]}, {"blend": 0, "grow": 0, "values": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]}, {"blend": 12, "grow": 0, "values": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.999999, 0.999996, 0.999991, 0.999982, 0.999967, 0.999943, 0.999907, 0.999857, 0.999789, 0.999706, 0.999608, 0.9995, 0.999389, 0.999282, 0.999184, 0.9991, 0.999033, 0.998982, 0.998946, 0.998922, 0.998907, 0.998899, 0.998894, 0.998891, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.999996, 0.999988, 0.999973, 0.999946, 0.999901, 0.999828, 0.999718, 0.999563, 0.999357, 0.999102, 0.998803, 0.998475, 0.998137, 0.997809, 0.99751, 0.997255, 0.99705, 0.996895, 0.996785, 0.996712, 0.996666, 0.996639, 0.996624, 0.996616, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.999991, 0.999973, 0.999939, 0.999877, 0.999772, 0.999604, 0.999352, 0.998996, 0.998525, 0.997939, 0.997253, 0.9965, 0.995723, 0.99497, 0.994284, 0.993698, 0.993226, 0.992871, 0.992618, 0.99245, 0.992345, 0.992283, 0.992249, 0.992232, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.999982, 0.999947, 0.999878, 0.999752, 0.99954, 0.9992, 0.99869, 0.997971, 0.997018, 0.995832, 0.994445, 0.992923, 0.991351, 0.989829, 0.988442, 0.987256, 0.986303, 0.985584, 0.985074, 0.984734, 0.984522, 0.984397, 0.984327, 0.984292, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.999967, 0.999901, 0.999772, 0.99954, 0.999144, 0.998513, 0.997564, 0.996226, 0.994454, 0.992248, 0.98967, 0.986837, 0.983915, 0.981083, 0.978505, 0.976299, 0.974527, 0.973189, 0.97224, 0.971609, 0.971213, 0.970981, 0.970852, 0.970786, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.999943, 0.999828, 0.999604, 0.9992, 0.998512, 0.997414, 0.995764, 0.993438, 0.990357, 0.986521, 0.982038, 0.977113, 0.972033, 0.967108, 0.962625, 0.95879, 0.955708, 0.953382, 0.951733, 0.950634, 0.949946, 0.949542, 0.949319, 0.949203, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.999907, 0.999718, 0.999352, 0.99869, 0.997564, 0.995764, 0.993063, 0.989253, 0.984206, 0.977925, 0.970581, 0.962516, 0.954195, 0.94613, 0.938786, 0.932505, 0.927458, 0.923648, 0.920946, 0.919147, 0.918021, 0.917359, 0.916993, 0.916803, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.999857, 0.999563, 0.998996, 0.997971, 0.996226, 0.993438, 0.989253, 0.983351, 0.975532, 0.965801, 0.954425, 0.94193, 0.929039, 0.916545, 0.905168, 0.895437, 0.887618, 0.881716, 0.877531, 0.874743, 0.872999, 0.871973, 0.871407, 0.871113, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.999789, 0.999357, 0.998525, 0.997018, 0.994454, 0.990357, 0.984206, 0.975532, 0.964041, 0.94974, 0.933021, 0.914659, 0.895713, 0.877351, 0.860631, 0.846331, 0.83484, 0.826166, 0.820015, 0.815918, 0.813354, 0.811847, 0.811014, 0.810583, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.999706, 0.999102, 0.997939, 0.995832, 0.992248, 0.986521, 0.977925, 0.965801, 0.94974, 0.929752, 0.906384, 0.880719, 0.854239, 0.828573, 0.805205, 0.785217, 0.769156, 0.757032, 0.748436, 0.742709, 0.739125, 0.737019, 0.735855, 0.735251, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.999608, 0.998803, 0.997253, 0.994445, 0.98967, 0.982038, 0.970581, 0.954425, 0.933021, 0.906384, 0.875242, 0.841039, 0.80575, 0.771547, 0.740404, 0.713767, 0.692363, 0.676207, 0.664751, 0.657119, 0.652343, 0.649535, 0.647985, 0.647181, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.9995, 0.998475, 0.9965, 0.992923, 0.986837, 0.977113, 0.962516, 0.94193, 0.914659, 0.880719, 0.841039, 0.797459, 0.752495, 0.708915, 0.669235, 0.635295, 0.608023, 0.587438, 0.57284, 0.563116, 0.557031, 0.553454, 0.551478, 0.550454, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.999389, 0.998137, 0.995723, 0.991351, 0.983915, 0.972033, 0.954195, 0.929039, 0.895713, 0.854239, 0.80575, 0.752495, 0.69755, 0.644296, 0.595807, 0.554332, 0.521006, 0.495851, 0.478013, 0.46613, 0.458694, 0.454323, 0.451908, 0.450656, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.999282, 0.997809, 0.99497, 0.989829, 0.981083, 0.967108, 0.94613, 0.916544, 0.877351, 0.828573, 0.771547, 0.708915, 0.644296, 0.581664, 0.524638, 0.47586, 0.436666, 0.407081, 0.386103, 0.372128, 0.363382, 0.358241, 0.355402, 0.353929, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.999184, 0.99751, 0.994284, 0.988442, 0.978505, 0.962625, 0.938786, 0.905168, 0.860631, 0.805205, 0.740404, 0.669235, 0.595807, 0.524638, 0.459837, 0.404411, 0.359874, 0.326256, 0.302417, 0.286538, 0.2766, 0.270758, 0.267532, 0.265858, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.9991, 0.997255, 0.993698, 0.987256, 0.976299, 0.95879, 0.932505, 0.895437, 0.846331, 0.785217, 0.713767, 0.635295, 0.554332, 0.47586, 0.404411, 0.343297, 0.29419, 0.257123, 0.230838, 0.213328, 0.202371, 0.19593, 0.192373, 0.190527]}], "blur_sigma_2": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997782, 0.989009, 0.961985, 0.89716, 0.776051, 0.599838, 0.400162, 0.223949, 0.10284, 0.038014, 0.010991, 0.002218, 0.0, 0.0, 8.4e-05, 0.000418, 0.001445, 0.003909, 0.008429, 0.014794, 0.021357, 0.025592, 0.025592, 0.021357, 0.014794, 0.008429, 0.003909, 0.001445, 0.000418, 8.4e-05, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997782, 0.989009, 0.961985, 0.89716, 0.776051, 0.599838, 0.400162, 0.223949, 0.10284, 0.038014, 0.010991, 0.002218, 0.0, 0.0, 0.000228, 0.00113, 0.003909, 0.010576, 0.022803, 0.040022, 0.057778, 0.069233, 0.069233, 0.057778, 0.040022, 0.022803, 0.010576, 0.003909, 0.00113, 0.000228, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997782, 0.989009, 0.961985, 0.89716, 0.776051, 0.599838, 0.400162, 0.223949, 0.10284, 0.038014, 0.010991, 0.002218, 0.0, 0.0, 0.000492, 0.002437, 0.008429, 0.022803, 0.049165, 0.086291, 0.124574, 0.149272, 0.149272, 0.124574, 0.086291, 0.049165, 0.022803, 0.008429, 0.002437, 0.000492, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997782, 0.989009, 0.961985, 0.89716, 0.776051, 0.599838, 0.400162, 0.223949, 0.10284, 0.038014, 0.010991, 0.002218, 0.0, 0.0, 0.000863, 0.004278, 0.014794, 0.040022, 0.086291, 0.151454, 0.218645, 0.261994, 0.261994, 0.218645, 0.151454, 0.086291, 0.040022, 0.014794, 0.004278, 0.000863, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997782, 0.989009, 0.961985, 0.89716, 0.776051, 0.599838, 0.400162, 0.223949, 0.10284, 0.038014, 0.010991, 0.002218, 0.0, 0.0, 0.001246, 0.006175, 0.021357, 0.057778, 0.124574, 0.218645, 0.315645, 0.378226, 0.378226, 0.315645, 0.218645, 0.124574, 0.057778, 0.021357, 0.006175, 0.001246, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997782, 0.989009, 0.961985, 0.89716, 0.776051, 0.599838, 0.400162, 0.223949, 0.10284, 0.038014, 0.010991, 0.002218, 0.0, 0.0, 0.001493, 0.007399, 0.025592, 0.069233, 0.149272, 0.261994, 0.378226, 0.453213, 0.453213, 0.378226, 0.261994, 0.149272, 0.069233, 0.025592, 0.007399, 0.001493, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997782, 0.989009, 0.961985, 0.89716, 0.776051, 0.599838, 0.400162, 0.223949, 0.10284, 0.038014, 0.010991, 0.002218, 0.0, 0.0, 0.001493, 0.007399, 0.025592, 0.069233, 0.149272, 0.261994, 0.378226, 0.453213, 0.453213, 0.378226, 0.261994, 0.149272, 0.069233, 0.025592, 0.007399, 0.001493, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997782, 0.989009, 0.961985, 0.89716, 0.776051, 0.599838, 0.400162, 0.223949, 0.10284, 0.038014, 0.010991, 0.002218, 0.0, 0.0, 0.001246, 0.006175, 0.021357, 0.057778, 0.124574, 0.218645, 0.315645, 0.378226, 0.378226, 0.315645, 0.218645, 0.124574, 0.057778, 0.021357, 0.006175, 0.001246, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997782, 0.989009, 0.961985, 0.89716, 0.776051, 0.599838, 0.400162, 0.223949, 0.10284, 0.038014, 0.010991, 0.002218, 0.0, 0.0, 0.000863, 0.004278, 0.014794, 0.040022, 0.086291, 0.151454, 0.218645, 0.261994, 0.261994, 0.218645, 0.151454, 0.086291, 0.040022, 0.014794, 0.004278, 0.000863, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997782, 0.989009, 0.961985, 0.89716, 0.776051, 0.599838, 0.400162, 0.223949, 0.10284, 0.038014, 0.010991, 0.002218, 0.0, 0.0, 0.000492, 0.002437, 0.008429, 0.022803, 0.049165, 0.086291, 0.124574, 0.149272, 0.149272, 0.124574, 0.086291, 0.049165, 0.022803, 0.008429, 0.002437, 0.000492, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997782, 0.989009, 0.961985, 0.89716, 0.776051, 0.599838, 0.400162, 0.223949, 0.10284, 0.038014, 0.010991, 0.002218, 0.0, 0.0, 0.000228, 0.00113, 0.003909, 0.010576, 0.022803, 0.040022, 0.057778, 0.069233, 0.069233, 0.057778, 0.040022, 0.022803, 0.010576, 0.003909, 0.00113, 0.000228, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997782, 0.989009, 0.961985, 0.89716, 0.776051, 0.599838, 0.400162, 0.223949, 0.10284, 0.038014, 0.010991, 0.002218, 0.0, 0.0, 8.4e-05, 0.000418, 0.001445, 0.003909, 0.008429, 0.014794, 0.021357, 0.025592, 0.025592, 0.021357, 0.014794, 0.008429, 0.003909, 0.001445, 0.000418, 8.4e-05, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997782, 0.989009, 0.961985, 0.89716, 0.776051, 0.599838, 0.400162, 0.223949, 0.10284, 0.038014, 0.010991, 0.002218, 0.0, 0.0, 2.4e-05, 0.000121, 0.000418, 0.00113, 0.002437, 0.004278, 0.006175, 0.007399, 0.007399, 0.006175, 0.004278, 0.002437, 0.00113, 0.000418, 0.000121, 2.4e-05, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997782, 0.989009, 0.961985, 0.89716, 0.776051, 0.599838, 0.400162, 0.223949, 0.10284, 0.038014, 0.010991, 0.002218, 0.0, 0.0, 5e-06, 2.4e-05, 8.4e-05, 0.000228, 0.000492, 0.000863, 0.001246, 0.001493, 0.001493, 0.001246, 0.000863, 0.000492, 0.000228, 8.4e-05, 2.4e-05, 5e-06, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997782, 0.989009, 0.961985, 0.89716, 0.776051, 0.599838, 0.400162, 0.223949, 0.10284, 0.038014, 0.010991, 0.002218, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997782, 0.989009, 0.961985, 0.89716, 0.776051, 0.599838, 0.400162, 0.223949, 0.10284, 0.038014, 0.010991, 0.002218, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997782, 0.989009, 0.961985, 0.89716, 0.776051, 0.599838, 0.400162, 0.223949, 0.10284, 0.038014, 0.010991, 0.002218, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997782, 0.989009, 0.961985, 0.89716, 0.776051, 0.599838, 0.400162, 0.223949, 0.10284, 0.038014, 0.010991, 0.002218, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997782, 0.989009, 0.961985, 0.89716, 0.776051, 0.599838, 0.400162, 0.223949, 0.10284, 0.038014, 0.010991, 0.002218, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997782, 0.989009, 0.961985, 0.89716, 0.776051, 0.599838, 0.400162, 0.223949, 0.10284, 0.038014, 0.010991, 0.002218, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997782, 0.989009, 0.961985, 0.89716, 0.776051, 0.599838, 0.400162, 0.223949, 0.10284, 0.038014, 0.010991, 0.002218, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997782, 0.989009, 0.961985, 0.89716, 0.776051, 0.599838, 0.400162, 0.223949, 0.10284, 0.038014, 0.010991, 0.002218, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997782, 0.989009, 0.961985, 0.89716, 0.776051, 0.599838, 0.400162, 0.223949, 0.10284, 0.038014, 0.010991, 0.002218, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.997782, 0.989009, 0.961985, 0.89716, 0.776051, 0.599838, 0.400162, 0.223949, 0.10284, 0.038014, 0.010991, 0.002218, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], "grow_3": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], "shrink_3": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]} \ No newline at end of file diff --git a/tests/load_video_preview.test.mjs b/tests/load_video_preview.test.mjs index a2b3c61..9c0b2dc 100644 --- a/tests/load_video_preview.test.mjs +++ b/tests/load_video_preview.test.mjs @@ -7,6 +7,7 @@ import { dragTrimHandle, formatFps, loadSummary, + snapFrameCount, playbackBoundaryAction, responsivePreviewHeight, singleFrameFraction, @@ -176,3 +177,19 @@ test("fps formatting trims float noise but keeps real fractions", () => { assert.equal(formatFps(24000 / 1001), "23.976"); assert.equal(formatFps(0), "0"); }); + +test("snapFrameCount keeps the largest video-model count and free keeps everything", () => { + assert.equal(snapFrameCount(78, "8n+1"), 73); + assert.equal(snapFrameCount(97, "8n+1"), 97); + assert.equal(snapFrameCount(78, "4n+1"), 77); + assert.equal(snapFrameCount(78, "free"), 78); + assert.equal(snapFrameCount(5, "8n+1"), 1); + assert.equal(snapFrameCount(0, "8n+1"), 0); + assert.equal(snapFrameCount(30, "nonsense"), 30); +}); + +test("loadSummary reports the snapped count", () => { + // 3.0 s at 30 fps = 90 frames; 8n+1 keeps 89. + assert.equal(loadSummary(10, { start: 0, end: 3 }, 30, 1, 0, false, "8n+1"), "89 frames @ 30 fps"); + assert.equal(loadSummary(10, { start: 0, end: 3 }, 30, 1, 0, false, "free"), "90 frames @ 30 fps"); +}); diff --git a/tests/lora_stack.test.mjs b/tests/lora_stack.test.mjs index 046283d..5951bf7 100644 --- a/tests/lora_stack.test.mjs +++ b/tests/lora_stack.test.mjs @@ -307,6 +307,13 @@ test("nameless rows do not vote on the scale", () => { assert.equal(strengthBarScale([row("", 5), row("a", 0.5)]), 1); }); +test("strengthBarScale follows the enabled rows only", () => { + const off = { ...row("a", 2), enabled: false }; + assert.equal(strengthBarScale([off, row("b", 0.5)]), 1); + assert.equal(strengthBarScale([off, row("b", 1.5)]), 1.5); + assert.equal(strengthBarScale([off, { ...row("b", 1.5), enabled: false }]), 1); +}); + // ---------- span ---------- test("positive strength grows right of center", () => { diff --git a/tests/panel_guards.test.mjs b/tests/panel_guards.test.mjs index 11d9832..046bba6 100644 --- a/tests/panel_guards.test.mjs +++ b/tests/panel_guards.test.mjs @@ -104,6 +104,7 @@ test("a panel that should follow the node's height never declares computeSize", // Listed here rather than derived, so adding a panel is a deliberate call: // is this a stage that should grow, or a fixed row? const mustGrow = new Set([ + "resolution", "compare", "image_crop_rotate_pad", "input_preview", @@ -112,6 +113,7 @@ test("a panel that should follow the node's height never declares computeSize", "lora_loader", "save_video", "show_text", + "workflow_note", ]); // Fixed by design: a toolbar - content whose height is a constant, not a // viewport onto something bigger. @@ -130,7 +132,10 @@ test("a panel that should follow the node's height never declares computeSize", // is excluded from the free-space split so it could never absorb the // difference. It now grows: the stack flexes and the add button rides the // node's bottom edge. - const fixedByDesign = new Set(["lmstudio_chat"]); + // + // seed and run_timer are single rows of readouts and buttons under plain + // widgets - a constant height by nature, nothing bigger behind them. + const fixedByDesign = new Set(["run_timer", "save_image", "seed", "widget_cards"]); const seen = new Set(); for (const { name, source } of domWidgetEntries()) { seen.add(name); @@ -200,10 +205,14 @@ test("every panel root class carries border-box and an overflow clip", () => { lora_loader: ".ausboss-lora-body {", compare: ".ausboss-compare-root{", input_preview: ".ausboss-input-preview{", - lmstudio_chat: ".ausboss-chat-toolbar{", + resolution: ".ausboss-res-panel {", load_image_pad: ".ausboss-loadpad-root{", image_crop_rotate_pad: ".ausboss-transform-panel{", + save_image: ".ausboss-save{", show_text: ".ausboss-show-text{", + workflow_note: ".ausboss-note{", + seed: ".ausboss-seed{", + run_timer: ".ausboss-timer{", }; for (const { name, source } of domWidgetEntries()) { const marker = roots[name]; diff --git a/tests/panel_layout.test.mjs b/tests/panel_layout.test.mjs new file mode 100644 index 0000000..d4f995f --- /dev/null +++ b/tests/panel_layout.test.mjs @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ensureNodeMinHeight, fillNodeHeight } from "../js/shared/panel_layout.mjs"; + +test("fillNodeHeight drops computeSize and declares a floor with no ceiling", () => { + const widget = { computeSize: () => [0, 200], options: {} }; + fillNodeHeight(widget, { minWidth: 200, minHeight: () => 140, minNodeSize: [200, 90] }); + assert.equal(widget.computeSize, undefined); + assert.deepEqual(widget.computeLayoutSize(), { minWidth: 200, minHeight: 140 }); + assert.deepEqual(widget.options.minNodeSize, [200, 90]); +}); + +test("ensureNodeMinHeight grows a node that loaded shorter than its widgets need", () => { + const node = { size: [300, 100], computeSize: () => [300, 180], setSize(size) { this.size = size; } }; + assert.equal(ensureNodeMinHeight(node), true); + assert.deepEqual(node.size, [300, 180]); + assert.equal(ensureNodeMinHeight(node), false, "already tall enough: untouched"); + node.size = [300, 400]; + assert.equal(ensureNodeMinHeight(node), false, "taller than the floor: never shrunk"); + assert.deepEqual(node.size, [300, 400]); + assert.equal(ensureNodeMinHeight(null), false); +}); diff --git a/tests/resolution_math.test.mjs b/tests/resolution_math.test.mjs new file mode 100644 index 0000000..0c5a78a --- /dev/null +++ b/tests/resolution_math.test.mjs @@ -0,0 +1,332 @@ +// The Resolution node's pure geometry: snapping, ratio math, drag model, +// and megapixel retargeting. + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + DIM_MAX, + DIM_MIN, + MP_LADDER, + applyRatioAtBudget, + chipMatch, + clampDim, + dragResize, + fitRectInBox, + megapixels, + offGrid, + orientRatio, + outwardDelta, + parsePins, + parseRail, + defaultSizeForRatio, + pinLabel, + quickSizes, + ratioLabel, + ratioSizeForAxis, + ratioSizeNear, + ratioStride, + readout, + reduceRatio, + scrubDim, + serializePins, + sizeForMegapixels, + snapDim, + stageScale, + stepMegapixels, + strideWarning, + togglePin, +} from "../js/shared/resolution_math.mjs"; + +// ---------- snapping ---------- + +test("snapDim rounds to the step and never collapses below one step", () => { + assert.equal(snapDim(1000, 32), 992); + assert.equal(snapDim(1016, 32), 1024); + assert.equal(snapDim(5, 64), 64); +}); + +test("clampDim holds the 64..8192 range", () => { + assert.equal(clampDim(10), DIM_MIN); + assert.equal(clampDim(999999), DIM_MAX); + assert.equal(clampDim("1024"), 1024); +}); + +// ---------- ratio math ---------- + +test("reduceRatio finds the exact reduced ratio", () => { + assert.deepEqual(reduceRatio(1344, 768), { x: 7, y: 4 }); + assert.deepEqual(reduceRatio(1024, 1024), { x: 1, y: 1 }); + assert.deepEqual(reduceRatio(2048, 1024), { x: 2, y: 1 }); +}); + +test("ratioLabel is exact for small terms, approximate for monsters", () => { + assert.equal(ratioLabel(1920, 1080), "16:9"); + assert.equal(ratioLabel(1344, 768), "7:4"); + assert.equal(ratioLabel(1000, 707), "~1.41:1"); + assert.equal(ratioLabel(707, 1000), "~1:1.41"); +}); + +test("readout combines size, MP, and ratio", () => { + assert.equal(readout(1024, 1024), "1024 × 1024 · 1.05 MP · 1:1"); + assert.equal(megapixels(1344, 768), 1.03); +}); + +test("fitRectInBox letterboxes by the tighter side", () => { + assert.deepEqual(fitRectInBox(16, 9, 32, 32), { w: 32, h: 18 }); + assert.deepEqual(fitRectInBox(9, 16, 32, 32), { w: 18, h: 32 }); +}); + +// ---------- exact-ratio sizing ---------- + +test("ratioSizeForAxis keeps the ratio mathematically exact", () => { + const size = ratioSizeForAxis({ x: 7, y: 4 }, "width", 1400, 32); + assert.equal(size.width % 7, 0); + assert.equal(size.height % 4, 0); + assert.equal(size.width * 4, size.height * 7); +}); + +test("ratioSizeNear lands on the closest on-ratio size", () => { + const size = ratioSizeNear({ x: 1, y: 1 }, 1030, 1010, 32); + assert.deepEqual(size, { width: 1024, height: 1024 }); +}); + +// ---------- drag model ---------- + +test("outward deltas grow whichever edge is grabbed", () => { + assert.deepEqual(outwardDelta("e", 10, 0), { ox: 10, oy: 0 }); + assert.deepEqual(outwardDelta("w", -10, 0), { ox: 10, oy: 0 }); + assert.deepEqual(outwardDelta("n", 0, -10), { ox: 0, oy: 10 }); + assert.deepEqual(outwardDelta("se", 10, 10), { ox: 10, oy: 10 }); +}); + +test("an east drag grows width only, snapped", () => { + const size = dragResize({ + handle: "e", startWidth: 1024, startHeight: 1024, + dx: 50, dy: 0, scale: 0.25, snap: 32, + }); + // 50 CSS px outward at 0.25 px/px = 400 image px of growth -> 1424 -> snap 1440 + assert.deepEqual(size, { width: 1440, height: 1024 }); +}); + +test("snap off gives pixel freedom", () => { + const size = dragResize({ + handle: "e", startWidth: 1024, startHeight: 1024, + dx: 5, dy: 0, scale: 1, snapOn: false, + }); + assert.deepEqual(size, { width: 1034, height: 1024 }); +}); + +test("a ratio-locked corner drag cannot drift the ratio", () => { + const size = dragResize({ + handle: "se", startWidth: 1344, startHeight: 768, + dx: 100, dy: 3, scale: 0.5, snap: 32, lockRatio: true, + }); + assert.equal(size.width * 4, size.height * 7); + assert.ok(size.width > 1344, "grew outward"); +}); + +test("stageScale leaves label and handle headroom", () => { + assert.ok(stageScale(1024, 1024, 200, 200) < 200 / 1024); + assert.equal(stageScale(1024, 1024, 400, 400), (400 - 90) / 1024); + assert.ok(stageScale(1024, 1024, 50, 50) > 0, "tiny stages stay positive"); +}); + +// ---------- megapixel retarget ---------- + +test("sizeForMegapixels hits near the target on the exact ratio", () => { + const size = sizeForMegapixels(1344, 768, 2.0, 32); + assert.equal(size.width * 4, size.height * 7); + const mp = (size.width * size.height) / 1e6; + assert.ok(Math.abs(mp - 2.0) < 0.25, `got ${mp} MP`); +}); + +test("retargeting a square to 1MP lands on 992 or 1024", () => { + const size = sizeForMegapixels(2048, 2048, 1.0, 32); + assert.equal(size.width, size.height); + assert.ok([992, 1024].includes(size.width), `got ${size.width}`); +}); + +// ---------- the judge's fixture table: chip clicks hit real sizes ---------- + +test("from the 1024x1024 default, 7:4 at /32 yields exactly 1344x768", () => { + assert.deepEqual(applyRatioAtBudget(1024, 1024, { x: 7, y: 4 }, 32), + { width: 1344, height: 768 }); +}); + +test("from the 1024x1024 default, 16:9 at /16 yields exactly 1280x720", () => { + assert.deepEqual(applyRatioAtBudget(1024, 1024, { x: 16, y: 9 }, 16), + { width: 1280, height: 720 }); +}); + +test("chips preserve the current orientation", () => { + // Portrait canvas + landscape 3:2 chip -> a 2:3 portrait result. + const size = applyRatioAtBudget(768, 1120, { x: 3, y: 2 }, 32); + assert.ok(size.height > size.width, `stayed portrait, got ${size.width}x${size.height}`); + assert.equal(size.width * 3, size.height * 2); +}); + +test("stride math: one snap system for locked ratios", () => { + assert.equal(ratioStride({ x: 7, y: 4 }, 32), 32); // k=192 -> 1344x768 + assert.equal(ratioStride({ x: 16, y: 9 }, 16), 16); // k=80 -> 1280x720 + assert.equal(ratioStride({ x: 1, y: 1 }, 32), 32); + const size = ratioSizeForAxis({ x: 7, y: 4 }, "width", 1344, 32); + assert.equal(size.width % 32, 0); + assert.equal(size.height % 32, 0); +}); + +test("coarse strides warn so the drag never feels stuck silently", () => { + assert.equal(strideWarning({ x: 16, y: 9 }, 32), true); // width jumps 512 + assert.equal(strideWarning({ x: 7, y: 4 }, 32), false); + assert.equal(strideWarning({ x: 1, y: 1 }, 64), false); +}); + +// ---------- chip matching ---------- + +test("chipMatch is exact, near, or nothing - orientation-blind", () => { + assert.deepEqual(chipMatch(1344, 768, { x: 7, y: 4 }), { state: "exact", flipped: false }); + assert.deepEqual(chipMatch(768, 1344, { x: 7, y: 4 }), { state: "exact", flipped: true }); + assert.equal(chipMatch(1704, 960, { x: 16, y: 9 }).state, "near"); // ~1.775 vs 1.778 + assert.equal(chipMatch(1024, 1024, { x: 16, y: 9 }), null); +}); + +test("the rail renders in the canvas orientation", () => { + assert.deepEqual(orientRatio({ x: 3, y: 2 }, true), { x: 2, y: 3, label: "2:3" }); + assert.deepEqual(orientRatio({ x: 3, y: 2 }, false), { x: 3, y: 2, label: "3:2" }); + assert.deepEqual(orientRatio({ x: 1, y: 1 }, true), { x: 1, y: 1, label: "1:1" }); +}); + +test("parseRail survives junk and never empties", () => { + const rail = parseRail("1:1, junk, 21x9, 4:3, 4:3"); + assert.deepEqual(rail.map((r) => r.label), ["1:1", "21:9", "4:3"]); + assert.equal(parseRail("").length, 6); // shipped defaults +}); + +// ---------- quick sizes ---------- + +test("the 1:1 ladder IS the canonical square list", () => { + const list = quickSizes(1024, 1024, 64); + assert.deepEqual(list.map((s) => s.width), + [512, 768, 1024, 1280, 1536, 1792, 2048]); + for (const size of list) assert.equal(size.width, size.height); +}); + +test("every rung is on-ratio, on-grid, and sorted", () => { + const list = quickSizes(1344, 768, 32); + for (const size of list) { + assert.equal(size.width * 4, size.height * 7); + assert.equal(size.width % 32, 0); + assert.equal(size.height % 32, 0); + } + assert.ok(list.every((s, i) => i === 0 || s.width > list[i - 1].width)); +}); + +test("chip clicks land on the ratio's ~1MP default", () => { + assert.deepEqual(defaultSizeForRatio({ x: 1, y: 1 }, 64), + { width: 1024, height: 1024 }); + assert.deepEqual(defaultSizeForRatio({ x: 16, y: 9 }, 16), + { width: 1280, height: 720 }); + assert.deepEqual(defaultSizeForRatio({ x: 7, y: 4 }, 32), + { width: 1344, height: 768 }); +}); + +test("custom-ratio corner drags are fluid, not stride jumps", () => { + // 1408x1840 reduces to 88:115 - exact lock would jump by 1840px steps. + const small = dragResize({ + handle: "se", startWidth: 1408, startHeight: 1840, + dx: 8, dy: 8, scale: 0.2, snap: 16, lockRatio: true, + }); + assert.ok(Math.abs(small.width - 1408) <= 96, `moved ${small.width - 1408}`); + assert.ok(Math.abs(small.height - 1840) <= 96); + assert.equal(small.width % 16, 0); + assert.equal(small.height % 16, 0); + const bigger = dragResize({ + handle: "se", startWidth: 1408, startHeight: 1840, + dx: 40, dy: 40, scale: 0.2, snap: 16, lockRatio: true, + }); + assert.ok(bigger.width > small.width, "keeps growing smoothly"); + // ratio approximately kept (within a snap step of proportional) + assert.ok(Math.abs(bigger.width / bigger.height - 1408 / 1840) < 0.05); +}); + +test("16:9 at /16 offers 1280x720", () => { + const list = quickSizes(1280, 720, 16); + assert.ok(list.some((s) => s.width === 1280 && s.height === 720), + JSON.stringify(list)); + for (const size of list) assert.equal(size.width * 9, size.height * 16); +}); + +test("7:4 at /32 offers the 1344x768 video canvas", () => { + const list = quickSizes(1344, 768, 32); + assert.ok(list.some((s) => s.width === 1344 && s.height === 768), + JSON.stringify(list)); +}); + +test("awkward ratios yield short lists rather than junk", () => { + const list = quickSizes(768, 1120, 64); // 24:35 at /64: huge stride + assert.ok(list.length >= 1); + for (const size of list) assert.equal(size.width * 35, size.height * 24); +}); + +// ---------- ladder, pins, misc ---------- + +test("MP stepping walks the rung ladder from the nearest rung", () => { + assert.equal(stepMegapixels(1.03, +1), 1.5); + assert.equal(stepMegapixels(1.03, -1), 0.5); + assert.equal(stepMegapixels(MP_LADDER[0], -1), MP_LADDER[0]); +}); + +test("pins round-trip, toggle, and cap", () => { + let pins = parsePins(serializePins([{ w: 1344, h: 768 }])); + assert.deepEqual(pins, [{ w: 1344, h: 768 }]); + pins = togglePin(pins, 1024, 1024); + assert.equal(pins.length, 2); + pins = togglePin(pins, 1344, 768); // unpin + assert.deepEqual(pins, [{ w: 1024, h: 1024 }]); + assert.equal(pinLabel(pins[0]), "1024×1024"); + assert.deepEqual(parsePins("not json"), []); +}); + +test("offGrid and scrubDim behave", () => { + assert.equal(offGrid(1920, 1080, 32), true); + assert.equal(offGrid(1920, 1088, 32), false); + assert.equal(scrubDim(1024, 2, 32), 1024); // dead zone: click stays a click + assert.equal(scrubDim(1024, 43, 32), 1344); // 40px travel = 10 steps of 32 +}); + +test("applyRatioAtBudget takes an explicit orientation for a square canvas", () => { + const landscape = applyRatioAtBudget(1024, 1024, { x: 16, y: 9 }, 32, false); + const portrait = applyRatioAtBudget(1024, 1024, { x: 16, y: 9 }, 32, true); + assert.ok(landscape.width > landscape.height); + assert.ok(portrait.height > portrait.width); + assert.equal(portrait.width, landscape.height); + assert.equal(portrait.height, landscape.width); +}); + + +test("default ratio sizes preserve both orientations for every shape chip", () => { + for (const [x, y] of [[1, 1], [4, 3], [3, 2], [16, 9], [7, 4], [2, 1]]) { + for (const step of [8, 16, 32, 64]) { + const landscape = defaultSizeForRatio({ x, y }, step); + const portrait = defaultSizeForRatio({ x: y, y: x }, step); + assert.equal(landscape.width * y, landscape.height * x); + assert.deepEqual(portrait, { width: landscape.height, height: landscape.width }); + } + } +}); + + +test("budgets below one megapixel remain usable", () => { + const size = sizeForMegapixels(1024, 1024, 0.25, 16); + assert.deepEqual(size, { width: 496, height: 496 }); +}); + +test("extreme ratio and snap combinations always return bounded dimensions", () => { + for (const ratio of [{x:999,y:1},{x:1,y:999},{x:997,y:991}]) { + const size = defaultSizeForRatio(ratio, 1024); + assert.ok(size.width >= 64 && size.width <= 8192); + assert.ok(size.height >= 64 && size.height <= 8192); + } + assert.deepEqual(orientRatio({x:3,y:4}, true), {x:3,y:4,label:"3:4"}); + assert.deepEqual(orientRatio({x:3,y:4}, false), {x:4,y:3,label:"4:3"}); +}); diff --git a/tests/run_timer.test.mjs b/tests/run_timer.test.mjs new file mode 100644 index 0000000..9921556 --- /dev/null +++ b/tests/run_timer.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + HISTORY_LIMIT, + createTimer, + formatElapsed, + historyLine, + startTimer, + stopTimer, + tickTimer, +} from "../js/shared/run_timer.mjs"; + +test("a run starts, ticks and stops into the history", () => { + let state = createTimer(); + state = startTimer(state, 1000); + assert.equal(state.running, true); + state = tickTimer(state, 3500); + assert.equal(state.elapsed, 2.5); + state = stopTimer(state, 5000, "done"); + assert.equal(state.running, false); + assert.equal(state.elapsed, 4); + assert.deepEqual(state.history, [4]); + assert.equal(state.outcome, "done"); +}); + +test("only completed runs join the history, which is capped and newest first", () => { + let state = createTimer([1, 2, 3, 4, 5, 6]); + assert.deepEqual(state.history, [1, 2, 3, 4, 5]); + state = stopTimer(startTimer(state, 0), 10000, "error"); + assert.deepEqual(state.history, [1, 2, 3, 4, 5]); + assert.equal(state.outcome, "error"); + state = stopTimer(startTimer(state, 0), 7000, "done"); + assert.deepEqual(state.history, [7, 1, 2, 3, 4]); + assert.equal(state.history.length, HISTORY_LIMIT); +}); + +test("ticking or stopping an idle timer leaves the elapsed value alone", () => { + const state = createTimer(); + assert.equal(tickTimer(state, 99).elapsed, 0); + assert.equal(stopTimer(state, 99, "interrupted").outcome, "interrupted"); +}); + +test("createTimer ignores a corrupt saved history", () => { + assert.deepEqual(createTimer(["x", -1, 2.5, null]).history, [2.5]); + assert.deepEqual(createTimer("nope").history, []); +}); + +test("formatElapsed picks seconds, m:ss.t, or h:mm:ss", () => { + assert.equal(formatElapsed(0), "0.0 s"); + assert.equal(formatElapsed(12.34), "12.3 s"); + assert.equal(formatElapsed(65.37), "1:05.3"); + assert.equal(formatElapsed(3723.9), "1:02:03"); + assert.equal(formatElapsed(NaN), "—"); +}); + +test("historyLine joins the previous runs", () => { + assert.equal(historyLine([]), ""); + assert.equal(historyLine([4, 65.4]), "4.0 s · 1:05.4"); +}); diff --git a/tests/save_image_naming.test.mjs b/tests/save_image_naming.test.mjs new file mode 100644 index 0000000..6f8c1db --- /dev/null +++ b/tests/save_image_naming.test.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + namingMode, previewFolder, previewName, stripImageExtension, tagsEnabled, +} from "../js/shared/save_image_naming.mjs"; + +const now = new Date(2026, 8, 3, 14, 5, 9); + +test("namingMode: linked beats exact beats local", () => { + assert.equal(namingMode({ filenameLinked: true, exactName: "x" }), "linked"); + assert.equal(namingMode({ filenameLinked: false, exactName: " photo " }), "exact"); + assert.equal(namingMode({ filenameLinked: false, exactName: "" }), "local"); +}); + +test("previewName composes prefix and tags in the backend's order", () => { + const base = { filename_prefix: "sets/shot", format: "png", exact_name: "", name_counter: true }; + assert.equal(previewName(base, { now }), "shot_#####.png"); + assert.equal(previewName({ ...base, name_date: true, name_time: true, name_size: true, name_batch: true }, { now }), "shot_2026-09-03_14-05-09_WxH_#####_b###.png"); + assert.equal(previewName({ ...base, name_size: true, name_counter: false }, { now, size: { width: 1024, height: 1536 } }), "shot_1024x1536.png"); + assert.equal(previewName({ ...base, format: "webp lossless" }, { now }), "shot_#####.webp"); + assert.equal(previewName({ ...base, filename_prefix: "" }, { now }), "image_#####.png"); +}); + +test("previewName in exact and linked modes", () => { + assert.equal(previewName({ filename_prefix: "x", format: "jxl lossless", exact_name: "photo123.jpg" }), "photo123.jxl"); + assert.equal(previewName({ filename_prefix: "x", format: "png", exact_name: "", filenameLinked: true, name_counter: true }), "{{filename}}.png"); +}); + +test("previewFolder joins the output folder, the folder field and the prefix's subfolders", () => { + assert.equal(previewFolder({ output_dir: "", filename_prefix: "shot" }), "ComfyUI/output/"); + assert.equal(previewFolder({ output_dir: "datasets/portraits", filename_prefix: "sets/shot" }), "ComfyUI/output/datasets/portraits/sets/"); + assert.equal(previewFolder({ output_dir: "/mnt/data/", filename_prefix: "shot" }), "/mnt/data/"); + assert.equal(previewFolder({ output_dir: "", filename_prefix: "sets/shot", filenameLinked: true }), "ComfyUI/output/"); +}); + +test("tags only decorate a local name; extensions strip like the backend", () => { + assert.equal(tagsEnabled({ exact_name: "", filenameLinked: false }), true); + assert.equal(tagsEnabled({ exact_name: "photo" }), false); + assert.equal(tagsEnabled({ exact_name: "", filenameLinked: true }), false); + assert.equal(stripImageExtension("photo.JPG"), "photo"); + assert.equal(stripImageExtension("photo.v2"), "photo.v2"); +}); diff --git a/tests/seed_history.test.mjs b/tests/seed_history.test.mjs new file mode 100644 index 0000000..e1d205a --- /dev/null +++ b/tests/seed_history.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { SEED_HISTORY_LIMIT, formatSeed, pushSeed, seedFromExecuted } from "../js/shared/seed_history.mjs"; + +test("seedFromExecuted reads the ui payload in its shapes", () => { + assert.equal(seedFromExecuted({ seed: [42] }), 42); + assert.equal(seedFromExecuted({ seed: ["977"] }), 977); + assert.equal(seedFromExecuted({ seed: 7 }), 7); + assert.equal(seedFromExecuted({ seed: [-1] }), null); + assert.equal(seedFromExecuted({}), null); + assert.equal(seedFromExecuted(null), null); +}); + +test("pushSeed keeps newest first, dedups, and caps", () => { + assert.deepEqual(pushSeed([1, 2, 3], 2), [2, 1, 3]); + assert.deepEqual(pushSeed(null, 5), [5]); + assert.deepEqual(pushSeed([1, "x", -2], 9), [9, 1]); + const many = pushSeed([1, 2, 3, 4, 5, 6, 7, 8], 9); + assert.equal(many.length, SEED_HISTORY_LIMIT); + assert.equal(many[0], 9); + assert.deepEqual(pushSeed([3, 4], NaN), [3, 4]); +}); + +test("formatSeed", () => { + assert.equal(formatSeed(976771647159643), "976771647159643"); + assert.equal(formatSeed(undefined), "—"); +}); diff --git a/tests/stitch_preview.test.mjs b/tests/stitch_preview.test.mjs new file mode 100644 index 0000000..09f9770 --- /dev/null +++ b/tests/stitch_preview.test.mjs @@ -0,0 +1,52 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { blurMask, featherGeneratedMask, growShrinkMask, overlayPlan, stitchBlendFromMask } from "../js/shared/stitch_preview.mjs"; + +const fixture = JSON.parse(readFileSync(new URL("./fixtures/stitch_blend_parity.json", import.meta.url), "utf8")); +const { width, height } = fixture; +const mask = Float32Array.from(fixture.mask); +const maxDiff = (a, b) => a.reduce((m, v, i) => Math.max(m, Math.abs(v - b[i])), 0); + +test("grow and shrink match the backend square max filter", () => { + assert.ok(maxDiff(growShrinkMask(mask, width, height, 3), fixture.grow_3) < 1e-6); + assert.ok(maxDiff(growShrinkMask(mask, width, height, -3), fixture.shrink_3) < 1e-6); +}); + +test("gaussian blur matches the backend to float precision", () => { + assert.ok(maxDiff(blurMask(mask, width, height, 2), fixture.blur_sigma_2) < 2e-6); +}); + +test("stitchBlendFromMask reproduces the Python helper for every fixture case", () => { + for (const c of fixture.cases) { + const out = stitchBlendFromMask(mask, width, height, c.blend, c.grow); + assert.ok(maxDiff(out, c.values) < 2e-6, `blend ${c.blend} grow ${c.grow}`); + } +}); + +test("zero settings return an equal copy, not the same buffer", () => { + const out = stitchBlendFromMask(mask, width, height, 0, 0); + assert.deepEqual(Array.from(out), Array.from(mask)); + assert.notEqual(out, mask); +}); + +test("feather ramps into the kept side and never lowers the generated side", () => { + const out = featherGeneratedMask(mask, width, height, 3); + for (let i = 0; i < out.length; i++) assert.ok(out[i] >= mask[i] - 1e-7); + // row 20, just past the step at column 16: the ramp is still partially on; + // far from both the step and the fixture's island it stays exactly zero. + assert.ok(out[20 * width + 17] > 0.2 && out[20 * width + 17] < 1); + assert.equal(out[20 * width + 36], 0); +}); + +test("overlayPlan converts output pixels into work pixels through the resize", () => { + const plan = overlayPlan(1821, 1024, { width: 1280, height: 704 }, 384); + assert.equal(plan.width, 384); + assert.equal(plan.height, Math.round(1024 * 384 / 1821)); + // 32 output px is ~45.6 pre-resize px, then scaled to the work canvas. + const perOutputPixel = (1821 / 1280 + 1024 / 704) / 2 * plan.k; + assert.ok(Math.abs(plan.unit - perOutputPixel) < 1e-9); + const plain = overlayPlan(200, 100, null, 384); + assert.equal(plain.k, 1); + assert.equal(plain.unit, 1); +}); diff --git a/tests/test_align_helpers.py b/tests/test_align_helpers.py index de73ae9..5b74cf8 100644 --- a/tests/test_align_helpers.py +++ b/tests/test_align_helpers.py @@ -110,14 +110,18 @@ class AlignImageTests(unittest.TestCase): class AlignImageNodeTests(unittest.TestCase): - def test_node_returns_image_size_and_offsets(self): + def test_node_returns_image_and_size_only(self): + # The helper still reports offsets; the node's face stops at the + # three outputs a graph wires (offsets were dropped 2026-09-03). node = AusBossAlignImage() - out, width, height, ox, oy = node.align(ramp(1, 100, 100), 32, "pad") + result = node.align(ramp(1, 100, 100), 32, "pad") + self.assertEqual(len(result), 3) + out, width, height = result self.assertEqual((width, height), (128, 128)) self.assertEqual(tuple(out.shape), (1, 128, 128, 3)) - self.assertEqual((ox, oy), (14, 14)) - for value in (width, height, ox, oy): + for value in (width, height): self.assertIsInstance(value, int) + self.assertEqual(AusBossAlignImage.RETURN_NAMES, ("image", "width", "height")) def test_mode_choices_match_the_helper(self): widget = AusBossAlignImage.INPUT_TYPES()["required"]["mode"][0] diff --git a/tests/test_color_helpers.py b/tests/test_color_helpers.py index 6c251c8..7f61545 100644 --- a/tests/test_color_helpers.py +++ b/tests/test_color_helpers.py @@ -79,8 +79,6 @@ class ParseFillColorTests(unittest.TestCase): self.assertEqual(normalize_fill_color("definitely broken"), "#808080") -if __name__ == "__main__": - unittest.main() class FillColorWarningNamesTheWidgetTests(unittest.TestCase): @@ -163,3 +161,7 @@ class ColorMatchMethodTests(unittest.TestCase): def test_unknown_method_raises(self): with self.assertRaises(ValueError): match_colors(self.image, self.reference, 1.0, None, "vibes") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_image_save_helpers.py b/tests/test_image_save_helpers.py index fb466ff..f1e1047 100644 --- a/tests/test_image_save_helpers.py +++ b/tests/test_image_save_helpers.py @@ -202,5 +202,99 @@ class SaveImageNodeTests(unittest.TestCase): self.assertIs(node.VALIDATE_INPUTS(), True) + + +class LocalNamingTests(unittest.TestCase): + def test_stem_appends_tags_in_a_fixed_order(self): + from datetime import datetime + from nodes._image_save_helpers import name_stem + now = datetime(2026, 9, 3, 14, 5, 9) + self.assertEqual(name_stem("shot", {}, now, 1024, 1536), "shot") + self.assertEqual( + name_stem("shot", {"size": True, "time": True, "date": True}, now, 1024, 1536), + "shot_2026-09-03_14-05-09_1024x1536", + ) + + def test_local_names_counter_and_batch_shapes(self): + from nodes._image_save_helpers import plan_local_names + self.assertEqual(plan_local_names("s", "png", 1, counter=True, batch=False, next_counter=4), ["s_00004.png"]) + self.assertEqual(plan_local_names("s", "png", 2, counter=True, batch=False, next_counter=4), ["s_00004.png", "s_00005.png"]) + self.assertEqual(plan_local_names("s", "png", 2, counter=True, batch=True, next_counter=4), ["s_00004_b001.png", "s_00004_b002.png"]) + self.assertEqual(plan_local_names("s", "png", 1, counter=False, batch=True), ["s.png"]) + self.assertEqual(plan_local_names("s", "png", 2, counter=False, batch=False), ["s_001.png", "s_002.png"]) + self.assertEqual(plan_local_names("s", "png", 0, counter=True, batch=False), []) + + def test_next_free_counter_reads_only_its_own_stem(self): + from nodes._image_save_helpers import next_free_counter + names = ["s_00003.png", "s_00007_b002.webp", "s2_00099.png", "s_00011_.png", "s_abc.png"] + self.assertEqual(next_free_counter(names, "s"), 8) + self.assertEqual(next_free_counter([], "s"), 1) + + def test_split_prefix(self): + from nodes._image_save_helpers import split_prefix + self.assertEqual(split_prefix("datasets/portraits/shot"), ("datasets/portraits", "shot")) + self.assertEqual(split_prefix("shot"), ("", "shot")) + self.assertEqual(split_prefix(""), ("", "image")) + + +class WebpEncodeTests(unittest.TestCase): + def test_webp_lossless_roundtrips_bit_identical_with_exif_metadata(self): + from nodes._image_save_helpers import encode_image + frame = gradient_batch(1, 8, 16)[0] + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "x.webp" + encode_image(path, frame, "webp lossless", {"prompt": "{}", "workflow": "{\"a\": 1}"}) + with Image.open(path) as image: + back = torch.from_numpy(__import__("numpy").asarray(image.convert("RGB"))).float() / 255.0 + exif = image.getexif() + expected = (frame * 255).round().clamp(0, 255) / 255.0 + self.assertTrue(torch.allclose(back, expected, atol=1 / 255)) + self.assertTrue(str(exif.get(0x010F, "")).startswith("prompt:")) + + +class SaveImageLocalModeTests(SaveImageNodeTests): + def test_local_mode_appends_tags_and_takes_the_next_free_counter(self): + with tempfile.TemporaryDirectory() as tmp: + first = self.run_node(tmp, filename_prefix="sets/shot", name_size=True) + second = self.run_node(tmp, filename_prefix="sets/shot", name_size=True) + self.assertEqual(Path(first["result"][0]).name, "shot_16x8_00001.png") + self.assertEqual(Path(second["result"][0]).name, "shot_16x8_00002.png") + self.assertEqual(Path(first["result"][0]).parent, Path(tmp) / "sets") + self.assertEqual(first["ui"]["ausboss_saved_path"], ["sets/shot_16x8_00001.png"]) + + def test_batch_tag_shares_one_counter(self): + with tempfile.TemporaryDirectory() as tmp: + result = self.run_node(tmp, images=gradient_batch(2, 8, 16), filename_prefix="shot", name_batch=True) + names = sorted(p.name for p in Path(tmp).glob("*.png")) + self.assertEqual(names, ["shot_00001_b001.png", "shot_00001_b002.png"]) + self.assertEqual(Path(result["result"][0]).name, "shot_00001_b001.png") + + def test_counter_off_reuses_the_path_and_overwrites(self): + with tempfile.TemporaryDirectory() as tmp: + first = self.run_node(tmp, filename_prefix="shot", name_counter=False) + second = self.run_node(tmp, filename_prefix="shot", name_counter=False) + self.assertEqual(first["result"][0], second["result"][0]) + self.assertEqual([p.name for p in Path(tmp).glob("*.png")], ["shot.png"]) + + def test_linked_filename_wins_and_a_blank_one_stops_the_run(self): + with tempfile.TemporaryDirectory() as tmp: + result = self.run_node(tmp, exact_name="legacy", filename="from_upstream.jpg") + self.assertEqual(Path(result["result"][0]).name, "from_upstream.png") + with self.assertRaises(ValueError): + self.run_node(tmp, filename=" ") + + def test_linked_caption_beats_the_legacy_box(self): + with tempfile.TemporaryDirectory() as tmp: + result = self.run_node(tmp, exact_name="photo", caption="old", caption_text="new caption") + self.assertEqual((Path(tmp) / "photo.txt").read_text(encoding="utf-8"), "new caption") + result = self.run_node(tmp, exact_name="photo2", caption="old", caption_text="") + self.assertEqual((Path(tmp) / "photo2.txt").read_text(encoding="utf-8"), "old") + + def test_prefix_validation_rejects_escapes(self): + node = node_save_image.AusBossSaveImage + self.assertIsInstance(node.VALIDATE_INPUTS(filename_prefix="../x"), str) + self.assertIs(node.VALIDATE_INPUTS(filename_prefix="a/b"), True) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_inpaint_crop_helpers.py b/tests/test_inpaint_crop_helpers.py index f76e524..6f65e5d 100644 --- a/tests/test_inpaint_crop_helpers.py +++ b/tests/test_inpaint_crop_helpers.py @@ -17,6 +17,7 @@ if "nodes" in sys.modules and not hasattr(sys.modules["nodes"], "__path__"): from nodes import _inpaint_crop_helpers as inpaint_helpers from nodes._inpaint_crop_helpers import ( build_canvas_stitcher, + stitch_blend_from_mask, apply_stitch, build_crop, expand_rect_to_multiple, @@ -370,12 +371,22 @@ class BatchTests(unittest.TestCase): out = apply_stitch(stitcher, cropped) self.assertTrue(torch.equal(out, image)) - def test_mismatched_batches_are_rejected(self): + def test_a_shorter_generated_batch_stitches_the_leading_frames(self): + # A video model keeps 8n+1 frames and drops the tail: the frames + # that came back land on their own source frames, the rest go. + image = rand_image(5, 32, 32, seed=21) + mask = box_mask(32, 32, 8, 24, 8, 24) + cropped, _, stitcher = build_crop(image, mask, 1.5, 4, 8) + out = apply_stitch(stitcher, cropped[:3]) + self.assertEqual(out.shape[0], 3) + self.assertTrue(torch.equal(out, image[:3])) + + def test_a_longer_generated_batch_is_rejected(self): image = rand_image(3, 32, 32, seed=21) mask = box_mask(32, 32, 8, 24, 8, 24) cropped, _, stitcher = build_crop(image, mask, 1.5, 4, 8) with self.assertRaises(ValueError): - apply_stitch(stitcher, cropped[:2]) + apply_stitch(stitcher, torch.cat([cropped, cropped], dim=0)) def test_stitch_rejects_a_foreign_stitcher(self): with self.assertRaises(ValueError): @@ -607,7 +618,7 @@ class EdgeHaloTests(unittest.TestCase): stitch_cls = NODE_CLASS_MAPPINGS["AUSBOSS_NODES_StitchInpaint"] types = stitch_cls.INPUT_TYPES() self.assertEqual(list(types["required"]), ["stitcher", "inpainted"]) - self.assertEqual(list(types["optional"]), ["fix_edge_halo"]) + self.assertEqual(list(types["optional"]), ["fix_edge_halo", "color_match"]) kind, options = types["optional"]["fix_edge_halo"] self.assertEqual(kind, "BOOLEAN") self.assertIs(options["default"], False) @@ -927,5 +938,183 @@ class CanvasStitcherBboxTests(unittest.TestCase): ) + + +class ToneMatchTests(unittest.TestCase): + """color_match: the drift is read off the feathered band and undone.""" + + def _pad_stitcher(self): + # A 1x40x60 canvas: source in the middle 20 columns, padding either + # side, and a feathered blend ramping 8px into the source on each side. + canvas = torch.full((1, 40, 60, 3), 0.40) + canvas[:, :, 20:40, :] = 0.40 + blend = torch.ones((1, 40, 60)) + blend[:, :, 20:40] = 0.0 + ramp = torch.linspace(1.0, 0.0, 8) + blend[:, :, 20:28] = ramp.view(1, 1, 8) + blend[:, :, 32:40] = ramp.flip(0).view(1, 1, 8) + # The source sits in columns 20..40: the bbox is what lets the tone + # match measure each padded side line by line. + return inpaint_helpers.build_canvas_stitcher(canvas, blend, bbox=(20, 0, 40, 40)) + + def test_offset_is_recovered_from_a_partially_regenerated_band(self): + stitcher = self._pad_stitcher() + canvas, blend = stitcher["canvas"], stitcher["blend"] + # The sampler drifted +0.1 brighter everywhere it generated; in the + # band the drift shows scaled by the mask, as the sampler mixes it. + drift = 0.10 + inpainted = canvas + drift * blend.unsqueeze(-1) + offset = inpaint_helpers.estimate_tone_offset(inpainted, canvas, blend) + from nodes._color_helpers import rgb_to_lab + expected = rgb_to_lab(torch.full((1, 1, 1, 3), 0.50)) - rgb_to_lab(torch.full((1, 1, 1, 3), 0.40)) + self.assertTrue(torch.allclose(offset, expected.view(1, 3), atol=0.35), (offset, expected)) + + def test_no_band_means_no_offset(self): + canvas = torch.rand((1, 8, 8, 3)) + hard = (torch.rand((1, 8, 8)) > 0.5).float() + offset = inpaint_helpers.estimate_tone_offset(canvas + 0.2, canvas, hard) + self.assertTrue(torch.equal(offset, torch.zeros((1, 3)))) + + def test_color_match_moves_the_bands_and_leaves_the_source_bit_identical(self): + stitcher = self._pad_stitcher() + canvas, blend = stitcher["canvas"], stitcher["blend"] + inpainted = (canvas + 0.10 * blend.unsqueeze(-1)).clamp(0, 1) + plain = inpaint_helpers.apply_stitch(stitcher, inpainted, color_match=0.0) + matched = inpaint_helpers.apply_stitch(stitcher, inpainted, color_match=1.0) + # Source columns (blend 0) never change, matched or not. + self.assertTrue(torch.equal(matched[:, :, 28:32, :], canvas[:, :, 28:32, :])) + self.assertTrue(torch.equal(plain[:, :, 28:32, :], canvas[:, :, 28:32, :])) + # The fully generated bands come back down toward the canvas tone. + self.assertGreater(float(plain[:, :, :20, :].mean()), 0.49) + self.assertLess(float(matched[:, :, :20, :].mean()), 0.44) + self.assertGreater(float(matched[:, :, :20, :].mean()), 0.36) + + def test_zero_strength_is_the_plain_stitch(self): + stitcher = self._pad_stitcher() + inpainted = torch.rand_like(stitcher["canvas"]) + self.assertTrue(torch.equal( + inpaint_helpers.apply_stitch(stitcher, inpainted), + inpaint_helpers.apply_stitch(stitcher, inpainted, color_match=0.0), + )) + + def test_drift_that_varies_along_the_seam_is_matched_line_by_line(self): + stitcher = self._pad_stitcher() + canvas, blend = stitcher["canvas"], stitcher["blend"] + # Top half drifted brighter, bottom half darker - one global shift + # could not fix both; the per-row field must. + drift = torch.zeros((1, 40, 1, 1)) + drift[:, :20] = 0.05 + drift[:, 20:] = -0.05 + inpainted = (canvas + drift * blend.unsqueeze(-1)).clamp(0, 1) + matched = inpaint_helpers.apply_stitch(stitcher, inpainted, color_match=1.0) + top = float(matched[:, :16, :20, :].mean()) + bottom = float(matched[:, 24:, :20, :].mean()) + self.assertAlmostEqual(top, 0.40, delta=0.02) + self.assertAlmostEqual(bottom, 0.40, delta=0.02) + self.assertTrue(torch.equal(matched[:, :, 28:32, :], canvas[:, :, 28:32, :])) + + def test_a_line_far_from_its_side_average_is_clamped_not_matched(self): + # LINE_DRIFT_CLAMP: a line whose overlap differs from its band by + # more than a few LAB units is treated as content (a reflection + # under open water), so only the side average plus the clamp is + # removed. A twenty-unit step between the halves therefore keeps + # part of its drift - by design, not by accident. + stitcher = self._pad_stitcher() + canvas, blend = stitcher["canvas"], stitcher["blend"] + drift = torch.zeros((1, 40, 1, 1)) + drift[:, :20] = 0.12 + drift[:, 20:] = -0.08 + inpainted = (canvas + drift * blend.unsqueeze(-1)).clamp(0, 1) + matched = inpaint_helpers.apply_stitch(stitcher, inpainted, color_match=1.0) + top = float(matched[:, :16, :20, :].mean()) + bottom = float(matched[:, 24:, :20, :].mean()) + # Part of the drift comes off (the side average plus the clamp), but + # not all of it - unlike the flat 0.40 a clamp-free match reaches. + self.assertLess(top, 0.40 + 0.12 - 0.03) + self.assertGreater(top, 0.40 + 0.02) + self.assertGreater(bottom, 0.40 - 0.08 + 0.02) + self.assertLess(bottom, 0.40 - 0.02) + with_clamp_lifted = inpaint_helpers.LINE_DRIFT_CLAMP + try: + inpaint_helpers.LINE_DRIFT_CLAMP = 100.0 + flat = inpaint_helpers.apply_stitch(stitcher, inpainted, color_match=1.0) + finally: + inpaint_helpers.LINE_DRIFT_CLAMP = with_clamp_lifted + self.assertAlmostEqual(float(flat[:, :16, :20, :].mean()), 0.40, delta=0.03) + self.assertAlmostEqual(float(flat[:, 24:, :20, :].mean()), 0.40, delta=0.03) + + +class ToneMatchSeamTests(unittest.TestCase): + """The drift is read across the seam and blended between sides.""" + + def _four_side_stitcher(self): + # 100x100 canvas, source 60x60 in the middle, 20 px of padding on + # every side, feathered 8 px into the source. + canvas = torch.full((1, 100, 100, 3), 0.40) + mask = torch.ones((1, 100, 100)) + mask[:, 20:80, 20:80] = 0.0 + from nodes._pad_helpers import feather_pad_mask + blend = feather_pad_mask(mask, 20, 20, 20, 20, 8) + return inpaint_helpers.build_canvas_stitcher(canvas, blend, bbox=(20, 20, 80, 80)) + + def test_drift_is_read_outside_the_seam_not_in_the_mixed_band(self): + stitcher = self._four_side_stitcher() + canvas, blend = stitcher["canvas"], stitcher["blend"] + # The sampler painted the padding 0.1 brighter and, as samplers do, + # smeared that into the feathered band in proportion to the mask. + inpainted = (canvas + 0.10 * blend.unsqueeze(-1)).clamp(0, 1) + matched = inpaint_helpers.apply_stitch(stitcher, inpainted, color_match=1.0) + # Padding comes back to the canvas tone on all four sides ... + for region in (matched[:, 40:60, :16], matched[:, 40:60, 84:], matched[:, :16, 40:60], matched[:, 84:, 40:60]): + self.assertAlmostEqual(float(region.mean()), 0.40, delta=0.015) + # ... and so does the band, whose mixed pixels get the same share of + # the correction the sampler gave them. + self.assertAlmostEqual(float(matched[:, 40:60, 20:28].mean()), 0.40, delta=0.015) + self.assertTrue(torch.equal(matched[:, 40:60, 40:60], canvas[:, 40:60, 40:60])) + + def test_corner_blends_two_sides_without_a_crease(self): + stitcher = self._four_side_stitcher() + canvas, blend = stitcher["canvas"], stitcher["blend"] + # Left side drifted bright, top side dark: the field must turn the + # corner between them smoothly rather than switch on the diagonal. + drift = torch.zeros((1, 100, 100, 1)) + drift[:, :, :20] = 0.08 + drift[:, :20, 20:] = -0.08 + inpainted = (canvas + drift * blend.unsqueeze(-1)).clamp(0, 1) + field = inpaint_helpers.tone_offset_field(inpainted, canvas, blend, stitcher["source_bbox"]) + lum = field[0, :, :, 0] + # Far from the corner each side gets its own sign. + self.assertGreater(float(lum[50, 5]), 2.0) + self.assertLess(float(lum[5, 50]), -2.0) + # Along the corner's diagonal the field changes by small steps only: + # no pixel jumps by more than a fraction of the side difference. + jumps = [abs(float(lum[i, 19 - i]) - float(lum[i + 1, 18 - i])) for i in range(0, 18)] + self.assertLess(max(jumps), 0.25 * (float(lum[50, 5]) - float(lum[5, 50]))) + + +class StitchBlendFromMaskTests(unittest.TestCase): + def test_zero_settings_return_an_equal_copy(self): + mask = torch.zeros((1, 20, 30)) + mask[:, :, :10] = 1.0 + blend = stitch_blend_from_mask(mask, 0) + self.assertTrue(torch.equal(blend, mask)) + self.assertIsNot(blend, mask) + + def test_blend_ramps_into_the_kept_side_only(self): + mask = torch.zeros((1, 20, 30)) + mask[:, :, :10] = 1.0 + blend = stitch_blend_from_mask(mask, 3) + # Inside the band the blur kernel's float sum lands a hair under 1. + self.assertTrue(torch.all(blend[:, :, :10] >= 1.0 - 1e-5)) + self.assertGreater(float(blend[:, :, 10:13].min()), 0.0) + self.assertEqual(float(blend[:, :, 20:].max()), 0.0) + + def test_grow_moves_the_boundary_before_the_ramp(self): + mask = torch.zeros((1, 20, 30)) + mask[:, :, :10] = 1.0 + self.assertTrue(torch.all(stitch_blend_from_mask(mask, 0, 4)[:, :, :14] == 1.0)) + self.assertTrue(torch.all(stitch_blend_from_mask(mask, 0, -4)[:, :, 6:] == 0.0)) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_interpolate_helpers.py b/tests/test_interpolate_helpers.py index 7cf83c6..2044735 100644 --- a/tests/test_interpolate_helpers.py +++ b/tests/test_interpolate_helpers.py @@ -264,8 +264,6 @@ class FrameInterpolateNodeTests(unittest.TestCase): self.assertEqual(fps, 24.0) -if __name__ == "__main__": - unittest.main() class CopyPathIsBatchedTests(unittest.TestCase): @@ -381,3 +379,7 @@ class FlowSolvedOncePerPairTests(unittest.TestCase): jobs_for_pair = sum(1 for job in blends if (job.src_a, job.src_b) == pair) if jobs_for_pair <= 8: self.assertEqual(len(indices), 1, pair) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_lmstudio_helpers.py b/tests/test_lmstudio_helpers.py deleted file mode 100644 index 2227e19..0000000 --- a/tests/test_lmstudio_helpers.py +++ /dev/null @@ -1,485 +0,0 @@ -"""Offline tests for the LM Studio chat node. - -Everything except the real POST runs here; the POST itself is exercised -against a mocked urlopen, including every error path a local server can -produce. No test touches the network. -""" - -from __future__ import annotations - -import asyncio -import base64 -import io -import json -import sys -import unittest -import urllib.error -from pathlib import Path -from unittest.mock import patch - -import torch - -ROOT = Path(__file__).resolve().parent.parent -sys.path.insert(0, str(ROOT)) -if "nodes" in sys.modules and not hasattr(sys.modules["nodes"], "__path__"): - del sys.modules["nodes"] - -from nodes._lmstudio_helpers import ( - DEFAULT_ENDPOINT, - build_chat_payload, - chat_completions_url, - history_with_turn, - image_data_url, - models_url, - normalize_history, - describe_empty_reply, - parse_chat_text, - parse_finish_reason, - parse_reasoning_content, - parse_response_schema, - request_chat, - split_reasoning, -) -from nodes.node_lmstudio_chat import AusBossLmStudioChat - - -class ChatUrlTests(unittest.TestCase): - def test_every_reasonable_spelling_lands_on_the_route(self): - expected = "http://127.0.0.1:1234/v1/chat/completions" - for endpoint in ( - "http://127.0.0.1:1234/v1", - "http://127.0.0.1:1234/v1/", - "http://127.0.0.1:1234", - "127.0.0.1:1234", - "127.0.0.1:1234/v1", - "http://127.0.0.1:1234/v1/chat/completions", - ): - self.assertEqual(chat_completions_url(endpoint), expected, endpoint) - - def test_empty_endpoint_falls_back_to_the_default(self): - self.assertEqual( - chat_completions_url(""), - DEFAULT_ENDPOINT.rstrip("/") + "/chat/completions", - ) - self.assertEqual(chat_completions_url(None), chat_completions_url("")) - - def test_https_and_remote_hosts_survive(self): - self.assertEqual( - chat_completions_url("https://box.local:8080/v1"), - "https://box.local:8080/v1/chat/completions", - ) - - def test_models_url_mirrors_every_endpoint_spelling(self): - expected = "http://127.0.0.1:1234/v1/models" - for endpoint in ( - "http://127.0.0.1:1234/v1", - "http://127.0.0.1:1234", - "127.0.0.1:1234", - "http://127.0.0.1:1234/v1/chat/completions", - ): - self.assertEqual(models_url(endpoint), expected, endpoint) - self.assertEqual(models_url(""), DEFAULT_ENDPOINT.rstrip("/") + "/models") - - -class PayloadTests(unittest.TestCase): - def test_text_only_payload_is_the_plain_openai_shape(self): - payload = build_chat_payload("", "", "hello", None, 0.7, 512, 42) - self.assertEqual( - payload["messages"], [{"role": "user", "content": "hello"}] - ) - self.assertNotIn("model", payload) # empty = whatever is loaded - self.assertEqual(payload["max_tokens"], 512) - self.assertEqual(payload["seed"], 42) - self.assertIs(payload["stream"], False) - - def test_system_prompt_prepends_a_system_message(self): - payload = build_chat_payload("", "be terse", "hi", None, 0.7, 512, 0) - self.assertEqual(payload["messages"][0], {"role": "system", "content": "be terse"}) - self.assertEqual(len(payload["messages"]), 2) - - def test_blank_system_prompt_sends_no_system_message(self): - payload = build_chat_payload("", " ", "hi", None, 0.7, 512, 0) - self.assertEqual(len(payload["messages"]), 1) - - def test_image_becomes_a_typed_content_part(self): - payload = build_chat_payload("m", "", "what is this", "data:image/png;base64,AA==", 0.5, 256, 1) - content = payload["messages"][0]["content"] - self.assertEqual(content[0], {"type": "text", "text": "what is this"}) - self.assertEqual(content[1]["type"], "image_url") - self.assertEqual(content[1]["image_url"]["url"], "data:image/png;base64,AA==") - self.assertEqual(payload["model"], "m") - - def test_image_with_empty_prompt_sends_only_the_image(self): - payload = build_chat_payload("", "", "", "data:image/png;base64,AA==", 0.5, 256, 1) - content = payload["messages"][0]["content"] - self.assertEqual(len(content), 1) - self.assertEqual(content[0]["type"], "image_url") - - def test_max_tokens_minus_one_is_left_to_the_server(self): - self.assertNotIn("max_tokens", build_chat_payload("", "", "hi", None, 0.7, -1, 0)) - - def test_neutral_advanced_values_change_nothing(self): - plain = build_chat_payload("", "", "hi", None, 0.7, 512, 0) - neutral = build_chat_payload( - "", "", "hi", None, 0.7, 512, 0, - advanced={ - "top_p": 1.0, "top_k": 0, "repeat_penalty": 1.0, "min_p": 0.0, - "presence_penalty": 0.0, "thinking_mode": "model default", - "idle_unload_seconds": 0, - }, - ) - self.assertEqual(plain, neutral) - - def test_active_advanced_values_land_in_the_body(self): - payload = build_chat_payload( - "", "", "hi", None, 0.7, 512, 0, - advanced={ - "top_p": 0.9, "top_k": 40, "repeat_penalty": 1.1, "min_p": 0.05, - "presence_penalty": 0.5, "thinking_mode": "off", - "idle_unload_seconds": 300, - }, - ) - self.assertEqual(payload["top_p"], 0.9) - self.assertEqual(payload["top_k"], 40) - self.assertEqual(payload["repeat_penalty"], 1.1) - self.assertEqual(payload["min_p"], 0.05) - self.assertEqual(payload["presence_penalty"], 0.5) - self.assertEqual(payload["ttl"], 300) - self.assertEqual(payload["chat_template_kwargs"], {"enable_thinking": False}) - - def test_thinking_mode_on_asks_the_template_to_think(self): - payload = build_chat_payload( - "", "", "hi", None, 0.7, 512, 0, advanced={"thinking_mode": "on"} - ) - self.assertEqual(payload["chat_template_kwargs"], {"enable_thinking": True}) - - -class ParseTests(unittest.TestCase): - def test_plain_string_content(self): - data = {"choices": [{"message": {"content": "a reply"}}]} - self.assertEqual(parse_chat_text(data), "a reply") - - def test_typed_part_list_content(self): - data = {"choices": [{"message": {"content": [{"type": "text", "text": "a"}, {"type": "text", "text": "b"}]}}]} - self.assertEqual(parse_chat_text(data), "ab") - - def test_server_error_object_surfaces_its_message(self): - with self.assertRaises(RuntimeError) as caught: - parse_chat_text({"error": {"message": "No model loaded"}}) - self.assertIn("No model loaded", str(caught.exception)) - - def test_malformed_response_names_the_expected_shape(self): - for bad in ({}, {"choices": []}, {"choices": [{}]}, "nope", None): - with self.assertRaises(RuntimeError) as caught: - parse_chat_text(bad) - self.assertIn("choices[0].message.content", str(caught.exception)) - - -class SplitReasoningTests(unittest.TestCase): - def test_ordinary_replies_pass_through_with_empty_thinking(self): - self.assertEqual(split_reasoning("just an answer"), ("just an answer", "")) - - def test_think_block_is_moved_to_the_second_output(self): - text = "step 1\nstep 2The answer is 4." - self.assertEqual(split_reasoning(text), ("The answer is 4.", "step 1\nstep 2")) - - def test_multiple_blocks_join_and_case_does_not_matter(self): - text = "amidb end" - answer, thinking = split_reasoning(text) - self.assertEqual(answer, "mid end") - self.assertEqual(thinking, "a\n\nb") - - def test_an_unclosed_block_counts_as_reasoning_to_the_end(self): - # The model hit max_tokens mid-thought; the answer must not contain it. - answer, thinking = split_reasoning("ran out of tok") - self.assertEqual(answer, "") - self.assertEqual(thinking, "ran out of tok") - - def test_custom_tags_split_and_defaults_survive_blanks(self): - text = "<|sot|>plan<|eot|>done" - self.assertEqual(split_reasoning(text, "<|sot|>", "<|eot|>"), ("done", "plan")) - # Blank custom tags fall back to the defaults. - self.assertEqual( - split_reasoning("ab", "", ""), ("b", "a") - ) - - -class ImageDataUrlTests(unittest.TestCase): - def frames(self, batch=1, height=32, width=48): - return torch.rand((batch, height, width, 3)) - - def decode(self, url: str): - from PIL import Image - - prefix = "data:image/png;base64," - self.assertTrue(url.startswith(prefix)) - return Image.open(io.BytesIO(base64.b64decode(url[len(prefix):]))) - - def test_round_trips_as_a_png_of_the_same_size(self): - picture = self.decode(image_data_url(self.frames(), 0)) - self.assertEqual(picture.size, (48, 32)) - self.assertEqual(picture.mode, "RGB") - - def test_only_the_first_frame_of_a_batch_is_sent(self): - batch = torch.zeros((3, 8, 8, 3)) - batch[0] = 1.0 # white first frame, black rest - picture = self.decode(image_data_url(batch, 0)) - self.assertEqual(picture.getpixel((4, 4)), (255, 255, 255)) - - def test_max_edge_downscales_and_keeps_aspect(self): - picture = self.decode(image_data_url(self.frames(1, 400, 800), 200)) - self.assertEqual(picture.size, (200, 100)) - - def test_max_edge_zero_never_resizes(self): - picture = self.decode(image_data_url(self.frames(1, 400, 800), 0)) - self.assertEqual(picture.size, (800, 400)) - - def test_rejects_non_bhwc_input(self): - with self.assertRaises(ValueError): - image_data_url(torch.zeros((32, 32, 3)), 0) - - -class FakeReply: - def __init__(self, body: bytes): - self.body = body - - def read(self): - return self.body - - def __enter__(self): - return self - - def __exit__(self, *args): - return False - - -class RequestChatTests(unittest.TestCase): - URL = "http://127.0.0.1:1234/v1/chat/completions" - - def test_posts_json_and_returns_the_parsed_reply(self): - seen = {} - - def fake_urlopen(request, timeout=None): - seen["url"] = request.full_url - seen["body"] = json.loads(request.data.decode("utf-8")) - seen["timeout"] = timeout - return FakeReply(b'{"choices": [{"message": {"content": "ok"}}]}') - - with patch("urllib.request.urlopen", fake_urlopen): - data = request_chat(self.URL, {"messages": []}, 30) - self.assertEqual(parse_chat_text(data), "ok") - self.assertEqual(seen["url"], self.URL) - self.assertEqual(seen["body"], {"messages": []}) - self.assertEqual(seen["timeout"], 30) - - def test_connection_refused_says_how_to_start_the_server(self): - def fake_urlopen(request, timeout=None): - raise urllib.error.URLError(ConnectionRefusedError(111, "refused")) - - with patch("urllib.request.urlopen", fake_urlopen): - with self.assertRaises(RuntimeError) as caught: - request_chat(self.URL, {}, 30) - message = str(caught.exception) - self.assertIn("LM Studio server running", message) - self.assertIn(self.URL, message) - - def test_http_error_carries_the_server_message(self): - def fake_urlopen(request, timeout=None): - raise urllib.error.HTTPError( - self.URL, 404, "Not Found", {}, - io.BytesIO(b'{"error": {"message": "Model not found"}}'), - ) - - with patch("urllib.request.urlopen", fake_urlopen): - with self.assertRaises(RuntimeError) as caught: - request_chat(self.URL, {}, 30) - message = str(caught.exception) - self.assertIn("HTTP 404", message) - self.assertIn("Model not found", message) - - def test_timeout_names_the_wait_and_suggests_raising_it(self): - def fake_urlopen(request, timeout=None): - raise urllib.error.URLError(TimeoutError("timed out")) - - with patch("urllib.request.urlopen", fake_urlopen): - with self.assertRaises(RuntimeError) as caught: - request_chat(self.URL, {}, 45) - self.assertIn("45s", str(caught.exception)) - - def test_non_json_reply_points_at_the_endpoint_setting(self): - def fake_urlopen(request, timeout=None): - return FakeReply(b"a web page") - - with patch("urllib.request.urlopen", fake_urlopen): - with self.assertRaises(RuntimeError) as caught: - request_chat(self.URL, {}, 30) - self.assertIn("not with JSON", str(caught.exception)) - - -class ChatNodeTests(unittest.TestCase): - def run_chat(self, **overrides): - node = AusBossLmStudioChat() - arguments = dict( - prompt="describe a cat", - system_prompt="", - endpoint=DEFAULT_ENDPOINT, - model="", - temperature=0.7, - max_tokens=512, - seed=0, - timeout_seconds=30, - image_max_edge=1024, - image=None, - ) - arguments.update(overrides) - return asyncio.run(node.chat(**arguments)) - - def test_returns_text_and_split_thinking(self): - reply = {"choices": [{"message": {"content": "hmmA tabby cat."}}]} - with patch("nodes.node_lmstudio_chat.request_chat", return_value=reply) as sent: - text, thinking, history = self.run_chat() - self.assertEqual(text, "A tabby cat.") - self.assertEqual(thinking, "hmm") - url, payload, timeout = sent.call_args[0] - self.assertTrue(url.endswith("/v1/chat/completions")) - self.assertEqual(payload["messages"][0]["content"], "describe a cat") - self.assertEqual(timeout, 30) - - def test_empty_prompt_without_an_image_refuses_before_any_request(self): - with patch("nodes.node_lmstudio_chat.request_chat") as sent: - with self.assertRaises(ValueError) as caught: - self.run_chat(prompt=" ") - self.assertIn("type a prompt or connect an image", str(caught.exception)) - sent.assert_not_called() - - def test_an_image_alone_is_a_valid_request(self): - reply = {"choices": [{"message": {"content": "A photo."}}]} - with patch("nodes.node_lmstudio_chat.request_chat", return_value=reply) as sent: - text, _, _history = self.run_chat(prompt="", image=torch.rand((1, 16, 16, 3))) - self.assertEqual(text, "A photo.") - content = sent.call_args[0][1]["messages"][0]["content"] - self.assertEqual(content[0]["type"], "image_url") - - def test_the_entry_point_is_async(self): - import inspect - - self.assertTrue(inspect.iscoroutinefunction(AusBossLmStudioChat.chat)) - - -class HistoryTests(unittest.TestCase): - def test_normalize_keeps_only_clean_user_assistant_turns(self): - raw = [ - {"role": "user", "content": "hi"}, - {"role": "assistant", "content": "hello"}, - {"role": "system", "content": "nope"}, - {"role": "user", "content": ""}, - {"role": "user", "content": ["not", "a", "string"]}, - "garbage", - ] - self.assertEqual( - normalize_history(raw), - [{"role": "user", "content": "hi"}, {"role": "assistant", "content": "hello"}], - ) - self.assertEqual(normalize_history(None), []) - self.assertEqual(normalize_history("nope"), []) - - def test_history_turns_replay_between_system_and_the_new_prompt(self): - history = [{"role": "user", "content": "first"}, {"role": "assistant", "content": "reply"}] - payload = build_chat_payload( - "", "be brief", "second", None, 0.7, 128, 0, history=history - ) - roles = [message["role"] for message in payload["messages"]] - self.assertEqual(roles, ["system", "user", "assistant", "user"]) - self.assertEqual(payload["messages"][-1]["content"], "second") - - def test_history_with_turn_appends_the_exchange(self): - grown = history_with_turn(None, "ask", False, "answer") - self.assertEqual( - grown, - [{"role": "user", "content": "ask"}, {"role": "assistant", "content": "answer"}], - ) - with_image = history_with_turn(grown, "look", True, "seen") - self.assertEqual(with_image[2]["content"], "look [image attached]") - self.assertEqual(len(with_image), 4) - - -class ResponseSchemaTests(unittest.TestCase): - def test_empty_schema_means_plain_text(self): - self.assertIsNone(parse_response_schema("")) - self.assertIsNone(parse_response_schema(" ")) - payload = build_chat_payload("", "", "hi", None, 0.7, 64, 0) - self.assertNotIn("response_format", payload) - - def test_schema_rides_the_payload_as_strict_json_schema(self): - body = parse_response_schema('{"type": "object"}') - self.assertEqual(body["type"], "json_schema") - self.assertTrue(body["json_schema"]["strict"]) - payload = build_chat_payload( - "", "", "hi", None, 0.7, 64, 0, response_format=body - ) - self.assertEqual(payload["response_format"]["json_schema"]["schema"], {"type": "object"}) - - def test_bad_schema_raises_with_the_fix_spelled_out(self): - with self.assertRaisesRegex(ValueError, "not valid JSON"): - parse_response_schema("{nope") - with self.assertRaisesRegex(ValueError, "JSON object"): - parse_response_schema('"just a string"') - - -if __name__ == "__main__": - unittest.main() - - -# The gemma/qwen-style hybrid shape: the whole reply lands in a sibling -# reasoning_content field and `content` comes back empty. -REASONING_ONLY = { - "choices": [{ - "index": 0, - "message": {"role": "assistant", "content": "", - "reasoning_content": "* Subject: a cabin.\n* Dusk.", - "tool_calls": []}, - "finish_reason": "length", - }], -} - - -class ReasoningContentTests(unittest.TestCase): - def test_reads_the_servers_own_reasoning_field(self): - self.assertIn("Subject: a cabin", parse_reasoning_content(REASONING_ONLY)) - - def test_accepts_the_shorter_reasoning_spelling(self): - data = {"choices": [{"message": {"content": "", "reasoning": "thought"}}]} - self.assertEqual(parse_reasoning_content(data), "thought") - - def test_absent_reasoning_is_empty_not_an_error(self): - self.assertEqual(parse_reasoning_content({"choices": [{"message": {"content": "hi"}}]}), "") - self.assertEqual(parse_reasoning_content({}), "") - self.assertEqual(parse_reasoning_content(None), "") - - def test_blank_reasoning_does_not_count(self): - data = {"choices": [{"message": {"content": "", "reasoning_content": " "}}]} - self.assertEqual(parse_reasoning_content(data), "") - - def test_finish_reason_read_and_tolerated(self): - self.assertEqual(parse_finish_reason(REASONING_ONLY), "length") - self.assertEqual(parse_finish_reason({}), "") - self.assertEqual(parse_finish_reason(None), "") - - -class EmptyReplyDiagnosisTests(unittest.TestCase): - def test_truncated_thinking_names_the_token_budget(self): - message = describe_empty_reply("thought", "length", 300) - self.assertIn("all 300 tokens", message) - self.assertIn("max_tokens", message) - message.encode("ascii") - - def test_unknown_budget_stays_readable(self): - self.assertIn("its whole budget", describe_empty_reply("thought", "length", -1)) - - def test_reasoning_without_truncation_suggests_turning_thinking_off(self): - message = describe_empty_reply("thought", "stop", 512) - self.assertIn("thinking to 'off'", message) - - def test_no_reasoning_means_no_diagnosis(self): - # A plain model returning "" is a different problem; do not guess. - self.assertEqual(describe_empty_reply("", "length", 300), "") diff --git a/tests/test_lora_helpers.py b/tests/test_lora_helpers.py index 280adcf..b3b1a65 100644 --- a/tests/test_lora_helpers.py +++ b/tests/test_lora_helpers.py @@ -185,8 +185,6 @@ class LoraCivitaiSidecarTests(unittest.TestCase): self.assertFalse(_lora_helpers.lora_info("Krea 2/candid.safetensors")["has_civitai"]) -if __name__ == "__main__": - unittest.main() class TestMatchLoraName(unittest.TestCase): @@ -233,3 +231,83 @@ class TestMatchLoraName(unittest.TestCase): _lora_helpers.match_lora_name("gone.safetensors", self.AVAILABLE)[1], "missing", ) + + +class MissingRowPolicyTests(unittest.TestCase): + """on_missing: error (the default) stops before sampling, skip warns and keeps going.""" + + ROW = {"name": "gone.safetensors", "strength": 1.0, "strength_clip": 1.0, + "enabled": True, "triggers": ""} + + def setUp(self): + _lora_helpers._missing_warned.clear() + self.addCleanup(_lora_helpers._missing_warned.clear) + # apply_lora_stack imports comfy.sd lazily; the missing-file branch runs + # before any of it is used, so an empty stand-in module is enough. + import types + + fake_comfy = types.ModuleType("comfy") + fake_sd = types.ModuleType("comfy.sd") + fake_comfy.sd = fake_sd + self.modules = patch.dict(sys.modules, {"comfy": fake_comfy, "comfy.sd": fake_sd}) + self.modules.start() + self.addCleanup(self.modules.stop) + self.gone = patch.object( + _lora_helpers, "resolve_lora_path", + side_effect=ValueError("LoRA file not found in models/loras: gone.safetensors"), + ) + self.gone.start() + self.addCleanup(self.gone.stop) + + def test_missing_rows_ignores_disabled_and_zero_rows(self): + rows = [ + dict(self.ROW), + {**self.ROW, "name": "off.safetensors", "enabled": False}, + {**self.ROW, "name": "parked.safetensors", "strength": 0, "strength_clip": 0}, + ] + self.assertEqual( + [name for name, _ in _lora_helpers.missing_lora_rows(rows)], + ["gone.safetensors"], + ) + + def test_skip_warns_once_and_keeps_going(self): + buffer = io.StringIO() + with contextlib.redirect_stdout(buffer): + model, clip = _lora_helpers.apply_lora_stack( + "model", None, [dict(self.ROW)], on_missing="skip" + ) + _lora_helpers.apply_lora_stack("model", None, [dict(self.ROW)], on_missing="skip") + self.assertEqual((model, clip), ("model", None)) + self.assertEqual(buffer.getvalue().count("skipping 'gone.safetensors'"), 1) + + def test_error_is_the_default_and_names_the_file_and_the_switch(self): + for kwargs in ({}, {"on_missing": "error"}): + with self.assertRaises(ValueError) as caught: + _lora_helpers.apply_lora_stack("model", None, [dict(self.ROW)], **kwargs) + message = str(caught.exception) + self.assertIn("gone.safetensors", message) + self.assertIn("Stop on missing LoRA", message) + self.assertIn("settings menu", message) + self.assertIn("on_missing: skip", message) + message.encode("ascii") # Windows cp1252 consoles + + def test_node_validation_blocks_by_default_and_passes_in_skip_mode(self): + try: + from nodes.node_lora_loader import AusBossLoraLoader + except Exception as exc: # pragma: no cover - needs the package importable + self.skipTest(f"node module not importable offline: {exc}") + stack = json.dumps([dict(self.ROW)]) + self.assertIs(AusBossLoraLoader.VALIDATE_INPUTS(stack, on_missing="skip"), True) + for kwargs in ({}, {"on_missing": "error"}): + verdict = AusBossLoraLoader.VALIDATE_INPUTS(stack, **kwargs) + self.assertIsInstance(verdict, str) + self.assertIn("gone.safetensors", verdict) + self.assertIn("settings menu", verdict) + verdict.encode("ascii") + # The input's declared default matches the code paths above. + on_missing = AusBossLoraLoader.INPUT_TYPES()["optional"]["on_missing"] + self.assertEqual(on_missing[1]["default"], "error") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mask_helpers.py b/tests/test_mask_helpers.py index 3b5e625..d9a1b64 100644 --- a/tests/test_mask_helpers.py +++ b/tests/test_mask_helpers.py @@ -295,8 +295,6 @@ class MattingSmokeTests(unittest.TestCase): self.assertEqual(float(refined.sum()), 0.0) -if __name__ == "__main__": - unittest.main() class SmoothPreservesSoftAlphaTests(unittest.TestCase): @@ -417,3 +415,7 @@ class SeparableMorphologyTests(unittest.TestCase): def test_zero_is_still_the_same_object(self): mask = torch.rand((1, 8, 8)) self.assertIs(grow_shrink_mask(mask, 0), mask) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pad_helpers.py b/tests/test_pad_helpers.py index eb577e2..32b992e 100644 --- a/tests/test_pad_helpers.py +++ b/tests/test_pad_helpers.py @@ -145,8 +145,6 @@ class PillarboxBlurModeTests(unittest.TestCase): ) -if __name__ == "__main__": - unittest.main() class LowResBackdropBlurTests(unittest.TestCase): @@ -284,6 +282,34 @@ class FeatherPadMaskTests(unittest.TestCase): self.assertLessEqual(float(out.max()), 1.0) +class FeatherCornerTests(unittest.TestCase): + """Two padded sides meet without a crease: the ramps unite instead of + taking their maximum, so the corner is bilinear.""" + + def test_corner_is_the_union_of_the_two_ramps(self): + _, mask = pad_image(rand_image(1, 16, 20, seed=11), 3, 5, 2, 4, "color") + out = feather_pad_mask(mask, 3, 5, 2, 4, 3) + # Row 5 is the first image row (top ramp 0.75), col 3 the first image + # column (left ramp 0.75): union 1 - 0.25 * 0.25, not max 0.75. + self.assertAlmostEqual(float(out[0, 5, 3]), 1.0 - 0.25 * 0.25, places=5) + self.assertAlmostEqual(float(out[0, 6, 4]), 1.0 - 0.5 * 0.5, places=5) + # Along a side the other ramp is 0, so nothing changes there. + self.assertAlmostEqual(float(out[0, 5, 12]), 0.75, places=5) + self.assertAlmostEqual(float(out[0, 10, 3]), 0.75, places=5) + + def test_corner_has_no_diagonal_crease(self): + # With max(), stepping across the corner's diagonal flips which ramp + # wins and the gradient jumps. With the union the value along the + # diagonal is symmetric and the differences to its two neighbours + # are equal - no crease to print. + _, mask = pad_image(rand_image(1, 60, 60, seed=12), 20, 20, 20, 20, "color") + out = feather_pad_mask(mask, 20, 20, 20, 20, 16) + for step in range(1, 15): + y, x = 20 + step, 20 + step + self.assertAlmostEqual(float(out[0, y, x - 1]), float(out[0, y - 1, x]), places=5) + self.assertLess(float(out[0, y, x]), float(out[0, y, x - 1])) + + class LoadImagePadNodeTests(unittest.TestCase): def make_node(self): from nodes.node_load_image_pad import ( @@ -454,3 +480,7 @@ class LoadImagePadNodeTests(unittest.TestCase): second = cls.IS_CHANGED(image=path, pad_left=2, feather=0) self.assertIsInstance(first, str) self.assertNotEqual(first, second) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_preview_helpers.py b/tests/test_preview_helpers.py index 93c43ed..c409fe3 100644 --- a/tests/test_preview_helpers.py +++ b/tests/test_preview_helpers.py @@ -196,5 +196,38 @@ class NodePreviewContractTests(unittest.TestCase): self.assertNotEqual(factory()._prefix, factory()._prefix) + + +class PreviewSwitchTests(unittest.TestCase): + """preview=False returns the plain outputs and writes nothing.""" + + def test_every_preview_node_offers_the_switch_off_by_widget(self): + for module, cls in ( + (node_select_frame, "AusBossSelectFrame"), + (node_refine_mask, "AusBossRefineMask"), + (node_lama_inpaint, "AusBossLaMaInpaint"), + ): + optional = getattr(module, cls).INPUT_TYPES()["optional"] + self.assertIn("preview", optional, cls) + self.assertEqual(optional["preview"][0], "BOOLEAN", cls) + self.assertTrue(optional["preview"][1]["default"], cls) + + def test_select_frame_off_returns_a_bare_tuple(self): + with patch.object(_preview_helpers, "save_temp_preview") as save: + result = node_select_frame.AusBossSelectFrame().select_frame(torch.zeros((3, 8, 8, 3)), 2, preview=False) + self.assertIsInstance(result, tuple) + self.assertEqual(tuple(result[0].shape), (1, 8, 8, 3)) + save.assert_not_called() + + def test_mask_refine_off_returns_both_masks_without_a_preview(self): + with patch.object(_preview_helpers, "save_temp_preview") as save: + result = node_refine_mask.AusBossRefineMask().refine( + torch.ones((1, 8, 8)), 0, 0.0, False, 0, 0.0, 1.0, "off", preview=False, + ) + self.assertIsInstance(result, tuple) + self.assertEqual(len(result), 2) + save.assert_not_called() + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_registry_status.py b/tests/test_registry_status.py new file mode 100644 index 0000000..59ed24d --- /dev/null +++ b/tests/test_registry_status.py @@ -0,0 +1,47 @@ +"""A green upload must never stand in for the target version's approval.""" + +import json +from pathlib import Path +import sys +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) +from registry_status import version_status + + +class RegistryStatusTests(unittest.TestCase): + def entry(self, version, status, **extra): + return {"node_id": "ausboss-nodes", "version": version, "status": status, **extra} + + def test_older_active_version_does_not_clear_target(self): + versions = [self.entry("1.3.0", "NodeVersionStatusActive"), + self.entry("2.0.0", "NodeVersionStatusFlagged")] + self.assertEqual(version_status(versions, "2.0.0")[0], "NodeVersionStatusFlagged") + + def test_absent_target_is_not_published(self): + self.assertEqual(version_status([], "2.0.0")[0], "NotPublished") + + def test_current_decision_wins_over_history(self): + reason = json.dumps({"message": "Needs a fix", "statusHistory": [{"status": "NodeVersionStatusActive"}]}) + result = version_status([self.entry("2.0.0", "NodeVersionStatusBanned", status_reason=reason)], "2.0.0") + self.assertEqual(result, ("NodeVersionStatusBanned", "Needs a fix")) + + def test_exact_active_target(self): + self.assertEqual(version_status([self.entry("2.0.0", "NodeVersionStatusActive")], "2.0.0"), + ("NodeVersionStatusActive", "")) + + def test_wrong_pack_and_unknown_status_are_errors(self): + for entry in [self.entry("2.0.0", "FutureStatus"), + self.entry("2.0.0", "NodeVersionStatusActive", node_id="different-pack")]: + with self.subTest(entry=entry), self.assertRaises(ValueError): + version_status([entry], "2.0.0") + + def test_malformed_and_duplicate_responses_are_errors(self): + entry = self.entry("2.0.0", "NodeVersionStatusActive") + for payload in [{"status": "NodeStatusActive"}, [None], [entry, entry]]: + with self.subTest(payload=payload), self.assertRaises(ValueError): + version_status(payload, "2.0.0") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_resolution.py b/tests/test_resolution.py new file mode 100644 index 0000000..b61eb95 --- /dev/null +++ b/tests/test_resolution.py @@ -0,0 +1,69 @@ +"""Resolution Master 🆎 backend: clamping and the latent output.""" + +import sys +import unittest +from unittest.mock import patch +from types import SimpleNamespace +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from nodes.node_resolution import ( + DIM_MAX, + DIM_MIN, + LATENT_FAMILIES, + clamp_dimension, + latent_shape, +) + +from nodes import node_resolution +ResolutionNode = getattr(node_resolution, "AusBossResolution", getattr(node_resolution, "AusBossLabResolution", None)) + + +class TestClampDimension(unittest.TestCase): + def test_passthrough_and_clamp(self): + self.assertEqual(clamp_dimension(1344), 1344) + self.assertEqual(clamp_dimension(5), DIM_MIN) + self.assertEqual(clamp_dimension(999999), DIM_MAX) + + def test_junk_falls_back(self): + self.assertEqual(clamp_dimension("wide"), 1024) + self.assertEqual(clamp_dimension(None), 1024) + self.assertEqual(clamp_dimension(float("inf")), 1024) + + +class TestLatentShape(unittest.TestCase): + def test_families_match_the_core_empty_latent_nodes(self): + # EmptySD3LatentImage: [b, 16, h//8, w//8]; EmptyLatentImage: [b, 4, h//8, w//8]; + # EmptyFlux2LatentImage: [b, 128, h//16, w//16]. + self.assertEqual(latent_shape(1024, 768, "16ch", 1), (1, 16, 96, 128)) + self.assertEqual(latent_shape(1024, 768, "4ch", 2), (2, 4, 96, 128)) + self.assertEqual(latent_shape(1024, 768, "128ch", 1), (1, 128, 48, 64)) + + def test_unknown_family_and_bad_batch_fall_back(self): + self.assertEqual(latent_shape(512, 512, "nope", "x"), (1, 16, 64, 64)) + self.assertEqual(latent_shape(512, 512, "16ch", 999), (64, 16, 64, 64)) + + def test_combo_options_are_the_family_labels(self): + options = ResolutionNode.INPUT_TYPES()["optional"]["latent"][0] + self.assertEqual(options, list(LATENT_FAMILIES)) + + +class TestResolve(unittest.TestCase): + def test_typed_values_survive_exactly_and_the_latent_follows(self): + # Snap governs gestures; the backend never re-snaps 1920x1080. + fake_torch = SimpleNamespace(zeros=lambda shape, **kwargs: SimpleNamespace(shape=tuple(shape))) + with patch("nodes.node_resolution.torch", fake_torch), patch("nodes.node_resolution.model_management", None): + width, height, latent = ResolutionNode().resolve(1920, 1080) + self.assertEqual((width, height), (1920, 1080)) + samples = latent["samples"] + shape = tuple(samples.shape) if samples is not None else latent["shape"] + self.assertEqual(shape, (1, 16, 135, 240)) + + def test_outputs_are_width_height_latent(self): + self.assertEqual(ResolutionNode.RETURN_NAMES, ("width", "height", "latent")) + self.assertEqual(ResolutionNode.RETURN_TYPES, ("INT", "INT", "LATENT")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_transform_engine.py b/tests/test_transform_engine.py index 4a23584..d073e85 100644 --- a/tests/test_transform_engine.py +++ b/tests/test_transform_engine.py @@ -302,8 +302,6 @@ class LocalPreviewGateTests(unittest.TestCase): self.assertTrue(local_preview_allowed(str(Path.home() / "video.mp4"))) -if __name__ == "__main__": - unittest.main() class TestScaleToMegapixels(unittest.TestCase): @@ -321,3 +319,7 @@ class TestScaleToMegapixels(unittest.TestCase): def test_never_below_one_step(self): width, height = scale_to_megapixels(100, 100, 0.01, 64) self.assertEqual((width, height), (128, 128)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_utility_nodes.py b/tests/test_utility_nodes.py index c7d978c..f7b9ede 100644 --- a/tests/test_utility_nodes.py +++ b/tests/test_utility_nodes.py @@ -14,7 +14,10 @@ if "nodes" in sys.modules and not hasattr(sys.modules["nodes"], "__path__"): from nodes.node_free_memory import AusBossFreeMemory from nodes.node_literals import AusBossFloat, AusBossInteger, AusBossText from nodes.node_math import AusBossMathExpression +from nodes.node_run_timer import AusBossRunTimer +from nodes.node_seed import MAX_SEED, AusBossSeed from nodes.node_show_text import AusBossShowText +from nodes.node_workflow_note import AusBossWorkflowNote class LiteralNodeTests(unittest.TestCase): @@ -69,5 +72,44 @@ class FreeMemoryNodeTests(unittest.TestCase): self.assertEqual(str(AusBossFreeMemory.RETURN_TYPES[0]), "*") + + +class SeedNodeTests(unittest.TestCase): + def test_reports_the_seed_it_ran_with_and_passes_it_through(self): + result = AusBossSeed().emit(976771647159643) + self.assertEqual(result["ui"], {"seed": [976771647159643]}) + self.assertEqual(result["result"], (976771647159643,)) + + def test_seed_widget_covers_the_sampler_range_and_has_a_control(self): + spec = AusBossSeed.INPUT_TYPES()["required"]["seed"] + self.assertEqual(spec[0], "INT") + self.assertEqual(spec[1]["min"], 0) + self.assertEqual(spec[1]["max"], MAX_SEED) + self.assertEqual(spec[1]["max"], 0xFFFFFFFFFFFFFFFF) + self.assertTrue(spec[1]["control_after_generate"]) + self.assertEqual(AusBossSeed.RETURN_TYPES, ("INT",)) + + +class PresentationNodeTests(unittest.TestCase): + """Workflow Note and Run Timer never execute: no outputs, not output nodes.""" + + def test_workflow_note_stores_the_card_in_one_string_widget(self): + inputs = AusBossWorkflowNote.INPUT_TYPES() + self.assertEqual(list(inputs["required"]), ["note"]) + spec = inputs["required"]["note"] + self.assertEqual(spec[0], "STRING") + self.assertTrue(spec[1]["multiline"]) + self.assertEqual(spec[1]["default"], "{}") + self.assertEqual(AusBossWorkflowNote.RETURN_TYPES, ()) + self.assertFalse(getattr(AusBossWorkflowNote, "OUTPUT_NODE", False)) + self.assertEqual(AusBossWorkflowNote().noop("{}"), ()) + + def test_run_timer_has_no_wires_at_all(self): + self.assertEqual(AusBossRunTimer.INPUT_TYPES(), {"required": {}}) + self.assertEqual(AusBossRunTimer.RETURN_TYPES, ()) + self.assertFalse(getattr(AusBossRunTimer, "OUTPUT_NODE", False)) + self.assertEqual(AusBossRunTimer().noop(), ()) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_video_clip_transform.py b/tests/test_video_clip_transform.py new file mode 100644 index 0000000..2c79c11 --- /dev/null +++ b/tests/test_video_clip_transform.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import asyncio +import sys +import tempfile +import unittest +from pathlib import Path + +import torch + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "tests")) +if "nodes" in sys.modules and not hasattr(sys.modules["nodes"], "__path__"): + del sys.modules["nodes"] + +from nodes._transform_engine import ( + TransformSpec, + transform_tensor_batch, + transform_tensor_batch_chunked, +) +from nodes._inpaint_crop_helpers import STITCHER_KIND, apply_stitch, stitch_blend_from_mask +from nodes._transform_inputs import resize_inputs, transform_inputs +from nodes.node_video_crop_rotate_pad_clip import AusBossVideoCropRotatePadClip, snap_frame_count +from test_video_load_helpers import FPS, FRAMES, HEIGHT, WIDTH, ensure_core_video_api, write_test_video + + +def transform_defaults(**overrides) -> dict: + values = {name: spec[1]["default"] for name, spec in transform_inputs().items()} + values.update({name: spec[1]["default"] for name, spec in resize_inputs().items()}) + values.update(overrides) + return values + + +class ChunkedTransformTests(unittest.TestCase): + def test_chunks_match_the_single_pass_transform(self): + torch.manual_seed(7) + batch = torch.rand((5, 40, 56, 3)) + spec = TransformSpec(rotation_degrees=12.5, pad_left=9, pad_top=4, feather=6, canvas_multiple=8) + whole_out, whole_mask, whole_geometry = transform_tensor_batch(batch, spec) + out, mask, geometry = transform_tensor_batch_chunked(batch, spec, chunk_size=2) + self.assertEqual(geometry, whole_geometry) + self.assertTrue(torch.equal(out, whole_out)) + self.assertTrue(torch.equal(mask, whole_mask)) + + def test_empty_batch_is_reported(self): + with self.assertRaisesRegex(ValueError, "no decodable frames"): + transform_tensor_batch_chunked(torch.zeros((0, 8, 8, 3)), TransformSpec()) + + +class VideoClipNodeTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls._tmp = tempfile.TemporaryDirectory() + cls.video = Path(cls._tmp.name) / "clip.mp4" + write_test_video(cls.video, with_audio=True) + + @classmethod + def tearDownClass(cls): + cls._tmp.cleanup() + + def run_node(self, **overrides): + values = transform_defaults() + args = { + "video": "", + "source_mode": "local path", + "local_path": str(self.video), + "start_seconds": 0.0, + "end_seconds": 0.0, + **values, + } + args.update(overrides) + return asyncio.run(AusBossVideoCropRotatePadClip().load_transform(**args)) + + def test_padding_applies_to_every_frame_and_lands_in_the_mask(self): + frames, mask, audio, count, fps, width, height, duration = self.run_node(pad_left=16, feather=0)[:8] + self.assertEqual(tuple(frames.shape), (FRAMES, HEIGHT, WIDTH + 16, 3)) + self.assertEqual(tuple(mask.shape), (FRAMES, HEIGHT, WIDTH + 16)) + self.assertTrue(torch.all(mask[:, :, :16] == 1.0)) + self.assertTrue(torch.all(mask[:, :, 16:] == 0.0)) + # Fill color #808080 in the new band, on every frame. + self.assertTrue(torch.allclose(frames[:, :, :16], torch.full((FRAMES, HEIGHT, 16, 3), 128 / 255))) + self.assertEqual((count, width, height), (FRAMES, WIDTH + 16, HEIGHT)) + self.assertAlmostEqual(fps, float(FPS)) + self.assertAlmostEqual(duration, FRAMES / FPS) + self.assertGreater(audio["waveform"].shape[-1], 0) + + def test_trim_thinning_and_crop_compose(self): + frames, mask, _, count, fps, width, height, duration = self.run_node( + start_seconds=0.5, end_seconds=1.5, every_nth=2, crop_x=8, crop_width=32, feather=0 + )[:8] + self.assertEqual(count, 6) + self.assertEqual(tuple(frames.shape), (6, HEIGHT, 32, 3)) + self.assertEqual((width, height), (32, HEIGHT)) + self.assertAlmostEqual(fps, FPS / 2) + self.assertAlmostEqual(duration, 1.0) + self.assertEqual(float(mask.max()), 0.0) + + def test_preview_position_never_changes_the_fingerprint(self): + common = dict(video="", source_mode="local path", local_path=str(self.video), start_seconds=0.0, end_seconds=0.0) + values = transform_defaults(pad_right=8) + one = AusBossVideoCropRotatePadClip.IS_CHANGED(**common, frame_index=0, frame_time=0.0, **values) + two = AusBossVideoCropRotatePadClip.IS_CHANGED(**common, frame_index=17, frame_time=1.4, seek_mode="time seconds", **values) + self.assertEqual(one, two) + trimmed = AusBossVideoCropRotatePadClip.IS_CHANGED(**{**common, "end_seconds": 1.0}, **values) + self.assertNotEqual(one, trimmed) + padded = AusBossVideoCropRotatePadClip.IS_CHANGED(**common, **transform_defaults(pad_right=9)) + self.assertNotEqual(one, padded) + + def test_validate_rejects_a_backwards_window(self): + message = AusBossVideoCropRotatePadClip.VALIDATE_INPUTS( + "", "local path", str(self.video), start_seconds=2.0, end_seconds=1.0 + ) + self.assertIn("start_seconds", str(message)) + self.assertIs(AusBossVideoCropRotatePadClip.VALIDATE_INPUTS("", "local path", str(self.video), 0.0, 0.0), True) + + def test_pad_fit_preserves_source_pixels_and_mask(self): + original = self.run_node(feather=0)[0] + frames, mask, _, _, _, width, height, _ = self.run_node( + crop_aspect_ratio="free", crop_width=WIDTH, crop_height=HEIGHT, + pad_top=17, pad_bottom=18, feather=0, + )[:8] + self.assertEqual((width, height), (WIDTH, HEIGHT + 35)) + self.assertTrue(torch.equal(frames[:, 17:17 + HEIGHT], original)) + self.assertTrue(torch.all(mask[:, 17:17 + HEIGHT] == 0)) + self.assertTrue(torch.all(mask[:, :17] == 1)) + self.assertTrue(torch.all(mask[:, -18:] == 1)) + + def test_limit_shortens_audio_and_duration_together(self): + _, _, audio, count, fps, _, _, duration = self.run_node( + start_seconds=0.5, end_seconds=1.5, every_nth=2, max_frames=3, + )[:8] + self.assertEqual(count, 3) + self.assertAlmostEqual(duration, count / fps) + self.assertAlmostEqual(audio["waveform"].shape[-1] / audio["sample_rate"], duration, delta=0.01) + + def test_frame_snap_keeps_a_video_model_count_and_the_audio_follows(self): + free = self.run_node(start_seconds=0.0, end_seconds=0.0) + self.assertEqual(free[3], FRAMES) + snapped = self.run_node(start_seconds=0.0, end_seconds=0.0, frame_snap="4n+1") + frames, mask, audio, count, fps, _, _, duration, stitcher = snapped + self.assertEqual(count, ((FRAMES - 1) // 4) * 4 + 1) + self.assertEqual(frames.shape[0], count) + self.assertEqual(mask.shape[0], count) + self.assertEqual(stitcher["canvas"].shape[0], count) + self.assertAlmostEqual(duration, count / fps) + self.assertAlmostEqual(audio["waveform"].shape[-1] / audio["sample_rate"], duration, delta=0.01) + + def test_frame_snap_rules(self): + self.assertEqual(snap_frame_count(78, "8n+1"), 73) + self.assertEqual(snap_frame_count(97, "8n+1"), 97) + self.assertEqual(snap_frame_count(78, "4n+1"), 77) + self.assertEqual(snap_frame_count(78, "free"), 78) + self.assertEqual(snap_frame_count(5, "8n+1"), 1) + self.assertEqual(snap_frame_count(0, "8n+1"), 0) + self.assertEqual(snap_frame_count(30, "nonsense"), 30) + + def test_frame_snap_changes_the_fingerprint(self): + common = dict(video="", source_mode="local path", local_path=str(self.video), start_seconds=0.0, end_seconds=0.0) + values = transform_defaults() + one = AusBossVideoCropRotatePadClip.IS_CHANGED(**common, **values) + two = AusBossVideoCropRotatePadClip.IS_CHANGED(**common, frame_snap="8n+1", **values) + self.assertNotEqual(one, two) + + def test_stitcher_pastes_the_source_back_over_a_generated_clip(self): + out = self.run_node(pad_left=16, pad_right=8, feather=0, stitch_blend=4) + frames, mask, stitcher = out[0], out[1], out[8] + self.assertEqual(stitcher["kind"], STITCHER_KIND) + self.assertTrue(torch.equal(stitcher["canvas"], frames)) + self.assertEqual(tuple(stitcher["blend"].shape), tuple(mask.shape)) + self.assertEqual(stitcher["source_bbox"], (16, 0, 16 + WIDTH, HEIGHT)) + # The paste covers the bands (to within the blur kernel's float sum) + # and ramps a few pixels into the source; beyond the ramp it is zero. + self.assertTrue(torch.all(stitcher["blend"][:, :, :16] >= 0.999)) + self.assertTrue(torch.all(stitcher["blend"][:, :, 16 + 12 : -(8 + 12)] == 0.0)) + generated = torch.rand_like(frames) + stitched = apply_stitch(stitcher, generated) + self.assertEqual(tuple(stitched.shape), tuple(frames.shape)) + self.assertTrue(torch.equal(stitched[:, :, 32:-24], frames[:, :, 32:-24])) + self.assertTrue(torch.allclose(stitched[:, :, :16], generated[:, :, :16], atol=1e-5)) + self.assertTrue(torch.allclose(stitched[:, :, -8:], generated[:, :, -8:], atol=1e-5)) + + def test_stitcher_without_padding_returns_the_frames_untouched(self): + out = self.run_node(feather=0) + frames, stitcher = out[0], out[8] + self.assertEqual(float(stitcher["blend"].max()), 0.0) + self.assertTrue(torch.equal(apply_stitch(stitcher, torch.rand_like(frames)), frames)) + + def test_stitch_grow_moves_the_paste_boundary(self): + base = self.run_node(pad_left=16, feather=0, stitch_blend=0)[8]["blend"] + grown = self.run_node(pad_left=16, feather=0, stitch_blend=0, stitch_grow=6)[8]["blend"] + shrunk = self.run_node(pad_left=16, feather=0, stitch_blend=0, stitch_grow=-6)[8]["blend"] + self.assertTrue(torch.all(base[:, :, :16] == 1.0) and torch.all(base[:, :, 16:] == 0.0)) + self.assertTrue(torch.all(grown[:, :, :22] == 1.0) and torch.all(grown[:, :, 22:] == 0.0)) + self.assertTrue(torch.all(shrunk[:, :, :10] == 1.0) and torch.all(shrunk[:, :, 10:] == 0.0)) + + def test_stitch_settings_change_the_fingerprint(self): + common = dict(video="", source_mode="local path", local_path=str(self.video), start_seconds=0.0, end_seconds=0.0) + values = transform_defaults(pad_left=8) + one = AusBossVideoCropRotatePadClip.IS_CHANGED(**common, **values) + two = AusBossVideoCropRotatePadClip.IS_CHANGED(**common, stitch_blend=48, **values) + self.assertNotEqual(one, two) + + def test_resize_keeps_frames_and_masks_aligned(self): + if not ensure_core_video_api(self): + self.skipTest("Set AUSBOSS_COMFY_ROOT to test ComfyUI's resize implementation.") + out = self.run_node( + max_frames=2, pad_left=8, resize_to_megapixels=True, + megapixels=0.01, resolution_steps=8, + ) + frames, mask, _, count, _, width, height, _ = out[:8] + self.assertEqual(count, 2) + self.assertEqual(tuple(frames.shape[1:3]), (height, width)) + self.assertEqual(tuple(mask.shape), (count, height, width)) + self.assertEqual(width % 8, 0) + self.assertEqual(height % 8, 0) + stitcher = out[8] + self.assertEqual(tuple(stitcher["canvas"].shape), tuple(frames.shape)) + self.assertEqual(tuple(stitcher["blend"].shape), (count, height, width)) + x0, _, x1, _ = stitcher["source_bbox"] + self.assertTrue(0 < x0 < x1 <= width) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_workflow_contract.py b/tests/test_workflow_contract.py new file mode 100644 index 0000000..c2438a3 --- /dev/null +++ b/tests/test_workflow_contract.py @@ -0,0 +1,64 @@ +"""Catch saved graphs that parse successfully but fail to load or read cleanly.""" +from pathlib import Path +import sys +import unittest + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "scripts")) +from workflow_contract import workflow_problems, example_problems + + +def good_graph(): + return { + "last_node_id": 3, "last_link_id": 1, + "nodes": [ + {"id": 1, "type": "Source", "pos": [0, 100], "size": [100, 80], "outputs": [{"links": [1]}]}, + {"id": 2, "type": "Target", "pos": [180, 100], "size": [100, 80], "inputs": [{"link": 1}]}, + {"id": 3, "type": "AUSBOSS_NODES_WorkflowNote", "pos": [-200, 100], "size": [100, 80], + "widgets_values": ['{"title":"Example","body":"Choose a source and queue."}']}, + ], + "links": [[1, 1, 0, 2, 0, "IMAGE"]], + "groups": [{"bounding": [-20, 40, 320, 160]}], + } + + +class WorkflowContractTests(unittest.TestCase): + def test_complete_graph(self): + self.assertEqual(workflow_problems(good_graph()), []) + + def test_link_must_agree_in_all_three_places(self): + graph = good_graph() + graph["nodes"][1]["inputs"][0]["link"] = 99 + self.assertIn("target input", " ".join(workflow_problems(graph))) + self.assertIn("stale link", " ".join(workflow_problems(graph))) + graph = good_graph() + graph["nodes"][0]["outputs"][0]["links"] = [] + self.assertIn("source output", " ".join(workflow_problems(graph))) + + def test_missing_nodes_and_stale_counters(self): + graph = good_graph() + graph["links"][0][1] = 99 + graph["last_link_id"] = 0 + problems = " ".join(workflow_problems(graph)) + self.assertIn("missing node", problems) + self.assertIn("last_link_id", problems) + + def test_title_bars_count_as_overlap(self): + graph = good_graph() + graph["nodes"][1]["pos"] = [0, 195] # Body clears; title collides. + self.assertIn("overlap", " ".join(workflow_problems(graph))) + + def test_notes_and_group_containment(self): + graph = good_graph() + graph["nodes"][2]["widgets_values"] = ['{"title":"Example","body":"one\\\\ntwo"}'] + graph["nodes"][1]["pos"] = [500, 100] + problems = " ".join(workflow_problems(graph)) + self.assertIn("escaped line breaks", problems) + self.assertIn("outside its stage", problems) + + def test_all_shipped_examples(self): + self.assertEqual(example_problems(ROOT / "example_workflows"), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/transform_geometry.test.mjs b/tests/transform_geometry.test.mjs index b249f7d..d9ec243 100644 --- a/tests/transform_geometry.test.mjs +++ b/tests/transform_geometry.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { canvasLocalPoint, + fitSourceToAspect, cropHandleCenters, nearestHandle, paddingHandleCenters, @@ -45,6 +46,47 @@ test("canvas multiple adds only right and bottom", () => { assert.deepEqual(resolvePadding({ pad_left: 0, pad_top: 0, pad_right: 0, pad_bottom: 0, canvas_multiple: 8 }, { width: 101, height: 99 }), { left: 0, top: 0, right: 3, bottom: 5, outputWidth: 104, outputHeight: 104 }); }); +test("pad to aspect preserves portrait and centers new landscape canvas", () => { + const source = { width: 576, height: 1024 }; + const patch = fitSourceToAspect(source, "16:9", "pad"); + const crop = resolveCrop(patch, source); + assert.deepEqual(crop, { x: 0, y: 0, ...source }); + assert.equal(patch.crop_aspect_ratio, "free"); + assert.deepEqual(resolvePadding(patch, crop), { + left: 622, right: 623, top: 0, bottom: 0, outputWidth: 1821, outputHeight: 1024, + }); +}); + +test("pad to portrait and rotation preserve all source pixels", () => { + const source = rotatedSize(576, 1024, 90); + const patch = fitSourceToAspect(source, "9:16", "pad"); + assert.equal(patch.pad_left, 0); + assert.equal(patch.pad_right, 0); + assert.equal(patch.pad_top, 622); + assert.equal(patch.pad_bottom, 623); + assert.deepEqual(resolveCrop(patch, source), { x: 0, y: 0, ...source }); +}); + +test("crop to aspect centers the largest crop and clears old padding", () => { + for (const [source, aspect, expected] of [ + [{ width: 576, height: 1024 }, "16:9", { x: 0, y: 350, width: 576, height: 324 }], + [{ width: 1024, height: 576 }, "9:16", { x: 350, y: 0, width: 324, height: 576 }], + ]) { + const patch = fitSourceToAspect(source, aspect); + assert.deepEqual(resolveCrop(patch, source), expected); + assert.equal(patch.pad_left + patch.pad_right + patch.pad_top + patch.pad_bottom, 0); + } +}); + +test("free, source and already-matching aspects do not add padding", () => { + const source = { width: 1920, height: 1080 }; + for (const aspect of ["free", "source", "16:9"]) { + const patch = fitSourceToAspect(source, aspect, "pad"); + assert.deepEqual(resolveCrop(patch, source), { x: 0, y: 0, ...source }); + assert.equal(patch.pad_left + patch.pad_right + patch.pad_top + patch.pad_bottom, 0); + } +}); + test("handle priority and closest distance are deterministic", () => { const selection = nearestHandle({ x: 20, y: 20 }, [ { kind: "padding", priority: 1, radius: 30, handles: [{ name: "pad_left", x: 20, y: 20 }] }, diff --git a/tests/video_source_card.test.mjs b/tests/video_source_card.test.mjs new file mode 100644 index 0000000..edbe6d3 --- /dev/null +++ b/tests/video_source_card.test.mjs @@ -0,0 +1,57 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + INPUT_FOLDER_MODE, + LOCAL_PATH_MODE, + mediaSourceState, + normalizeVideoOptions, + normalizeVideoSourceMode, + videoSourceState, +} from "../js/shared/video_source_card.mjs"; + +test("unknown source modes fall back to the input folder", () => { + assert.equal(normalizeVideoSourceMode("mystery"), INPUT_FOLDER_MODE); + assert.equal(normalizeVideoSourceMode(LOCAL_PATH_MODE), LOCAL_PATH_MODE); +}); + +test("video options are unique and retain a restored selection", () => { + assert.deepEqual( + normalizeVideoOptions(["a.mp4", "a.mp4", "b.mov"], "restored.webm"), + ["a.mp4", "b.mov", "restored.webm"], + ); +}); + +test("source state uses only the field belonging to the active mode", () => { + assert.deepEqual(videoSourceState(INPUT_FOLDER_MODE, "clip.mp4", "/tmp/wrong.mov"), { + mode: INPUT_FOLDER_MODE, + selection: "clip.mp4", + key: "input:clip.mp4", + hint: "Choose an uploaded video or add one to ComfyUI's input folder.", + }); + assert.equal(videoSourceState(LOCAL_PATH_MODE, "wrong.mp4", "/data/clip.mov").key, "local:/data/clip.mov"); + assert.equal(videoSourceState(LOCAL_PATH_MODE, "wrong.mp4", " ").key, ""); +}); + +test("image source cards use the existing image input, never a local-path mode", () => { + assert.deepEqual(mediaSourceState("image", LOCAL_PATH_MODE, "portraits/face 01.png", "/tmp/unused.jpg"), { + mode: INPUT_FOLDER_MODE, + selection: "portraits/face 01.png", + key: "portraits/face 01.png", + hint: "Choose an uploaded image or drop one onto this node.", + }); +}); + +test("image picker retains restored and newly uploaded filenames", () => { + assert.deepEqual(normalizeVideoOptions(["a.png", "a.png", "猫.webp"], "subfolder/new image.png"), + ["a.png", "猫.webp", "subfolder/new image.png"]); + assert.equal(mediaSourceState("image", INPUT_FOLDER_MODE, null).key, ""); + assert.equal(mediaSourceState("image", INPUT_FOLDER_MODE, " leading space.png").key, " leading space.png"); +}); + +test("the shared media card keeps both video source modes unchanged", () => { + for (const mode of [INPUT_FOLDER_MODE, LOCAL_PATH_MODE]) { + assert.deepEqual(mediaSourceState("video", mode, "clip.mp4", "/data/clip.mp4"), + videoSourceState(mode, "clip.mp4", "/data/clip.mp4")); + } +}); diff --git a/tests/widget_card.test.mjs b/tests/widget_card.test.mjs new file mode 100644 index 0000000..44e2a52 --- /dev/null +++ b/tests/widget_card.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + CARD_PADDING, GROUP_HEIGHT, ROW_GAP, ROW_HEIGHT, SECTION_HEIGHT, SLOT_OFFSET, + cardHeight, rowHeight, rowKind, rowTops, scrubSteps, segmentFits, socketWidgetY, visibleRows, +} from "../js/shared/widget_card_math.mjs"; + +test("visibleRows honors when() and closed groups but keeps the header", () => { + const rows = [ + { widget: "mode" }, + { widget: "width", when: (v) => v.mode === "size" }, + { group: "advanced", label: "More" }, + { widget: "extra", inGroup: "advanced" }, + ]; + assert.deepEqual(visibleRows(rows, { mode: "size" }, { advanced: false }).map((r) => r.widget ?? r.group), ["mode", "width", "advanced"]); + assert.deepEqual(visibleRows(rows, { mode: "other" }, { advanced: true }).map((r) => r.widget ?? r.group), ["mode", "advanced", "extra"]); +}); + +test("cardHeight sums rows, captions and headers with one gap between each", () => { + assert.equal(cardHeight([]), CARD_PADDING * 2); + assert.equal(cardHeight([{ widget: "a" }]), CARD_PADDING * 2 + ROW_HEIGHT); + assert.equal( + cardHeight([{ section: "Crop" }, { widget: "a" }, { group: "x" }]), + CARD_PADDING * 2 + SECTION_HEIGHT + ROW_HEIGHT + GROUP_HEIGHT + ROW_GAP * 2, + ); +}); + +test("segmentFits takes a few short labels and rejects long lists", () => { + assert.equal(segmentFits(["lab", "rgb", "mkl", "histogram"]), true); + assert.equal(segmentFits(["overwrite", "skip", "error"]), true); + assert.equal(segmentFits(["lanczos", "bicubic", "bilinear", "nearest", "area"]), false); + assert.equal(segmentFits(["width+height", "longest_edge", "shortest_edge"]), false); + assert.equal(segmentFits(["only"]), false); +}); + +test("scrubSteps: 0..1 floats move by 0.05, wide floats by ten increments, big ints by 8", () => { + assert.deepEqual(scrubSteps({ min: 0, max: 1, step: 0.1, step2: 0.01, precision: 2 }, true), { step: 0.05, fineStep: 0.01, decimals: 2 }); + assert.deepEqual(scrubSteps({ min: 0, max: 64, step: 0.1, step2: 0.01, precision: 2 }, true), { step: 0.1, fineStep: 0.01, decimals: 2 }); + assert.deepEqual(scrubSteps({ min: 0, max: 16384, step: 10, step2: 1, precision: 0 }, false), { step: 8, fineStep: 1, decimals: 0 }); + assert.deepEqual(scrubSteps({ min: 1, max: 128, step: 10, step2: 1, precision: 0 }, false), { step: 1, fineStep: 1, decimals: 0 }); +}); + +test("rowKind follows the widget type unless the row names a kind", () => { + assert.equal(rowKind({ widget: "strength" }, { type: "number" }), "scrub"); + assert.equal(rowKind({ widget: "method" }, { type: "combo", options: { values: ["lab", "rgb"] } }), "segment"); + assert.equal(rowKind({ widget: "interpolation" }, { type: "combo", options: { values: ["lanczos", "bicubic", "bilinear", "nearest", "area"] } }), "select"); + assert.equal(rowKind({ widget: "flag" }, { type: "toggle" }), "switch"); + assert.equal(rowKind({ widget: "fill_color" }, { type: "text", value: "#000000" }), "color"); + assert.equal(rowKind({ widget: "prefix" }, { type: "text", value: "AusBoss" }), "text"); + assert.equal(rowKind({ widget: "caption" }, { type: "customtext" }), "skip"); + assert.equal(rowKind({ widget: "method", kind: "select" }, { type: "combo", options: { values: ["a", "b"] } }), "select"); +}); + +test("rowHeight and cardHeight honor a textarea row's own height", () => { + assert.equal(rowHeight({ widget: "prompt", kind: "textarea", height: 96 }), 96); + assert.equal(rowHeight({ widget: "a" }), ROW_HEIGHT); + assert.equal( + cardHeight([{ widget: "prompt", kind: "textarea", height: 96 }, { widget: "flag" }]), + CARD_PADDING * 2 + 96 + ROW_HEIGHT + ROW_GAP, + ); +}); + +test("rowTops walks the visible rows from the card's padding", () => { + const rows = [{ widget: "a" }, { section: "More" }, { widget: "b", kind: "textarea", height: 60 }, { group: "x" }]; + assert.deepEqual(rowTops(rows), [ + CARD_PADDING, + CARD_PADDING + ROW_HEIGHT + ROW_GAP, + CARD_PADDING + ROW_HEIGHT + ROW_GAP + SECTION_HEIGHT + ROW_GAP, + CARD_PADDING + ROW_HEIGHT + ROW_GAP + SECTION_HEIGHT + ROW_GAP + 60 + ROW_GAP, + ]); +}); + +test("socketWidgetY puts a row's socket on its centre line and a tall row's near its top", () => { + // The frontend draws the socket SLOT_OFFSET below the widget's y. + const cardY = 120; + const row = socketWidgetY(cardY, 10, CARD_PADDING, ROW_HEIGHT); + assert.equal(row + SLOT_OFFSET, cardY + 10 + CARD_PADDING + ROW_HEIGHT / 2); + const tall = socketWidgetY(cardY, 10, CARD_PADDING, 96); + assert.equal(tall + SLOT_OFFSET, cardY + 10 + CARD_PADDING + 13); +}); + +test("rowKind: an explicit textarea kind wins over the customtext skip", () => { + assert.equal(rowKind({ widget: "prompt", kind: "textarea" }, { type: "customtext" }), "textarea"); + assert.equal(rowKind({ widget: "caption" }, { type: "customtext" }), "skip"); +}); diff --git a/tests/widget_visibility.test.mjs b/tests/widget_visibility.test.mjs index b121e3c..75fd1fb 100644 --- a/tests/widget_visibility.test.mjs +++ b/tests/widget_visibility.test.mjs @@ -2,6 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { + hideInputsInDef, hideWidget, setWidgetVisible, showWidget, @@ -20,6 +21,32 @@ test("hide collapses the widget and reports the change", () => { assert.equal(typeof widget.draw, "function"); }); +test("hide also sets the Nodes 2.0 flag and show clears it again", () => { + // The Vue renderer filters widgets on options.hidden and ignores the + // canvas-side collapse; both switches have to move together. + const widget = makeWidget(); + hideWidget(widget); + assert.equal(widget.options.hidden, true); + showWidget(widget); + assert.equal("hidden" in widget.options, false); + const preset = { ...makeWidget(), options: { hidden: false, step: 8 } }; + hideWidget(preset); + assert.equal(preset.options.hidden, true); + showWidget(preset); + assert.deepEqual(preset.options, { hidden: false, step: 8 }); +}); + +test("the Nodes 2.0 flag survives the frontend swapping the options object", () => { + const widget = { ...makeWidget(), options: { step: 1 } }; + hideWidget(widget); + widget.options = { step: 1, precision: 0 }; // what the after-generate control does + assert.equal(widget.options.hidden, true); + assert.equal(widget.options.precision, 0); + showWidget(widget); + widget.options = { step: 2 }; + assert.equal(widget.options.hidden, undefined); +}); + test("hide is idempotent", () => { const widget = makeWidget(); assert.equal(hideWidget(widget), true); @@ -54,3 +81,28 @@ test("hide tolerates missing widgets", () => { assert.equal(hideWidget(null), false); assert.equal(showWidget(undefined), false); }); + +test("a repeated hide re-asserts the renderer flag on a rebuilt options object", () => { + const widget = makeWidget(); + hideWidget(widget); + Object.defineProperty(widget, "options", { value: { step: 1 }, writable: true, configurable: true }); + assert.equal(hideWidget(widget), false); + assert.equal(widget.options.hidden, true); +}); + +test("hideInputsInDef flags spec entries in either group and tolerates gaps", () => { + const nodeData = { input: { required: { seed: ["INT", { default: 0 }], clip: ["CLIP"] }, optional: { note: ["STRING"] } } }; + assert.equal(hideInputsInDef(nodeData, ["seed", "note", "missing"]), 2); + assert.equal(nodeData.input.required.seed[1].hidden, true); + assert.deepEqual(nodeData.input.optional.note[1], { hidden: true }); + assert.equal(nodeData.input.required.clip.length, 1); + assert.equal(hideInputsInDef({}, ["x"]), 0); +}); + +test("hideInputsInDef never throws on frozen definitions", () => { + const frozenOptions = { input: { required: { seed: ["INT", Object.freeze({ default: 0 })] } } }; + assert.equal(hideInputsInDef(frozenOptions, ["seed"]), 1); + assert.equal(frozenOptions.input.required.seed[1].hidden, true); + const frozenAll = Object.freeze({ input: Object.freeze({ required: Object.freeze({ seed: Object.freeze(["INT", Object.freeze({})]) }) }) }); + assert.equal(hideInputsInDef(frozenAll, ["seed"]), 0); +}); diff --git a/tests/workflow_note.test.mjs b/tests/workflow_note.test.mjs new file mode 100644 index 0000000..b90e01e --- /dev/null +++ b/tests/workflow_note.test.mjs @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + baseName, + emptyNote, + folderPath, + groupModels, + hostLabel, + matchInstalled, + noteIsEmpty, + normalizeNote, + packsFromGraph, + parseInline, + parseMarkdown, + safeUrl, + serializeNote, +} from "../js/shared/workflow_note.mjs"; + +test("normalizeNote accepts the widget string, an object, or garbage", () => { + assert.deepEqual(normalizeNote(""), emptyNote()); + assert.deepEqual(normalizeNote("not json"), emptyNote()); + assert.deepEqual(normalizeNote(null), emptyNote()); + assert.deepEqual(normalizeNote([1, 2]), emptyNote()); + const note = normalizeNote({ title: " Krea 2 Outpaint ", layout: "banner", accent: "#00B4AA" }); + assert.equal(note.title, "Krea 2 Outpaint"); + assert.equal(note.layout, "banner"); + assert.equal(note.accent, "#00b4aa"); +}); + +test("rows drop empties, coerce types, and keep only http(s) urls", () => { + const note = normalizeNote({ + models: [ + { name: "a.safetensors", dir: "/loras/", url: "javascript:alert(1)", size: 12 }, + { name: "", url: "https://x" }, + "nonsense", + { name: "b.safetensors", dir: "vae", url: "https://huggingface.co/x/b.safetensors" }, + ], + packs: [{ name: "ComfyUI-AusBoss", url: "http://github.com/a", node: "AUSBOSS_NODES_Seed" }, { url: "https://x" }], + links: [{ label: "", url: "https://civitai.com/user/AusBoss" }, { label: "", url: "ftp://nope" }], + layout: "weird", + accent: "red", + }); + assert.deepEqual(note.models, [ + { name: "a.safetensors", dir: "loras", url: "", size: "12", note: "" }, + { name: "b.safetensors", dir: "vae", url: "https://huggingface.co/x/b.safetensors", size: "", note: "" }, + ]); + assert.deepEqual(note.packs, [{ name: "ComfyUI-AusBoss", url: "http://github.com/a", node: "AUSBOSS_NODES_Seed" }]); + assert.deepEqual(note.links, [{ label: "civitai.com", url: "https://civitai.com/user/AusBoss" }]); + assert.equal(note.layout, "card"); + assert.equal(note.accent, ""); +}); + +test("serializeNote round-trips through normalizeNote", () => { + const note = normalizeNote({ title: "T", body: "b", models: [{ name: "m", dir: "vae" }] }); + assert.deepEqual(normalizeNote(serializeNote(note)), note); +}); + +test("noteIsEmpty is true only for a card with nothing on it", () => { + assert.equal(noteIsEmpty(emptyNote()), true); + assert.equal(noteIsEmpty(normalizeNote({ body: " \n" })), true); + assert.equal(noteIsEmpty(normalizeNote({ links: [{ url: "https://a.b" }] })), false); +}); + +test("safeUrl, hostLabel and baseName", () => { + assert.equal(safeUrl(" https://huggingface.co/a/b "), "https://huggingface.co/a/b"); + assert.equal(safeUrl("data:text/html,hi"), ""); + assert.equal(safeUrl("https://a b"), ""); + assert.equal(hostLabel("https://www.youtube.com/@ausboss"), "youtube.com"); + assert.equal(hostLabel("nope"), ""); + assert.equal(baseName("Krea 2\\krea2_turbo_fp8.safetensors"), "krea2_turbo_fp8.safetensors"); + assert.equal(baseName("plain.png"), "plain.png"); +}); + +test("folderPath and groupModels read like the install tree", () => { + assert.equal(folderPath("loras"), "ComfyUI/models/loras"); + assert.equal(folderPath("input"), "ComfyUI/input"); + assert.equal(folderPath(""), "ComfyUI/models"); + const groups = groupModels([ + { name: "a", dir: "vae" }, { name: "b", dir: "loras" }, { name: "c", dir: "vae" }, + ]); + assert.deepEqual(groups.map((g) => [g.dir, g.rows.map((r) => r.name)]), [["vae", ["a", "c"]], ["loras", ["b"]]]); +}); + +test("matchInstalled finds exact paths first, then basenames in any subfolder", () => { + const files = ["Krea 2/krea2_turbo_fp8.safetensors", "other/thing.safetensors", "top.safetensors"]; + assert.deepEqual(matchInstalled("krea2_turbo_fp8.safetensors", files), { found: true, path: "Krea 2/krea2_turbo_fp8.safetensors" }); + assert.deepEqual(matchInstalled("TOP.safetensors", files), { found: true, path: "top.safetensors" }); + assert.deepEqual(matchInstalled("other\\thing.safetensors", files), { found: true, path: "other/thing.safetensors" }); + assert.deepEqual(matchInstalled("missing.safetensors", files), { found: false, path: null }); + assert.deepEqual(matchInstalled("x", null), { found: false, path: null }); +}); + +test("packsFromGraph lists each custom pack once and skips core modules", () => { + const moduleOf = (type) => ({ + KSampler: "nodes", + ImageBlend: "comfy_extras.nodes_post_processing", + AUSBOSS_NODES_Seed: "custom_nodes.ComfyUI-AusBoss", + AUSBOSS_NODES_LoraLoader: "custom_nodes.ComfyUI-AusBoss", + "Power Lora Loader (rgthree)": "custom_nodes.rgthree-comfy", + Unknown: undefined, + })[type]; + assert.deepEqual( + packsFromGraph(["KSampler", "AUSBOSS_NODES_Seed", "ImageBlend", "AUSBOSS_NODES_LoraLoader", "Power Lora Loader (rgthree)", "Unknown"], moduleOf), + [ + { name: "ComfyUI-AusBoss", url: "", node: "AUSBOSS_NODES_Seed" }, + { name: "rgthree-comfy", url: "", node: "Power Lora Loader (rgthree)" }, + ], + ); +}); + +test("parseInline handles bold, italic, code and safe links", () => { + assert.deepEqual(parseInline("a **b** *c* `d` [e](https://f.g) [h](javascript:x) i"), [ + { type: "text", text: "a " }, + { type: "strong", text: "b" }, + { type: "text", text: " " }, + { type: "em", text: "c" }, + { type: "text", text: " " }, + { type: "code", text: "d" }, + { type: "text", text: " " }, + { type: "link", text: "e", url: "https://f.g" }, + { type: "text", text: " " }, + { type: "text", text: "h" }, + { type: "text", text: " i" }, + ]); + // A lone asterisk or one inside a word is just text. + assert.deepEqual(parseInline("2 * 3 and snake_case*x"), [{ type: "text", text: "2 * 3 and snake_case*x" }]); +}); + +test("parseMarkdown yields headings, paragraphs with soft breaks, lists, rules and code", () => { + const blocks = parseMarkdown("# Title\n\nline one\nline two\n\n- a\n- **b**\n more\n1. one\n2. two\n\n---\n```\nx = 1\n```\n"); + assert.deepEqual(blocks.map((b) => b.type), ["heading", "paragraph", "list", "list", "hr", "code"]); + assert.equal(blocks[0].level, 1); + assert.deepEqual(blocks[1].spans, [ + { type: "text", text: "line one" }, { type: "br" }, { type: "text", text: "line two" }, + ]); + assert.equal(blocks[2].ordered, false); + assert.deepEqual(blocks[2].items[1], [{ type: "strong", text: "b" }, { type: "br" }, { type: "text", text: "more" }]); + assert.equal(blocks[3].ordered, true); + assert.equal(blocks[5].text, "x = 1"); +}); + +test("parseMarkdown on empty or plain text is a single paragraph or nothing", () => { + assert.deepEqual(parseMarkdown(""), []); + assert.deepEqual(parseMarkdown("just words"), [{ type: "paragraph", spans: [{ type: "text", text: "just words" }] }]); +});