Compare commits

..
Author SHA1 Message Date
ausboss 0960817698 Example notes: say what the graph does
- Krea 2 Inpaint Masked: the saved-by-name copies of two notes now
  match the positional text.
- Krea 2 Rotate + Outpaint: the note describes the tone match the
  graph runs.
- Qwen Image 2.1 Inpaint: the note says the seed is fixed.
- Qwen Image 2.1 Upscale + Restore: the Megapixels note mentions the
  roughly 2 MP minimum redraw.
2026-11-04 00:35:38 -06:00
149 changed files with 868 additions and 9942 deletions
-33
View File
@@ -11,7 +11,6 @@ on:
# version in pyproject.toml, or on a manual run from main. Editing another
# pyproject field (description, keywords) without a new version publishes
# nothing, and the offline checks must pass before anything is uploaded.
# A successful upload also creates the version's tag and GitHub Release.
# Requires the repository secret REGISTRY_ACCESS_TOKEN, an API key created
# for the "ausboss" publisher at registry.comfy.org.
permissions:
@@ -29,7 +28,6 @@ jobs:
if: ${{ github.repository == 'ausboss/ComfyUI-AusBoss' && github.ref == 'refs/heads/main' }}
outputs:
publish: ${{ steps.version.outputs.publish }}
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v7
with:
@@ -46,7 +44,6 @@ jobs:
run: |
read_version='import sys, tomllib; print(tomllib.loads(sys.stdin.read())["project"]["version"])'
current=$(python -c "$read_version" < pyproject.toml)
echo "version=$current" >> "$GITHUB_OUTPUT"
previous=""
if [ -n "$BEFORE" ] && git cat-file -e "$BEFORE^{commit}" 2>/dev/null; then
previous=$(git show "$BEFORE:pyproject.toml" | python -c "$read_version")
@@ -87,33 +84,3 @@ jobs:
uses: Comfy-Org/publish-node-action@main
with:
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
github-release:
name: Create GitHub Release
needs: [check, publish-node]
if: ${{ needs.check.outputs.publish == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-python@v7
with:
python-version: "3.12"
- name: Publish release notes and tag
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
VERSION: ${{ needs.check.outputs.version }}
run: |
tag="v$VERSION"
if gh release view "$tag" >/dev/null 2>&1; then
echo "Release $tag already exists; leaving it unchanged."
exit 0
fi
python scripts/changelog_notes.py --version "$VERSION" --full > "$RUNNER_TEMP/release-notes.md"
gh release create "$tag" --target "$GITHUB_SHA" --title "$VERSION" \
--notes-file "$RUNNER_TEMP/release-notes.md" --latest
@@ -1,74 +0,0 @@
name: Publish Model Downloader addon
on:
workflow_dispatch:
inputs:
addon_commit:
description: Full commit SHA from ComfyUI-AusBoss-Model-Downloader main
required: true
type: string
# The existing AusBoss publisher credential stays in this repository. This
# manual job publishes only the separately maintained Model Downloader addon.
# It does not release or change the main AusBoss node pack.
permissions:
contents: read
concurrency:
group: registry-publish-model-downloader
cancel-in-progress: false
jobs:
publish:
if: github.repository == 'ausboss/ComfyUI-AusBoss' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Require an exact addon commit
env:
ADDON_COMMIT: ${{ inputs.addon_commit }}
run: '[[ "$ADDON_COMMIT" =~ ^[0-9a-f]{40}$ ]]'
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
repository: ausboss/ComfyUI-AusBoss-Model-Downloader
ref: ${{ inputs.addon_commit }}
fetch-depth: 0
persist-credentials: false
- name: Require a commit merged into addon main
run: git merge-base --is-ancestor HEAD origin/main
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
with:
python-version: '3.12'
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: '24'
package-manager-cache: false
- name: Install validation and publishing tools
run: python -m pip install -r requirements-dev.txt comfy-cli==1.25.0
- name: Offline checks
run: |
python -m unittest discover -s tests -v
ruff check --select F,E9 __init__.py downloader tests scripts
node --test tests/*.test.mjs
node --check js/model_downloader/index.js
node --check js/shared/download_controls.mjs
- name: Build and inspect Registry package
run: |
comfy --skip-prompt --no-enable-telemetry node pack
python scripts/check_registry_package.py
- name: Refuse an existing immutable version
run: python scripts/registry_status.py --require-unpublished
- name: Publish addon
id: publish
env:
REGISTRY_ACCESS_TOKEN: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
COMFY_NODE_CHANGELOG: 'Initial release: model downloads from Civitai and Hugging Face, installed-file checks, saved model lists, and AusBoss Workflow Note integration.'
run: |
if [ -z "$REGISTRY_ACCESS_TOKEN" ]; then
echo '::error::REGISTRY_ACCESS_TOKEN is not configured.'
exit 1
fi
comfy --skip-prompt --no-enable-telemetry node publish --token "$REGISTRY_ACCESS_TOKEN"
- name: Report Registry review status
if: always() && steps.publish.outcome != 'skipped'
run: python scripts/registry_status.py --report
+2 -87
View File
@@ -4,91 +4,6 @@ All notable changes to ComfyUI-AusBoss are documented here.
## Unreleased
- **New node: Camera Move 🆎.** Moves the camera of your own clip. Drag the
picture on the node to turn the camera, pull back or push in, and set keys
on a time bar. What the old camera never saw comes out pink, ready for
MiniMax H3 with the CrossView-Warp LoRA to paint in. The new MiniMax H3
Camera Move example puts it all in one box between Load Video and Save
Video.
- **Mask by Touch 🆎 no longer fills holes unless you ask.** Fill small
holes, under More, now starts off, so a hole you pick stays a hole. Saved
workflows keep their setting.
- **H3 Prompt Writer: a swap no longer makes the new person talk to noise.** With
**Keep the clip's sound** on and no words typed, the writer told H3 the new
person speaks as in the video whenever the clip's sound was loud, so a train
or a beat made their mouth move. It now does that only when it hears a real
voice: half a second or more of clear speech.
- **Mask by Touch shows a changed picture without unplugging it.** When the
picture coming in changes and the run stops because the picks were made on
the old one, the new picture now shows on the node, ready to pick from. A
new reload button beside Undo shows it any time, without running the
workflow.
- **Save Image and Save Video: the Steps switch is now called Preview steps.** It
shows the picture or the clip's first frame while it is being made, and "Steps"
read like a sampler's step count. It works the same, and saved workflows keep
their setting.
## 2.21.2 - 2026-11-07
- **H3 Prompt Writer reads a plain swap sentence.** "Replace the man in the red
shirt with @1", "Swap the woman for @1" and similar phrasings now name only the
person who goes, instead of putting the whole sentence into the prompt.
- **A swapped-in person is there from the first frame.** Swaps with a picture
always ask for the replacement throughout, with no transition or morph.
- **A text model on H3 Prompt Writer's `clip` input writes the `@1 looks like`
line in a swap.** It describes the person's hair and face from the picture
when no line is typed. Without a model on `clip`, nothing changes.
- **Workflow Note tiles start compact in the classic canvas again.** A leftover
image preview filed under the node's id made the classic canvas grow a new
tile to hold the picture; notes now stand that preview down.
- **Colored link buttons and pop-out notes keep their tile layout when shared.**
Callout Tile and Workflow Note Tile save as distinct node types, so an older
install can report what is missing. Tiles have larger editors and visible
resize grips, start compact when added, and pop-out notes wrap long code lines.
- **H3 Prompt Writer can change something in a video while keeping its person and sound.**
The new `keeps_clip_sound` output helps route the source audio. Video edits
show **From the clip** for their length, and swaps accept an optional
`@1 looks like ...` line describing the connected replacement.
- **Workflow Switches' Off and On buttons now select the state they name.**
Pressing the already selected state leaves it unchanged.
## 2.21.1 - 2026-11-06
- **The README release badge stays available when the badge service is down.**
The badge is served from the repository, shows the current version, and opens
the latest GitHub Release.
## 2.21.0 - 2026-11-05
- **Crop + Rotate + Pad has clearer controls and steadier previews.** Image
and video pickers share Inputs and Outputs tabs and a Load button. Video
previews have a play/pause button, and dragging a locked-length clip stops
where the full selection still fits. Crop, pad and resize controls share
the same compact buttons, and warnings no longer move the controls below.
- **Latent Size can show just the sizes recommended for your workflow.**
Pick the allowed presets in its settings and save them with the workflow.
Workflows without a custom list keep all their choices.
- **Workflow Notes and Callouts can be compact titles, links and guide tiles.**
Guides support formatted text, tables, accent colors and model download
links. YouTube and Discord tiles keep their red and purple choices.
- **Media lists open faster on network shares, and controls load behind
a web address prefix.** File scans reuse directory information. Frontend
imports also work when ComfyUI is served under a path such as `/comfy/`.
- **GitHub Releases now follow version updates automatically.** After the
Registry upload, the publish workflow creates the release with its notes.
The README release badge uses that release and opens its download page.
- **LoRA Loader supports stacks larger than 64 rows.** Folder adds, templates, and absorbed loaders keep the full stack when you save and reopen a workflow.
- **Workflow Note info tiles show a book for guides and a download arrow for models.** Click the dark tile to open the note. Its icon and left edge use the accent you picked.
- **H3 Prompt Writer swaps two people.** With Swap the person and two person
pictures, Picture 1 takes the place of the person on the left and Picture 2
the person on the right. Type who goes to change that, one line each, such
as `@1 the man in the hat` and `@2 the woman in red`.
- **New node: Load Audio.** Load a sound file and keep only the part you
want. The node shows the sound as a wave: drag the IN and OUT handles to
trim it, and press play to hear just that part. Set Max length to the
@@ -130,8 +45,8 @@ All notable changes to ComfyUI-AusBoss are documented here.
by a corner lines that edge up the same way. Hold Shift while you drag to
place it without lining up; ComfyUI then snaps it to its grid as usual. Turn
it off under Settings → 🆎 AusBoss → Canvas. In the Nodes 2.0 look, resizing
a node does not snap yet. If another pack's snapping is on, AusBoss leaves
the dragging to it.
a node does not snap yet. If another pack's snapping is on, such as
Pixaroma's Align, AusBoss leaves the dragging to it.
- **Line up nodes and groups from the selection toolbar.** Select two or more
nodes or groups and a Line up button pops out a small panel: line up edges or
+1 -16
View File
@@ -198,11 +198,6 @@ example_workflows/ # example workflows (regular workflow JSON, not API JSON)
(`tests/canvas_passthrough.test.mjs` lists the few that exist). After a
change to a node face, `scripts/dev/wheel_sweep.mjs` turns the wheel at
every point of every node and must find no dead spot, in both renderers.
- For compact panel actions and preview switches, use `compactAction` and
`panelSwitch` from `js/shared/panel_controls.mjs`. That module owns their
appearance, section caption styles, and common file and save labels. A
node styles its layout locally; leave the shared control's face to the
component. Its selectors only match explicitly named AusBoss controls.
- 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
@@ -345,17 +340,7 @@ before a release that follows a ComfyUI update.
A new `version` in `pyproject.toml` that lands on main publishes to the Comfy
Registry through the publish workflow (`.github/workflows/publish_action.yml`).
After a successful upload, the same workflow tags that commit and creates a
GitHub Release with the full changelog section. The README badge links to the
latest GitHub Release. If only that step fails, rerun the failed jobs to retry
it without uploading the Registry version again. Existing releases are left
unchanged on a rerun. Contributors never change `version`.
For an authorized release, update both `pyproject.toml` and
`AUSBOSS_JS_VERSION`, date the changelog section, then run
`python scripts/release_badge.py`. Preflight rejects a badge that does not
match the pack version. Its SVG lives in the repository so the release
number stays visible when an external badge service is unavailable.
Contributors never change `version`.
The Registry shows notes with each version. They are that version's section
of `CHANGELOG.md`, one line per entry: its bold opening, and for a new node
+7 -18
View File
@@ -3,9 +3,10 @@
<p>ComfyUI nodes for image and video work.</p>
<p>
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square" alt="MIT License">
<!-- Generated by scripts/release_badge.py. Release preflight checks
the badge against pyproject.toml so it cannot silently go stale. -->
<a href="https://github.com/ausboss/ComfyUI-AusBoss/releases/latest"><img src="assets/readme/release.svg" alt="Latest release"></a>
<!-- Dynamic: shields.io reads the version out of pyproject.toml on main
at view time, so this badge can never go stale. release_preflight.py
checks it stays the dynamic kind. -->
<img src="https://img.shields.io/badge/dynamic/toml?url=https%3A%2F%2Fraw.githubusercontent.com%2Fausboss%2FComfyUI-AusBoss%2Fmain%2Fpyproject.toml&query=%24.project.version&label=release&color=00b4aa&style=flat-square" alt="Release">
<a href="https://www.youtube.com/@ausbossdev"><img src="https://img.shields.io/badge/YouTube-%40ausbossdev-00b4aa?style=flat-square&logo=youtube&logoColor=white" alt="YouTube @ausbossdev"></a>
<a href="https://discord.gg/RwVxY9Fuaj"><img src="https://img.shields.io/badge/Discord-AusBoss-00b4aa?style=flat-square&logo=discord&logoColor=white" alt="Discord: AusBoss"></a>
</p>
@@ -65,7 +66,7 @@ The pack uses the Pillow, NumPy, Torch and PyAV that come with ComfyUI. Each exa
Rotate, crop and pad a picture by dragging handles on the node. Cyan squares crop, orange diamonds pad, and the green handle rotates. It rotates first, then crops, then pads. The full-screen editor adds exact sizes, zoom and pan.
Open **Crop, pad & resize** under the preview and tap a ratio to pad the picture to it, or to crop it with **Fit** set to crop. A lit ratio is the shape the canvas has right now: drag a handle to another shape and it goes dark, and the row says Custom. The padlock at the end of the row keeps the shape while you drag, and the button at the start turns the shape between portrait and landscape. **Reset crop** brings back the whole picture, and **Divisible by** makes the width and height divide evenly by a number such as 8, 16 or 32, for models that need it. Feather and output resizing are on the node and in the editor.
Tap a ratio under the preview to pad the picture to it, or to crop it with **Fit** set to crop. A lit ratio is the shape the canvas has right now: drag a handle to another shape and it goes dark, and the row says Custom. The padlock at the end of the row keeps the shape while you drag, and the button at the start turns the shape between portrait and landscape. **Reset crop** brings back the whole picture, and **Divisible by** makes the width and height divide evenly by a number such as 8, 16 or 32, for models that need it. Feather and output resizing are on the node and in the editor.
It returns the `image`, a `mask` of everything the model paints (padding, see-through parts of the source and the corners a rotation opens up), a `stitcher`, the untouched `original`, the output `width` and `height`, and a `prompt_image`. Wire the stitcher into **Stitch Inpaint** after the sampler to put your original back. See-through parts of a PNG, such as a cutout's missing background, are filled like the padding and painted; `prompt_image` and `original` show them as white. Give `prompt_image` to whatever writes your prompt, so it describes a real backdrop instead of the gray fill. **Load Image + Pad** has more fill choices and a separate reference output.
@@ -167,12 +168,6 @@ Upload a sound file, or pick one from the Source list, and the node shows it as
It returns the `audio` you kept and its length in `seconds`. Only that part is decoded. [Help page](js/docs/AUSBOSS_NODES_LoadAudio.md).
### Camera Move
Moves the camera of your own clip. Press **Show my clip** and the node shows your clip in 3D. Drag the picture to turn the camera, scroll or pinch to pull back or push in, and click the time bar to set keys. What the old camera never saw comes out pink, and MiniMax H3 with Cseti's CrossView-Warp LoRA paints it in. Stay within about 45° left or right, 30° up and 20° down. Your keys add to any move your clip's camera already makes.
It needs depth from the core **MoGe Inference** node and returns the `guide` clip for H3 and a `holes` mask. [Help page](js/docs/AUSBOSS_NODES_CameraMove.md). [Open the MiniMax H3 Camera Move workflow →](example_workflows/MiniMax%20H3%20Camera%20Move%20%28AusBoss%29.json)
### Video Crop + Rotate + Pad → Frame
Picks one frame from a video, then gives you the same rotate, crop and pad controls as the image node, plus an optional megapixel resize. Scrub to the frame with the playhead on the node; the editor adds frame-by-frame stepping and playback. It returns the transformed `image`, a `mask` of the added area, a `stitcher`, the chosen `original` frame, and the output `width` and `height`. For a whole clip, use the Clip version below.
@@ -181,7 +176,7 @@ Picks one frame from a video, then gives you the same rotate, crop and pad contr
Applies one rotate, crop and pad to every frame of a trimmed clip. It has the source picker, a timeline with a playhead and frame-accurate IN and OUT handles, frame skipping, a frame limit, the transform handles and output resizing. While you drag a trim handle, the node shows the exact first or last frame the run keeps. The bright part of the selection is what gets output.
Fill, feather and the resize budget are in **Crop, pad & resize** below the timeline, and a new clip keeps them. That matters for video outpainting, because the black, hard-edged canvas the model needs survives a source swap. A fresh Clip node starts with that canvas, and the editor's **Reset transform** goes back to it. **Frames for** trims the end to the frame count the next video model takes: LTX (8n+1) or Wan (4n+1). Tap a ratio to pad the clip to it; a new clip is padded to the lit ratio too. The padlock keeps the shape while you drag a handle.
Fill, feather and the resize budget sit on the node face, and a new clip keeps them. That matters for video outpainting, because the black, hard-edged canvas the model needs survives a source swap. A fresh Clip node starts with that canvas, and the editor's **Reset transform** goes back to it. **Frames for** trims the end to the frame count the next video model takes: LTX (8n+1) or Wan (4n+1). Tap a ratio to pad the clip to it; a new clip is padded to the lit ratio too. The padlock keeps the shape while you drag a handle.
It returns the frames, mask, audio, frame count, fps, size, duration, a `stitcher`, and the chosen `original` frames before the transform. **Inpaint & Stitch** in the editor sets the protected source area and previews the blend. After a video outpaint, wire the stitcher straight into **Stitch Inpaint**, with no Crop For Inpaint in between. It works in chunks to keep memory down.
@@ -205,7 +200,7 @@ It returns the new frames and their fps. It doesn't touch audio, so carry the so
Saves a frame batch or a core `VIDEO` as MP4 (H.264 or H.265), WebM (VP9 or AV1), ProRes MOV, lossless FFV1 MKV, GIF or WebP. Which encoders you get depends on your FFmpeg and PyAV build, and H.264 and H.265 come in CPU and NVENC versions. The videos carry color tags, and optionally audio and the workflow.
**Ping-pong** plays forward and then back without repeating the turnaround frames. A wired `VIDEO` brings its own frame rate; otherwise wire fps from Load Video or Frame Interpolate. The player on the node shows the saved file, and **Preview steps** shows the clip's first frame while it is being made, with a **STOP** button. Drop a saved video back on the canvas to load the workflow inside it, for the formats that can hold one. The [help page](js/docs/AUSBOSS_NODES_SaveVideo.md) has the format and audio details.
**Ping-pong** plays forward and then back without repeating the turnaround frames. A wired `VIDEO` brings its own frame rate; otherwise wire fps from Load Video or Frame Interpolate. The player on the node shows the saved file, and **STEPS** shows the clip's first frame while it is being made, with a **STOP** button. Drop a saved video back on the canvas to load the workflow inside it, for the formats that can hold one. The [help page](js/docs/AUSBOSS_NODES_SaveVideo.md) has the format and audio details.
## Mask and inpaint nodes
@@ -350,8 +345,6 @@ A text box that grows with the node and sends its text, unchanged, down a `STRIN
Type what happens, one line for each moment, and leave a blank line to cut to a new place. Connect up to four pictures and give each one a job: a person, a place, an outfit, a thing or a style. Copy the moves from a video, keep a clip going, or add a voice or a song. A timeline on the node shows when each moment plays before you run it. It needs no extra model and you never see H3's prompt format. Connect a small text model and it fills in a one-line idea.
**Change something** edits the named part of a video while keeping its person and sound. For a person swap, an optional `@1 looks like ...` line describes the connected replacement. Jobs that keep the source clip's length show **From the clip**; the `keeps_clip_sound` output helps a workflow choose the source audio.
### Integer
One whole number (`INT`) on a scrub control, for sharing a size, count or other whole number across nodes. Like every number box in the pack, it takes a typed sum: `1024*1.5`.
@@ -380,10 +373,6 @@ A setup card for a shared workflow: a title, instructions in Markdown, each mode
A note for the canvas whose text grows to fill it, so it reads without zooming in. Double-click to type; a blank line starts a new point and `**two stars**` stress a word. Type an arrow emoji such as ⬆️ and it turns into a teal arrow in the middle of the line. Click a node while you edit, then press an arrow, and the arrow is linked to that node: click it later and the canvas brings the node into view. It never runs and has no wires.
### Compact note and link tiles
**Callout Tile** shows a compact title or colored YouTube/Discord link button. **Workflow Note Tile** opens a full help note from a compact button. They reuse the note editors and save as distinct node types, so an older install reports a missing node instead of drawing an incompatible plain note. Install or update ComfyUI-AusBoss, restart ComfyUI and reload when either tile is missing. Existing ordinary notes and callouts keep their original types.
### Run Timer
A stopwatch for the whole run, with no wires. It starts when the queue starts running, holds the total when the run ends, and saves the last time with the workflow. Recent times are in its right-click menu. You can resize it. It only shows time and doesn't change how the graph runs.
-1
View File
@@ -20,7 +20,6 @@ NODE_MODULES = [
"node_align_image",
"node_batch_utils",
"node_callout",
"node_camera_move",
"node_color_match",
"node_compare",
"node_frame_interpolate",
-9
View File
@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="105" height="20" role="img" aria-label="release: v2.21.2">
<title>release: v2.21.2</title>
<path fill="#555" d="M0 0h48v20H0z"/>
<path fill="#00b4aa" d="M48 0h57v20H48z"/>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text x="24" y="14">release</text>
<text x="76.5" y="14">v2.21.2</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 425 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

@@ -1913,7 +1913,7 @@
"The painted area, grown a little, is filled with flat gray, so Krea 2 knows where to paint. The plain crop goes along too."
],
"widgets_values_named": {
"text": "The painted area, grown a little, is filled with flat gray. Krea 2 never sees what you are replacing."
"text": "The painted area, grown a little, is filled with flat gray, so Krea 2 knows where to paint. The plain crop goes along too."
},
"color": "#1f2c38",
"bgcolor": "#2a3b4a"
@@ -3052,7 +3052,7 @@
"The gray-filled crop is the reference. Painting starts from the plain crop, inside the noise mask: the painted area, widened by 24 px."
],
"widgets_values_named": {
"text": "The gray-filled crop is the reference. The noise mask is the grown painted area, widened by 24 px. Krea 2 paints only inside it."
"text": "The gray-filled crop is the reference. Painting starts from the plain crop, inside the noise mask: the painted area, widened by 24 px."
},
"color": "#1f2c38",
"bgcolor": "#2a3b4a"
@@ -2139,10 +2139,10 @@
"title": "Note",
"properties": {},
"widgets_values": [
"Blend in fades the model's picture into yours just inside its edge, with no tone shift. The new area stays as painted."
"Blend in fades the model's picture into yours just inside its edge. Tone match is fully on, so the new area is adjusted to your picture's tone."
],
"widgets_values_named": {
"text": "Blend in fades the model's picture into yours just inside its edge, with no tone shift. The new area stays as painted."
"text": "Blend in fades the model's picture into yours just inside its edge. Tone match is fully on, so the new area is adjusted to your picture's tone."
},
"color": "#1f2c38",
"bgcolor": "#2a3b4a"
@@ -439,7 +439,7 @@
0,
0,
0,
false,
true,
"stop the run",
false,
false,
@@ -451,7 +451,7 @@
"grow": 0,
"soften": 0,
"smooth": 0,
"fill_holes": false,
"fill_holes": true,
"if_nothing": "stop the run",
"free_vram": false,
"invert": false,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

File diff suppressed because it is too large Load Diff
@@ -37,10 +37,10 @@
"Node name for S&R": "AUSBOSS_NODES_WorkflowNote"
},
"widgets_values": [
"{\"v\": 1, \"title\": \"Qwen Image 2.1 Inpaint\", \"subtitle\": \"Click a thing in your picture and say what to change. Only that part is repainted.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"1. Load your image. You can crop it right there with the blue handles.\\n2. On **Mask by Touch**, click the thing you want to change. It lights up before you click.\\n3. In **What to change**, say it the way you would ask a person: \\\"Replace the cup with a glass of orange juice.\\\"\\n4. Press **Run**.\\n\\nOnly the picked part is repainted. Everything outside it stays your own picture.\\n\\n### Tips\\n- Wrong thing picked? The arrows under the picture swap it for the next thing it could mean.\\n- Name everything that should go: \\\"the iced coffee and its straw\\\". What you do not name can stay.\\n- To remove something, pick it and write \\\"Remove the cup.\\\" Pick its shadow too, or the shadow stays.\\n- To add something, switch to **Paint** and paint where it should go, with some room. Say how big: \\\"Add small black sunglasses on her eyes.\\\"\\n- Not what you wanted? Run it again. The seed changes each run.\\n\\n### Switches\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA from the list below. Off: 25 steps without it, about twice as long.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.12.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"sam3.1_multiplex_fp16.safetensors\", \"dir\": \"checkpoints\", \"size\": \"1.75 GB\", \"url\": \"https://huggingface.co/Comfy-Org/sam3.1/resolve/main/checkpoints/sam3.1_multiplex_fp16.safetensors\", \"note\": \"by Meta, packaged by Comfy-Org\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"optional, for Fast · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_MaskByTouch\"}], \"links\": [{\"label\": \"YouTube · this workflow\", \"url\": \"https://youtu.be/iMgaiXoB0v4\"}, {\"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\"}]}"
"{\"v\": 1, \"title\": \"Qwen Image 2.1 Inpaint\", \"subtitle\": \"Click a thing in your picture and say what to change. Only that part is repainted.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"1. Load your image. You can crop it right there with the blue handles.\\n2. On **Mask by Touch**, click the thing you want to change. It lights up before you click.\\n3. In **What to change**, say it the way you would ask a person: \\\"Replace the cup with a glass of orange juice.\\\"\\n4. Press **Run**.\\n\\nOnly the picked part is repainted. Everything outside it stays your own picture.\\n\\n### Tips\\n- Wrong thing picked? The arrows under the picture swap it for the next thing it could mean.\\n- Name everything that should go: \\\"the iced coffee and its straw\\\". What you do not name can stay.\\n- To remove something, pick it and write \\\"Remove the cup.\\\" Pick its shadow too, or the shadow stays.\\n- To add something, switch to **Paint** and paint where it should go, with some room. Say how big: \\\"Add small black sunglasses on her eyes.\\\"\\n- Not what you wanted? Change **Seed** and run it again. It stays fixed between runs.\\n\\n### Switches\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA from the list below. Off: 25 steps without it, about twice as long.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.12.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"sam3.1_multiplex_fp16.safetensors\", \"dir\": \"checkpoints\", \"size\": \"1.75 GB\", \"url\": \"https://huggingface.co/Comfy-Org/sam3.1/resolve/main/checkpoints/sam3.1_multiplex_fp16.safetensors\", \"note\": \"by Meta, packaged by Comfy-Org\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"optional, for Fast · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_MaskByTouch\"}], \"links\": [{\"label\": \"YouTube · this workflow\", \"url\": \"https://youtu.be/iMgaiXoB0v4\"}, {\"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\": \"Qwen Image 2.1 Inpaint\", \"subtitle\": \"Click a thing in your picture and say what to change. Only that part is repainted.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"1. Load your image. You can crop it right there with the blue handles.\\n2. On **Mask by Touch**, click the thing you want to change. It lights up before you click.\\n3. In **What to change**, say it the way you would ask a person: \\\"Replace the cup with a glass of orange juice.\\\"\\n4. Press **Run**.\\n\\nOnly the picked part is repainted. Everything outside it stays your own picture.\\n\\n### Tips\\n- Wrong thing picked? The arrows under the picture swap it for the next thing it could mean.\\n- Name everything that should go: \\\"the iced coffee and its straw\\\". What you do not name can stay.\\n- To remove something, pick it and write \\\"Remove the cup.\\\" Pick its shadow too, or the shadow stays.\\n- To add something, switch to **Paint** and paint where it should go, with some room. Say how big: \\\"Add small black sunglasses on her eyes.\\\"\\n- Not what you wanted? Run it again. The seed changes each run.\\n\\n### Switches\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA from the list below. Off: 25 steps without it, about twice as long.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.12.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"sam3.1_multiplex_fp16.safetensors\", \"dir\": \"checkpoints\", \"size\": \"1.75 GB\", \"url\": \"https://huggingface.co/Comfy-Org/sam3.1/resolve/main/checkpoints/sam3.1_multiplex_fp16.safetensors\", \"note\": \"by Meta, packaged by Comfy-Org\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"optional, for Fast · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_MaskByTouch\"}], \"links\": [{\"label\": \"YouTube · this workflow\", \"url\": \"https://youtu.be/iMgaiXoB0v4\"}, {\"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\"}]}"
"note": "{\"v\": 1, \"title\": \"Qwen Image 2.1 Inpaint\", \"subtitle\": \"Click a thing in your picture and say what to change. Only that part is repainted.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"1. Load your image. You can crop it right there with the blue handles.\\n2. On **Mask by Touch**, click the thing you want to change. It lights up before you click.\\n3. In **What to change**, say it the way you would ask a person: \\\"Replace the cup with a glass of orange juice.\\\"\\n4. Press **Run**.\\n\\nOnly the picked part is repainted. Everything outside it stays your own picture.\\n\\n### Tips\\n- Wrong thing picked? The arrows under the picture swap it for the next thing it could mean.\\n- Name everything that should go: \\\"the iced coffee and its straw\\\". What you do not name can stay.\\n- To remove something, pick it and write \\\"Remove the cup.\\\" Pick its shadow too, or the shadow stays.\\n- To add something, switch to **Paint** and paint where it should go, with some room. Say how big: \\\"Add small black sunglasses on her eyes.\\\"\\n- Not what you wanted? Change **Seed** and run it again. It stays fixed between runs.\\n\\n### Switches\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA from the list below. Off: 25 steps without it, about twice as long.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.12.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"sam3.1_multiplex_fp16.safetensors\", \"dir\": \"checkpoints\", \"size\": \"1.75 GB\", \"url\": \"https://huggingface.co/Comfy-Org/sam3.1/resolve/main/checkpoints/sam3.1_multiplex_fp16.safetensors\", \"note\": \"by Meta, packaged by Comfy-Org\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"optional, for Fast · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_MaskByTouch\"}], \"links\": [{\"label\": \"YouTube · this workflow\", \"url\": \"https://youtu.be/iMgaiXoB0v4\"}, {\"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\"}]}"
},
"color": "#14424d",
"bgcolor": "#161f21"
@@ -37,10 +37,10 @@
"ver": "2.9.0"
},
"widgets_values": [
"{\"v\": 1, \"title\": \"Qwen Image 2.1 Upscale + Restore\", \"subtitle\": \"Load a small, soft or worn picture. Qwen Image 2.1 redraws it sharp at the megapixels you set, in its own colors.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"[▶ AusBoss on YouTube](https://www.youtube.com/@ausbossdev) · videos on these nodes and workflows\\n\\n---\\n\\n1. Load your image. You can crop it right there with the blue handles.\\n2. Set **Megapixels**: the size of the result.\\n3. Press **Run**.\\n4. Slide **Before and after** to compare. The result is saved as a PNG.\\n\\n### What you get\\n\\n- Your picture redrawn sharp, at the size you asked for.\\n- Its own colors, also on a noisy or faded photo.\\n\\n### Megapixels\\n\\n- Up to about 2: one pass.\\n- Over that: redrawn in tiles of about 2 megapixels and joined. 4 takes two tiles, 8 takes four.\\n- A small picture is redrawn at up to 8 times its own megapixels, then enlarged the plain way.\\n- A picture of 1 megapixel or more only looks better when it grows about 1.6 times per side.\\n\\n### Switches\\n\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA. Off: 25 steps without it, about three times as long.\\n\\n### Tips\\n\\n- It is a redraw. Fine detail is made up to fit, so a face stays the same person, not the same pixels.\\n- Small lettering on signs and labels can come back misspelled.\\n- Each tile is matched to your picture's colors. To let Qwen's own colors through, lower **Keep colors** inside the box.\\n- If the run stops at a red **LoRA Loader**, a LoRA that is switched on is missing. Download it from the list below.\\n- Not tested on blur from a moving camera or missed focus.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.9.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"qwen-image-2.1-restore.safetensors\", \"dir\": \"loras\", \"size\": \"159 MB\", \"url\": \"https://huggingface.co/ausboss/Qwen-Image-2.1-Restore-LoRA/resolve/main/qwen-image-2.1-restore.safetensors\", \"note\": \"keeps the result close to your picture\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"for Fast on · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_TiledUpscale\"}], \"links\": [{\"label\": \"YouTube · ausbossdev\", \"url\": \"https://www.youtube.com/@ausbossdev\"}, {\"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\"}]}"
"{\"v\": 1, \"title\": \"Qwen Image 2.1 Upscale + Restore\", \"subtitle\": \"Load a small, soft or worn picture. Qwen Image 2.1 redraws it sharp at the megapixels you set, in its own colors.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"[▶ AusBoss on YouTube](https://www.youtube.com/@ausbossdev) · videos on these nodes and workflows\\n\\n---\\n\\n1. Load your image. You can crop it right there with the blue handles.\\n2. Set **Megapixels**: the size of the result.\\n3. Press **Run**.\\n4. Slide **Before and after** to compare. The result is saved as a PNG.\\n\\n### What you get\\n\\n- Your picture redrawn sharp, at the size you asked for.\\n- Its own colors, also on a noisy or faded photo.\\n\\n### Megapixels\\n\\n- Up to about 2: one pass.\\n- Over that: redrawn in tiles of about 2 megapixels and joined. 4 takes two tiles, 8 takes four.\\n- The redraw limit for a small picture is 8 times its own megapixels or one roughly 2 megapixel tile, whichever is larger. The rest is enlarged the plain way.\\n- A picture of 1 megapixel or more only looks better when it grows about 1.6 times per side.\\n\\n### Switches\\n\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA. Off: 25 steps without it, about three times as long.\\n\\n### Tips\\n\\n- It is a redraw. Fine detail is made up to fit, so a face stays the same person, not the same pixels.\\n- Small lettering on signs and labels can come back misspelled.\\n- Each tile is matched to your picture's colors. To let Qwen's own colors through, lower **Keep colors** inside the box.\\n- If the run stops at a red **LoRA Loader**, a LoRA that is switched on is missing. Download it from the list below.\\n- Not tested on blur from a moving camera or missed focus.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.9.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"qwen-image-2.1-restore.safetensors\", \"dir\": \"loras\", \"size\": \"159 MB\", \"url\": \"https://huggingface.co/ausboss/Qwen-Image-2.1-Restore-LoRA/resolve/main/qwen-image-2.1-restore.safetensors\", \"note\": \"keeps the result close to your picture\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"for Fast on · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_TiledUpscale\"}], \"links\": [{\"label\": \"YouTube · ausbossdev\", \"url\": \"https://www.youtube.com/@ausbossdev\"}, {\"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\": \"Qwen Image 2.1 Upscale + Restore\", \"subtitle\": \"Load a small, soft or worn picture. Qwen Image 2.1 redraws it sharp at the megapixels you set, in its own colors.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"[▶ AusBoss on YouTube](https://www.youtube.com/@ausbossdev) · videos on these nodes and workflows\\n\\n---\\n\\n1. Load your image. You can crop it right there with the blue handles.\\n2. Set **Megapixels**: the size of the result.\\n3. Press **Run**.\\n4. Slide **Before and after** to compare. The result is saved as a PNG.\\n\\n### What you get\\n\\n- Your picture redrawn sharp, at the size you asked for.\\n- Its own colors, also on a noisy or faded photo.\\n\\n### Megapixels\\n\\n- Up to about 2: one pass.\\n- Over that: redrawn in tiles of about 2 megapixels and joined. 4 takes two tiles, 8 takes four.\\n- A small picture is redrawn at up to 8 times its own megapixels, then enlarged the plain way.\\n- A picture of 1 megapixel or more only looks better when it grows about 1.6 times per side.\\n\\n### Switches\\n\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA. Off: 25 steps without it, about three times as long.\\n\\n### Tips\\n\\n- It is a redraw. Fine detail is made up to fit, so a face stays the same person, not the same pixels.\\n- Small lettering on signs and labels can come back misspelled.\\n- Each tile is matched to your picture's colors. To let Qwen's own colors through, lower **Keep colors** inside the box.\\n- If the run stops at a red **LoRA Loader**, a LoRA that is switched on is missing. Download it from the list below.\\n- Not tested on blur from a moving camera or missed focus.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.9.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"qwen-image-2.1-restore.safetensors\", \"dir\": \"loras\", \"size\": \"159 MB\", \"url\": \"https://huggingface.co/ausboss/Qwen-Image-2.1-Restore-LoRA/resolve/main/qwen-image-2.1-restore.safetensors\", \"note\": \"keeps the result close to your picture\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"for Fast on · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_TiledUpscale\"}], \"links\": [{\"label\": \"YouTube · ausbossdev\", \"url\": \"https://www.youtube.com/@ausbossdev\"}, {\"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\"}]}"
"note": "{\"v\": 1, \"title\": \"Qwen Image 2.1 Upscale + Restore\", \"subtitle\": \"Load a small, soft or worn picture. Qwen Image 2.1 redraws it sharp at the megapixels you set, in its own colors.\", \"author\": \"AusBoss\", \"layout\": \"card\", \"accent\": \"\", \"body\": \"[▶ AusBoss on YouTube](https://www.youtube.com/@ausbossdev) · videos on these nodes and workflows\\n\\n---\\n\\n1. Load your image. You can crop it right there with the blue handles.\\n2. Set **Megapixels**: the size of the result.\\n3. Press **Run**.\\n4. Slide **Before and after** to compare. The result is saved as a PNG.\\n\\n### What you get\\n\\n- Your picture redrawn sharp, at the size you asked for.\\n- Its own colors, also on a noisy or faded photo.\\n\\n### Megapixels\\n\\n- Up to about 2: one pass.\\n- Over that: redrawn in tiles of about 2 megapixels and joined. 4 takes two tiles, 8 takes four.\\n- The redraw limit for a small picture is 8 times its own megapixels or one roughly 2 megapixel tile, whichever is larger. The rest is enlarged the plain way.\\n- A picture of 1 megapixel or more only looks better when it grows about 1.6 times per side.\\n\\n### Switches\\n\\n- **Fast** on: 8 steps with the Viggle Turbo LoRA. Off: 25 steps without it, about three times as long.\\n\\n### Tips\\n\\n- It is a redraw. Fine detail is made up to fit, so a face stays the same person, not the same pixels.\\n- Small lettering on signs and labels can come back misspelled.\\n- Each tile is matched to your picture's colors. To let Qwen's own colors through, lower **Keep colors** inside the box.\\n- If the run stops at a red **LoRA Loader**, a LoRA that is switched on is missing. Download it from the list below.\\n- Not tested on blur from a moving camera or missed focus.\\n\\nNeeds ComfyUI 0.38 or newer and AusBoss nodes 2.9.0 or newer (update first).\", \"models\": [{\"name\": \"qwen_image_2.1_int8_convrot.safetensors\", \"dir\": \"diffusion_models\", \"size\": \"7.26 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen3vl_8b_int8_convrot.safetensors\", \"dir\": \"text_encoders\", \"size\": \"9.35 GB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors\", \"note\": \"\"}, {\"name\": \"qwen_image_2.1_vae_bf16.safetensors\", \"dir\": \"vae\", \"size\": \"676 MB\", \"url\": \"https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors\", \"note\": \"\"}, {\"name\": \"qwen-image-2.1-restore.safetensors\", \"dir\": \"loras\", \"size\": \"159 MB\", \"url\": \"https://huggingface.co/ausboss/Qwen-Image-2.1-Restore-LoRA/resolve/main/qwen-image-2.1-restore.safetensors\", \"note\": \"keeps the result close to your picture\"}, {\"name\": \"Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"dir\": \"loras\", \"size\": \"680 MB\", \"url\": \"https://huggingface.co/Viggle/Qwen-Image-2.1-viggle-turbo/resolve/main/Qwen-Image-2.1-viggle-turbo-v0.3-6step-lora-r128.safetensors\", \"note\": \"for Fast on · Qwen Research License, research use\"}], \"packs\": [{\"name\": \"ComfyUI-AusBoss\", \"url\": \"https://github.com/ausboss/ComfyUI-AusBoss\", \"node\": \"AUSBOSS_NODES_TiledUpscale\"}], \"links\": [{\"label\": \"YouTube · ausbossdev\", \"url\": \"https://www.youtube.com/@ausbossdev\"}, {\"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\"}]}"
},
"color": "#14424d",
"bgcolor": "#161f21"
+5 -4
View File
@@ -1,4 +1,4 @@
import { app } from "../../../scripts/app.js";
import { app } from "/scripts/app.js";
import {
CUSTOM_SCHEME,
DEFAULT_SCHEME,
@@ -190,7 +190,7 @@ app.registerExtension({
});
function isTitleless(node) {
return (node.title_mode ?? node.constructor?.title_mode) === (globalThis.LiteGraph?.NO_TITLE ?? 1);
return node.constructor?.title_mode === (globalThis.LiteGraph?.NO_TITLE ?? 1);
}
function openHelpCard(node, event) {
@@ -210,8 +210,9 @@ function openHelpCard(node, event) {
function installHelpBadge(node) {
// A title-less node (Run Timer) has no bar to hang the badge on; its
// card opens from the node's context menu instead.
if (isTitleless(node)) return;
chainCallback(node, "onDrawForeground", function (ctx) {
if (isTitleless(this) || !showBadge(this.size?.[0] ?? 0, this.flags?.collapsed)) return;
if (!showBadge(this.size?.[0] ?? 0, this.flags?.collapsed)) return;
const [x, y] = badgeCenter(this.size[0]);
ctx.save();
ctx.beginPath();
@@ -227,7 +228,7 @@ function installHelpBadge(node) {
ctx.restore();
});
chainHandler(node, "onMouseDown", function (event, pos) {
if (isTitleless(this) || !showBadge(this.size?.[0] ?? 0, this.flags?.collapsed)) return false;
if (!showBadge(this.size?.[0] ?? 0, this.flags?.collapsed)) return false;
if (!pos || !hitsBadge(pos, this.size[0])) return false;
openHelpCard(this, event);
return true; // consume the click so it does not start a drag
+2 -2
View File
@@ -4,8 +4,8 @@
//
// Imports nothing from ../shared on purpose. Old cached copies of those files
// are the problem this entry has to survive.
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
const PACK_URL = new URL("../", import.meta.url).href;
const PARALLEL = 6;
+10 -156
View File
@@ -6,9 +6,7 @@
// and copy ride the default path); the panel only shows it. Decisions live in
// js/shared/callout.mjs under node:test.
import { app } from "../../../scripts/app.js";
import { setTileFace, TILE_FACE_CSS } from "../shared/tile_face.mjs";
import { CALLOUT_TYPES, serializeNoteIdentity } from "../shared/note_identity.mjs";
import { app } from "/scripts/app.js";
import { BRAND, chainCallback, keepDomWidgetWidthAuto } from "../shared/index.mjs";
import {
ARROW_ANGLE,
@@ -21,8 +19,6 @@ import {
repointLinks,
targetId,
toggleSmall,
tileLinkFields,
tileLinkText,
urlLabel,
wrapLink,
} from "../shared/callout.mjs";
@@ -31,9 +27,9 @@ import { keepKeyInField } from "../shared/canvas_passthrough.mjs";
import { WIDGET_FRAME, fillNodeHeight } from "../shared/panel_layout.mjs";
import { hideInputsInDef, hideWidget } from "../shared/widget_visibility.mjs";
import { commitWidgetValue } from "../shared/widget_card_math.mjs";
import { compactAction } from "../shared/panel_controls.mjs";
import { frameBounds } from "../shared/workflow_switches.mjs";
const NODE_CLASS = "AUSBOSS_NODES_Callout";
const CSS_ID = "ausboss-callout-css";
const WIDGET_NAME = "ausboss_callout_panel";
const DEFAULT_SIZE = [380, 160];
@@ -41,10 +37,6 @@ const PANEL_MIN_WIDTH = 200;
const PANEL_MIN_HEIGHT = 64;
const SVG_NS = "http://www.w3.org/2000/svg";
const ARROW_COLOR = "#1fd3c6";
const LINK_TILES = {
youtube: { label: "Watch Me on YouTube", url: "https://www.youtube.com/@ausbossdev" },
discord: { label: "Join for Help or More Workflows", url: "https://discord.gg/RwVxY9Fuaj" },
};
const FRAME_FILL = 0.8; // the share of the view a node brought into view takes, as in Workflow Switches
const FONT = '"IBM Plex Sans", "Inter", "Segoe UI", system-ui, -apple-system, sans-serif';
@@ -63,7 +55,6 @@ function ensureCss() {
const style = document.createElement("style");
style.id = CSS_ID;
style.textContent = `
${TILE_FACE_CSS}
.ausboss-callout{box-sizing:border-box;width:100%;height:100%;padding:2px 4px 6px;overflow:hidden;pointer-events:none;}
.ausboss-callout-card{position:relative;box-sizing:border-box;width:100%;height:100%;overflow:hidden;border-radius:8px;background:linear-gradient(135deg,rgba(0,180,170,.07),rgba(0,0,0,.2));}
.ausboss-callout-inner{position:absolute;top:0;bottom:0;left:0;box-sizing:border-box;width:100%;height:fit-content;margin:auto 0;padding:.6em 1em .6em .85em;color:#d6e6e4;font-family:${FONT};font-weight:500;line-height:1.4;letter-spacing:.003em;overflow-wrap:anywhere;}
@@ -72,15 +63,6 @@ ${TILE_FACE_CSS}
.ausboss-callout-point b{color:#6fe0d6;font-weight:650;}
.ausboss-callout-small{margin-top:.3em;color:#9fb5b2;font-size:max(11px,.62em);font-weight:400;line-height:1.4;}
.ausboss-callout-small b{color:#8fd6cf;font-weight:600;}
.ausboss-callout-card[data-tile=youtube]{background:#ae2639}
.ausboss-callout-card[data-tile=discord]{background:#5146aa}
.ausboss-callout-card[data-tile=title]{background:#090909;border-left:4px solid #21c7b9}
.ausboss-callout-card[data-tile] .ausboss-callout-inner{padding-block:.15em;}
.ausboss-callout-card[data-tile] .ausboss-callout-point{border:0;padding:0;margin:0;font-weight:650;color:#fff}
.ausboss-callout-card[data-tile] .ausboss-callout-url{color:white;text-decoration:none;display:flex;align-items:center;gap:12px}
.ausboss-callout-card[data-tile] .ausboss-callout-url svg{width:36px;height:28px;flex:none}
.ausboss-callout-card[data-tile] .ausboss-callout-out{margin-left:auto}
.ausboss-callout-card[data-tile] .ausboss-callout-tools{right:27px;}
.ausboss-callout-empty{color:#6f8987;font-size:14px;font-weight:400;}
.ausboss-callout-arrow{display:inline-block;width:1.2em;height:1.2em;margin:0 .1em;vertical-align:-.26em;filter:drop-shadow(0 0 .3em rgba(0,180,170,.55));}
.ausboss-callout-arrow svg{display:block;width:100%;height:100%;overflow:visible;}
@@ -93,7 +75,7 @@ ${TILE_FACE_CSS}
.ausboss-callout-out{display:inline-block;margin-left:.2em;font-size:.7em;font-weight:600;vertical-align:.14em;text-decoration:none;opacity:.85;}
.ausboss-callout-jump.is-gone{opacity:.45;cursor:default;text-decoration-style:dotted;}
.ausboss-callout-tools{position:absolute;top:5px;right:5px;display:flex;gap:4px;opacity:0;transition:opacity .15s;pointer-events:auto;}
.ausboss-callout-card:is(:hover,:focus-within) .ausboss-callout-tools,.ausboss-callout.is-selected .ausboss-callout-tools{opacity:.95;}
.ausboss-callout-card:hover .ausboss-callout-tools,.ausboss-callout.is-selected .ausboss-callout-tools{opacity:.95;}
.ausboss-callout-tool{height:22px;padding:0 8px;border:1px solid rgba(0,180,170,.45);border-radius:5px;background:rgba(8,14,16,.86);color:#9fd8d3;font:600 11px ${FONT};letter-spacing:.02em;cursor:pointer;}
.ausboss-callout-tool:hover{color:#fff;border-color:${BRAND};}
.ausboss-callout-editor{position:absolute;inset:0;box-sizing:border-box;display:flex;flex-direction:column;gap:6px;padding:6px;border:1px solid ${BRAND};border-radius:8px;background:rgba(5,10,11,.95);pointer-events:auto;container-type:size;}
@@ -108,17 +90,6 @@ ${TILE_FACE_CSS}
.ausboss-callout-pick svg{display:block;width:100%;height:100%;}
.ausboss-callout-size{flex:none;height:24px;margin-left:4px;padding:0 7px;border:1px solid rgba(0,180,170,.35);border-radius:5px;background:rgba(8,14,16,.9);color:#9fd8d3;font:600 11px ${FONT};cursor:pointer;}
.ausboss-callout-size:hover{border-color:${BRAND};background:rgba(0,180,170,.14);}
.ausboss-tile-editor{box-sizing:border-box;width:min(520px,94vw);max-height:86vh;overflow:auto;margin:auto;padding:24px;border:1px solid #365957;border-top:3px solid ${BRAND};border-radius:10px;background:#111b1c;color:#e6f4f3;box-shadow:0 18px 64px #0008;font:14px/1.5 ${FONT};}
.ausboss-tile-editor::backdrop{background:#06090ab8;}
.ausboss-tile-editor h2{margin:0 0 18px;font-size:21px;}
.ausboss-tile-editor label{display:flex;flex-direction:column;gap:7px;margin-bottom:16px;font-weight:600;}
.ausboss-tile-editor input,.ausboss-tile-editor textarea{box-sizing:border-box;width:100%;padding:10px 12px;border:1px solid #365957;border-radius:5px;background:#080e10;color:#e6f4f3;font:16px/1.5 ${FONT};user-select:text;}
.ausboss-tile-editor textarea{min-height:120px;resize:vertical;}
.ausboss-tile-editor :is(input,textarea):focus{outline:2px solid ${BRAND};outline-offset:1px;}
.ausboss-tile-editor footer{display:flex;justify-content:flex-end;align-items:center;gap:8px;}
.ausboss-tile-editor footer span{margin-right:auto;color:#99b0ad;font-size:12px;}
.ausboss-tile-editor .ausboss-compact-action{height:32px;padding:0 15px;font-size:13px;}
.ausboss-tile-editor .ausboss-tile-error{color:#ffc36d;margin:0 0 12px;}
@container (max-width:395px){.ausboss-callout-arrowbar>span:first-child,.ausboss-callout-link:not(.has-node){display:none;}}
@container (max-width:395px) and (min-height:112px){.ausboss-callout-arrowbar{flex-wrap:wrap;}.ausboss-callout-pick{flex:none;}}
`;
@@ -206,18 +177,6 @@ function arrowSpan(state, kind, piece = {}) {
function linkRun(state, first) {
if (first.url !== undefined) {
const link = el("a", "ausboss-callout-url");
const tile = state.card.dataset.tile;
if (tile === "youtube" || tile === "discord") {
const icon = svg("svg", { viewBox:"0 0 28 20", "aria-hidden":"true" });
if (tile === "youtube") {
icon.append(svg("rect",{x:0,y:1,width:28,height:18,rx:5,fill:"white"}),
svg("path",{d:"M11 5.5 19 10 11 14.5Z",fill:"#ae2639"}));
} else {
icon.append(svg("path",{d:"M4 3 10 1l1 2a15 15 0 0 1 6 0l1-2 6 2c2 4 3 8 3 12l-6 4-2-3a17 17 0 0 1-10 0l-2 3-6-4c0-4 1-8 3-12Z",fill:"white"}));
for (const cx of [9,19]) icon.append(svg("ellipse",{cx,cy:10,rx:2,ry:2.6,fill:"#5146aa"}));
}
link.append(icon);
}
link.href = first.url;
link.target = "_blank";
link.rel = "noopener noreferrer";
@@ -263,10 +222,6 @@ function bringIntoView(target) {
}
function renderText(state) {
const style = state.node.properties?.ausbossCalloutTile;
if (["youtube","discord","title"].includes(style)) state.card.dataset.tile = style;
else delete state.card.dataset.tile;
setTileFace(state.node, state.root, Boolean(state.card.dataset.tile), () => startEdit(state));
const blocks = parseBlocks(state.valueWidget.value);
state.inner.replaceChildren();
state.empty = blocks.length === 0;
@@ -364,9 +319,7 @@ function fit(state) {
inner.style.fontSize = `${px}px`;
return inner.offsetHeight <= height && inner.scrollWidth <= width + 1;
};
const size = fitFontSize(fits);
const social = card.dataset.tile === "youtube" || card.dataset.tile === "discord";
inner.style.fontSize = `${social ? Math.min(size, 16) : size}px`;
inner.style.fontSize = `${fitFontSize(fits)}px`;
}
function markChanged() {
@@ -378,78 +331,8 @@ function markChanged() {
}
}
function startTileEdit(state) {
if (state.editor) return;
const original = String(state.valueWidget.value ?? "");
const social = ["youtube", "discord"].includes(state.card.dataset.tile);
const link = social ? tileLinkFields(original) : null;
const dialog = el("dialog", "ausboss-tile-editor");
const heading = social ? "Edit link tile" : "Edit title";
dialog.setAttribute("aria-label", heading);
dialog.append(el("h2", "", heading));
const label = el("label", "", link ? "Button text" : "Text");
const text = el(link ? "input" : "textarea");
text.value = link?.label ?? original;
label.append(text);
dialog.append(label);
let address;
if (link) {
const field = el("label", "", "Link address");
address = el("input");
address.type = "url";
address.value = link.url;
field.append(address);
dialog.append(field);
}
const error = el("p", "ausboss-tile-error");
error.setAttribute("role", "alert");
const foot = el("footer");
const cancel = compactAction("Cancel");
const save = compactAction("Save");
foot.append(el("span", "", "Ctrl + Enter to save"), cancel, save);
dialog.append(error, foot);
const commit = () => {
let value = text.value;
try {
if (address) value = tileLinkText(text.value, address.value);
} catch (problem) {
error.textContent = problem.message;
return;
}
if (value !== original) {
state.node.graph?.beforeChange?.();
commitWidgetValue(state.node, state.valueWidget, value, app.canvas);
renderText(state);
state.node.graph?.afterChange?.();
markChanged();
}
dialog.close();
};
save.addEventListener("click", commit);
cancel.addEventListener("click", () => dialog.close());
dialog.addEventListener("keydown", event => {
event.stopPropagation();
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
commit();
}
});
dialog.addEventListener("keyup", event => event.stopPropagation());
dialog.addEventListener("close", () => {
state.editor = null;
dialog.remove();
state.root.querySelector(".ausboss-callout-tool")?.focus();
}, { once: true });
state.editor = dialog;
document.body.append(dialog);
dialog.showModal();
text.focus();
text.select();
}
function startEdit(state) {
if (state.editor) return;
if (state.card.dataset.tile) return startTileEdit(state);
const wrap = el("div", "ausboss-callout-editor");
const area = el("textarea", "ausboss-callout-edit");
area.value = state.valueWidget.value ?? "";
@@ -616,17 +499,16 @@ function buildPanel(node) {
root.append(card);
tools.addEventListener("pointerdown", (event) => event.stopPropagation());
const panelHeight = () => (card.dataset.tile ? 36 : PANEL_MIN_HEIGHT) + WIDGET_FRAME;
const domWidget = node.addDOMWidget(WIDGET_NAME, "ausboss_callout", root, {
serialize: false,
hideOnZoom: false,
getMinHeight: panelHeight,
getMinHeight: () => PANEL_MIN_HEIGHT + WIDGET_FRAME,
});
keepDomWidgetWidthAuto(domWidget);
domWidget.serialize = false;
fillNodeHeight(domWidget, {
minWidth: PANEL_MIN_WIDTH,
minHeight: panelHeight,
minHeight: () => PANEL_MIN_HEIGHT + WIDGET_FRAME,
minNodeSize: [PANEL_MIN_WIDTH, PANEL_MIN_HEIGHT + 40],
exactMinWidth: true,
});
@@ -634,9 +516,6 @@ function buildPanel(node) {
const state = { node, valueWidget, domWidget, root, card, inner, empty: true, editor: null, frame: 0 };
node.__ausbossCallout = state;
edit.addEventListener("click", () => startEdit(state));
card.addEventListener("dblclick", (event) => {
if (card.dataset.tile && !event.target.closest("a,button,textarea,.ausboss-callout-editor")) startEdit(state);
});
state.observer = new ResizeObserver(() => {
cancelAnimationFrame(state.frame);
state.frame = requestAnimationFrame(() => fit(state));
@@ -656,7 +535,7 @@ app.registerExtension({
// A note copied with what its links name follows the copies.
onPaste(({ nodes, groups, created }) => {
for (const node of created) {
if (!CALLOUT_TYPES.has(node?.comfyClass) && !CALLOUT_TYPES.has(node?.type)) continue;
if (node?.comfyClass !== NODE_CLASS && node?.type !== NODE_CLASS) continue;
const state = buildPanel(node);
if (!state) continue;
const text = repointLinks(state.valueWidget.value, nodes, groups);
@@ -667,29 +546,16 @@ app.registerExtension({
});
},
beforeRegisterNodeDef(nodeType, nodeData) {
if (!CALLOUT_TYPES.has(nodeData?.name)) return;
if (nodeData?.name !== NODE_CLASS) return;
hideInputsInDef(nodeData, ["text"]);
chainCallback(nodeType.prototype, "onNodeCreated", function () {
if (nodeData.name === "AUSBOSS_NODES_CalloutTile") {
this.properties ??= {};
this.properties.ausbossCalloutTile ??= "title";
}
buildPanel(this);
// Keep new tiles compact without changing ordinary or saved callouts.
if (nodeData.name === "AUSBOSS_NODES_CalloutTile") this.setSize?.([380, 80]);
});
chainCallback(nodeType.prototype, "onSerialize", function (saved) {
serializeNoteIdentity(saved, ["youtube", "discord", "title"].includes(this.properties?.ausbossCalloutTile));
});
chainCallback(nodeType.prototype, "onConfigure", function (saved) {
chainCallback(nodeType.prototype, "onConfigure", function () {
// Saved values land after onNodeCreated; show them.
queueMicrotask(() => {
const state = buildPanel(this);
if (!state) return;
renderText(state);
if (state.card.dataset.tile && Number.isFinite(saved?.size?.[1])) {
this.setSize?.([this.size[0], saved.size[1]]);
}
if (state) renderText(state);
});
});
chainCallback(nodeType.prototype, "onDblClick", function () {
@@ -699,24 +565,12 @@ app.registerExtension({
chainCallback(nodeType.prototype, "getExtraMenuOptions", function (_canvas, options) {
const state = this.__ausbossCallout;
if (!state || !Array.isArray(options)) return;
for (const [style, label] of [["plain","Plain callout"],["title","Title tile"],["youtube","Set YouTube link tile"],["discord","Set Discord link tile"]]) {
options.unshift({ content: label, callback: () => {
this.properties ??= {};
this.properties.ausbossCalloutTile = style;
const preset = LINK_TILES[style];
if (preset) commitWidgetValue(this, state.valueWidget, `[${preset.label}](${preset.url})`, app.canvas);
renderText(state);
if (style !== "plain") this.setSize?.([this.size[0], style === "title" ? 86 : 78]);
markChanged();
}});
}
options.unshift({ content: "Edit callout text", callback: () => startEdit(state) }, null);
});
chainCallback(nodeType.prototype, "onRemoved", function () {
const state = this.__ausbossCallout;
if (!state) return;
state.observer?.disconnect();
state.editor?.close?.();
cancelAnimationFrame(state.frame);
this.__ausbossCallout = null;
});
-92
View File
@@ -1,92 +0,0 @@
// The settings brought up to a Camera Move box, as one compact card above
// the shot: what people change per clip on top (size, seed), the model files
// folded under "Models", since they are set once. A LoRA Loader's rows keep
// their own panel, with its strength bars; the camera keys are the shot.
//
// A box row shares its options with the setting inside the box, and Nodes 2.0
// reads "hidden" from there, so a row the card takes over is hidden inside the
// box too in that renderer (the classic canvas still shows it). Its value is
// set from the box either way.
import { mountWidgetCard } from "../shared/widget_card.mjs";
import { boxRowInputs } from "../shared/lora_box.mjs";
import { hideWidget } from "../shared/widget_visibility.mjs";
const STASH = "__ausbossVisibilityStash"; // widget_visibility.mjs: a row already hidden
const MODEL_FILE = /\.(safetensors|sft|ckpt|pt|pth|bin|gguf|onnx)$/i;
const SUFFIX = { megapixels: "MP" };
const ROW_TYPES = new Set(["number", "slider", "combo", "toggle", "text"]);
const isLoraLoader = (node) => node?.type === "AUSBOSS_NODES_LoraLoader" || node?.comfyClass === "AUSBOSS_NODES_LoraLoader";
const isModelRow = (widget) => {
if (widget?.type !== "combo") return false;
if (MODEL_FILE.test(String(widget.value ?? ""))) return true;
const values = typeof widget.options?.values === "function" ? [] : widget.options?.values;
return Array.isArray(values) && values.some((value) => MODEL_FILE.test(String(value)));
};
function labelOf(box, widget) {
const input = (box.inputs ?? []).find((slot) => slot?.widget?.name === widget.name);
const text = String(input?.label || widget.label || widget.name).replace(/_/g, " ");
return text.charAt(0).toUpperCase() + text.slice(1);
}
// The rows a box's card shows: every setting brought up to it except `skip`
// (the camera keys), a LoRA Loader's rows and anything with its own panel.
export function boxCardRows(box, skip) {
const loraRows = new Set(boxRowInputs(box, isLoraLoader).map((entry) => entry.name));
const top = [], models = [];
for (const widget of box.widgets ?? []) {
if (!widget || widget === skip || widget.element || !ROW_TYPES.has(String(widget.type)) || loraRows.has(widget.name)) continue;
if (widget[STASH] && !widget.__ausbossCameraCard) continue;
const row = { widget: widget.name, label: labelOf(box, widget) };
if (isModelRow(widget)) models.push({ ...row, kind: "select", group: "models" });
else top.push(SUFFIX[innerName(box, widget)] ? { ...row, suffix: SUFFIX[innerName(box, widget)] } : row);
}
return models.length ? [...top, { group: "models", label: `Models · ${models.length} files` }, ...models] : top;
}
// The name of the setting inside the box that a box row stands for.
function innerName(box, widget) {
const input = (box.subgraph?.inputs ?? []).find((slot) => slot?.name === widget.name);
for (const id of input?.linkIds ?? []) {
const link = box.subgraph.getLink?.(id) ?? box.subgraph.links?.get?.(id) ?? box.subgraph.links?.[id];
const node = link && (box.subgraph.getNodeById?.(link.target_id));
const name = node?.inputs?.[link.target_slot]?.widget?.name ?? node?.inputs?.[link.target_slot]?.name;
if (name) return name;
}
return widget.name;
}
// The frontend now and then makes a box's rows afresh (a new object under
// the same name), so the card hides its rows again whenever it finds one
// showing. Cheap: a handful of names.
export function rehideBoxCard(box) {
let changed = false;
for (const name of box.__ausbossCameraCardRows ?? []) {
const widget = (box.widgets ?? []).find((w) => w?.name === name);
if (!widget || widget[STASH]) continue;
hideWidget(widget);
widget.__ausbossCameraCard = true;
changed = true;
}
return changed;
}
// Mount the card once per box. Settings brought up later show as plain rows.
// `tidy` puts the box's panels back in order and says whether it moved one.
export function mountBoxCard(box, skip, tidy) {
if (box.__ausbossCameraCard) { rehideBoxCard(box); return false; }
const rows = boxCardRows(box, skip);
if (!rows.length) return false;
box.__ausbossCameraCardRows = rows.filter((row) => row.widget).map((row) => row.widget);
rehideBoxCard(box);
mountWidgetCard(box, { rows, minWidth: 300, first: true });
box.__ausbossCameraCard = true;
const draw = box.onDrawForeground;
box.onDrawForeground = function (...args) {
if (rehideBoxCard(this) | Boolean(tidy?.(this))) this.setDirtyCanvas?.(true, true);
return draw?.apply(this, args);
};
return true;
}
-183
View File
@@ -1,183 +0,0 @@
// Camera Move: the camera maths the face shares with the node.
//
// The same keys, pose and easing as nodes/_camera_move_helpers.py, so the
// picture on the node is the shot the warp makes. No DOM and no app import:
// tests/camera_move.test.mjs runs it under node:test.
//
// Axes are OpenCV's: x right, y down, z into the picture. A key is
// { t, az, el, back, px, py }: t from 0 to 1 over the clip, turn and rise in
// degrees, back and the pan in distances to the pivot.
export const LIM = { az: [-90, 90], el: [-30, 60], back: [-0.6, 2], px: [-1, 1], py: [-1, 1] };
export const K0 = { az: 0, el: 0, back: 0, px: 0, py: 0 };
export const FIELDS = Object.keys(K0);
// Two keys this close on the clip are the same key.
export const SAME_KEY = 0.015;
export const MAX_KEYS = 64;
export const LORA_RANGE = { az: 45, up: 30, down: -20 };
export const DEFAULT_KEYS = [{ t: 0, ...K0 }, { t: 1, ...K0, az: 30, el: 5 }];
export const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const num = (v, fallback = 0) => {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
};
export function cleanKey(raw) {
const key = { t: clamp(num(raw?.t), 0, 1) };
for (const name of FIELDS) key[name] = clamp(num(raw?.[name]), ...LIM[name]);
return key;
}
// The camera a node holds: { keys, pivot }. Unreadable text gives the default move.
export function parseCamera(text) {
let data = null;
if (text && typeof text === "object") data = text;
else if (typeof text === "string" && text.trim()) {
try { data = JSON.parse(text); } catch { data = null; }
}
const fallback = !data || !Array.isArray(data.keys) || !data.keys.length;
if (fallback) data = { keys: DEFAULT_KEYS, pivot: null };
const keys = [];
for (const raw of data.keys.slice(0, MAX_KEYS)) {
const key = cleanKey(raw);
const same = keys.find((k) => Math.abs(k.t - key.t) < SAME_KEY);
if (same) Object.assign(same, key);
else keys.push(key);
}
keys.sort((a, b) => a.t - b.t);
const p = data.pivot;
const pivot = p && typeof p === "object" && "u" in p && "v" in p
? { u: clamp(num(p.u, 0.5), 0, 1), v: clamp(num(p.v, 0.5), 0, 1), t: clamp(num(p.t), 0, 1) }
: null;
return { keys, pivot, fallback };
}
const round = (v, places) => Math.round(v * 10 ** places) / 10 ** places;
export function cameraText({ keys, pivot }) {
const out = {
v: 1,
keys: keys.map((k) => ({ t: round(k.t, 4), az: round(k.az, 2), el: round(k.el, 2), back: round(k.back, 3), px: round(k.px, 3), py: round(k.py, 3) })),
pivot: pivot ? { u: round(pivot.u, 4), v: round(pivot.v, 4), t: round(pivot.t, 4) } : null,
};
return JSON.stringify(out);
}
// How fast one field moves as it passes each key: zero at the first and last
// key (a gentle start and end) and where the field turns back or holds (it
// never runs past a key); elsewhere it keeps its speed through the key, so a
// move made of several keys does not slow down at each one.
function keySlopes(ts, ys) {
const n = ys.length, m = new Array(n).fill(0);
for (let k = 1; k < n - 1; k++) {
const h0 = ts[k] - ts[k - 1], h1 = ts[k + 1] - ts[k];
if (h0 <= 0 || h1 <= 0) continue;
const d0 = (ys[k] - ys[k - 1]) / h0, d1 = (ys[k + 1] - ys[k]) / h1;
if (d0 * d1 > 0) m[k] = 3 * (h0 + h1) / ((2 * h1 + h0) / d0 + (h1 + 2 * h0) / d1);
}
return m;
}
// Where the camera is at moment t: through every key, gently at the ends.
// Same maths as camera_at in nodes/_camera_move_helpers.py.
export function camAt(keys, t) {
if (!keys.length) return { ...K0 };
const n = keys.length;
const pick = (k) => { const o = {}; for (const f of FIELDS) o[f] = k[f] || 0; return o; };
if (n === 1 || t <= keys[0].t) return pick(keys[0]);
if (t >= keys[n - 1].t) return pick(keys[n - 1]);
const ts = keys.map((k) => k.t);
for (let i = 0; i < n - 1; i++) {
const a = keys[i], b = keys[i + 1];
if (t >= a.t && t <= b.t) {
const h = Math.max(1e-6, b.t - a.t), s = (t - a.t) / h;
const h00 = 2 * s ** 3 - 3 * s ** 2 + 1, h10 = s ** 3 - 2 * s ** 2 + s, h01 = -2 * s ** 3 + 3 * s ** 2, h11 = s ** 3 - s ** 2;
const o = {};
for (const f of FIELDS) {
const m = keySlopes(ts, keys.map((k) => k[f] || 0));
o[f] = h00 * (a[f] || 0) + h10 * h * m[i] + h01 * (b[f] || 0) + h11 * h * m[i + 1];
}
return o;
}
}
return pick(keys[n - 1]);
}
export const keyAt = (keys, t) => keys.findIndex((k) => Math.abs(k.t - t) < SAME_KEY);
// Moving the camera at the time bar sets the key there, or adds one.
export function setKeyAt(keys, t, cam) {
const c = {};
for (const f of FIELDS) c[f] = clamp(num(cam[f]), ...LIM[f]);
const i = keyAt(keys, t);
if (i >= 0) { Object.assign(keys[i], c); return keys; }
if (keys.length >= MAX_KEYS) return keys;
keys.push({ t: clamp(t, 0, 1), ...c });
keys.sort((a, b) => a.t - b.t);
return keys;
}
export const pastLoraRange = (c) => Math.abs(c.az) > LORA_RANGE.az || c.el > LORA_RANGE.up || c.el < LORA_RANGE.down;
// R = Ry(-az) Rx(-el), row-major.
export function rot(az, el) {
const th = -az * Math.PI / 180, ph = -el * Math.PI / 180;
const ct = Math.cos(th), st = Math.sin(th), cp = Math.cos(ph), sp = Math.sin(ph);
return [ct, st * sp, st * cp, 0, cp, -sp, -st, ct * sp, ct * cp];
}
export const mv = (R, v) => [R[0] * v[0] + R[1] * v[1] + R[2] * v[2], R[3] * v[0] + R[4] * v[1] + R[5] * v[2], R[6] * v[0] + R[7] * v[1] + R[8] * v[2]];
// The moved camera's rotation R and centre C. Pan slides the camera in its own frame.
export function pose(cam, pivot) {
const R = rot(cam.az, cam.el), p = pivot, Rp = mv(R, p), d = Math.hypot(p[0], p[1], p[2]);
const b = mv(R, [(cam.px || 0) * d, (cam.py || 0) * d, -(cam.back || 0) * d]);
return { R, C: [p[0] - Rp[0] + b[0], p[1] - Rp[1] + b[1], p[2] - Rp[2] + b[2]] };
}
// A world point in the moved camera: R^T (P - C).
export function toCam(R, C, P) {
const d = [P[0] - C[0], P[1] - C[1], P[2] - C[2]];
return [R[0] * d[0] + R[3] * d[1] + R[6] * d[2], R[1] * d[0] + R[4] * d[1] + R[7] * d[2], R[2] * d[0] + R[5] * d[1] + R[8] * d[2]];
}
// The node's depth picture: red is the high byte, green the low one, on a log
// scale from zmin to zmax; 0 is no depth (sky), which sits at `far`.
export function unpackDepth(rgba, count, zmin, zmax, far) {
const z = new Float32Array(count), valid = new Uint8Array(count);
const lmin = Math.log(zmin), lr = Math.log(zmax) - lmin;
for (let i = 0; i < count; i++) {
const q = rgba[i * 4] * 256 + rgba[i * 4 + 1];
if (q === 0) z[i] = far;
else { z[i] = Math.exp(lmin + (q - 1) / 65534 * lr); valid[i] = 1; }
}
return { z, valid };
}
// The 3D spot under a pixel of a frame: the middle of the nearest depth found
// within three pixels, as the node does it. Null over sky.
export function pivotFromPixel(frame, u, v) {
const { W, H, z, valid, fx, fy, cx, cy } = frame;
const x = clamp(Math.floor(u * W), 0, W - 1), y = clamp(Math.floor(v * H), 0, H - 1);
for (let r = 0; r <= 3; r++) {
const found = [];
for (let yy = Math.max(0, y - r); yy <= Math.min(H - 1, y + r); yy++) {
for (let xx = Math.max(0, x - r); xx <= Math.min(W - 1, x + r); xx++) if (valid[yy * W + xx]) found.push(z[yy * W + xx]);
}
if (found.length) {
found.sort((a, b) => a - b);
const depth = found.length % 2 ? found[found.length >> 1] : (found[found.length / 2 - 1] + found[found.length / 2]) / 2;
return [(x + 0.5 - cx) / fx * depth, (y + 0.5 - cy) / fy * depth, depth];
}
}
return null;
}
// Words for where the camera is.
export function fmtAz(v) { const r = Math.round(v); return r === 0 ? "0°" : `${Math.abs(r)}° ${r > 0 ? "right" : "left"}`; }
export function fmtEl(v) { const r = Math.round(v); return r === 0 ? "0°" : `${Math.abs(r)}° ${r > 0 ? "up" : "down"}`; }
export function describe(c) {
const part = (v, neg, pos) => (Math.abs(v) < 0.005 ? "" : `${Math.abs(v).toFixed(2)} ${v > 0 ? pos : neg}`);
const pan = [part(c.px, "left", "right"), part(c.py, "up", "down")].filter(Boolean).join(", ");
return `Turn ${fmtAz(c.az)} · Rise ${fmtEl(c.el)} · ${c.back < -0.004 ? "In" : "Back"} ${Math.abs(c.back).toFixed(2)}×${pan ? ` · Pan ${pan}` : ""}`;
}
-900
View File
@@ -1,900 +0,0 @@
// Camera Move's face: the shot on the node, steered by grabbing it.
//
// Drag the picture to turn the camera round, pinch or scroll to pull back,
// two fingers or Shift to pan, tap a spot to circle round it. Top right, as
// in Blender: an axis ball, a magnifier, a hand, and a camera that puts you
// back on your own shot. Under it the time bar with its keys.
//
// No app import: index.js hands in where the camera is kept, how to run the
// node and how to reach a file, so the same face sits on the node or on the
// box that holds it.
//
// The stage is the one part of the node that keeps the mouse wheel (it zooms
// the camera, as in Blender); its root carries no "ausboss-" class, so the
// pack's wheel hand-off leaves it alone. The controls under it do carry one,
// and the wheel there zooms the graph as everywhere else.
import { makeScrubInput } from "../shared/scrub_input.mjs";
import {
K0, LIM, SAME_KEY, camAt, cameraText, clamp, describe, keyAt, mv, parseCamera, pastLoraRange,
pivotFromPixel, pose, rot, setKeyAt, toCam, unpackDepth,
} from "./camera_math.mjs";
export const MIN_WIDTH = 380; // the play row on one line, with "243 / 243" frames
export const MIN_STAGE = 184;
export const CONTROLS_HEIGHT = 168;
const FPS = 24; // H3 renders at 24 frames a second; only playback speed uses it
const MAG = 0xFFFF00FF;
const CSS = `
.acm-face{position:relative;display:flex;flex-direction:column;width:100%;height:100%;box-sizing:border-box;
--acm-teal:#4fb3a9;--acm-teal-dim:#2b605b;--acm-line:#34413f;--acm-fg:#e3e9e8;--acm-muted:#92a2a1;--acm-amber:#e2a94c;
--acm-stage:#0c0f10;--acm-panel:#1f2629;--acm-head:#283236;color:var(--acm-fg);font:12.5px system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
.acm-face *{box-sizing:border-box}
.acm-stage{position:relative;flex:1 1 auto;min-height:${MIN_STAGE}px;background:var(--acm-stage);border-radius:6px;overflow:hidden;
touch-action:none;user-select:none;-webkit-user-select:none}
.acm-stage>canvas.acm-pic{position:absolute;display:block;cursor:grab}
.acm-stage>canvas.acm-pic:active{cursor:grabbing}
.acm-pivot{position:absolute;width:18px;height:18px;margin:-9px 0 0 -9px;border:2px solid var(--acm-teal);border-radius:50%;pointer-events:none;box-shadow:0 0 0 1px rgba(0,0,0,.6)}
.acm-pivot::after{content:"";position:absolute;left:50%;top:50%;width:3px;height:3px;margin:-1.5px;background:var(--acm-teal);border-radius:50%}
.acm-nav{position:absolute;top:6px;right:6px;display:flex;flex-direction:column;align-items:center;gap:5px}
.acm-nav canvas{width:64px;height:64px;cursor:grab;touch-action:none;margin-bottom:1px}
.acm-nav button,.acm-helpbtn{width:30px;height:30px;border-radius:50%;border:1px solid rgba(146,162,161,.4);background:rgba(12,15,16,.62);
color:var(--acm-fg);padding:0;display:grid;place-items:center;touch-action:none;box-shadow:0 1px 6px rgba(0,0,0,.45);cursor:pointer}
.acm-nav button svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
.acm-nav button:hover,.acm-helpbtn:hover{border-color:var(--acm-teal)}
.acm-nav button.on,.acm-helpbtn[aria-expanded="true"]{background:var(--acm-teal-dim);border-color:var(--acm-teal)}
.acm-nav .pan{cursor:move}.acm-nav .zoom{cursor:ns-resize}
.acm-helpbtn{position:absolute;top:6px;left:6px;width:24px;height:24px;font:600 12px system-ui,sans-serif}
.acm-inset{position:absolute;left:6px;bottom:6px;width:36%;border:1px solid var(--acm-line);border-radius:5px;overflow:hidden;background:var(--acm-stage);
box-shadow:0 2px 10px rgba(0,0,0,.5);pointer-events:none}
.acm-inset canvas{display:block;width:100%;height:auto}
.acm-tag{position:absolute;top:4px;left:5px;font-size:10px;color:var(--acm-muted);background:rgba(12,15,16,.7);padding:1px 5px;border-radius:4px;pointer-events:none}
.acm-empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:16px;text-align:center;color:var(--acm-muted);line-height:1.4}
.acm-empty b{color:var(--acm-fg);font-weight:600;font-size:13px}
.ausboss-camera-move-controls{flex:0 0 auto;display:flex;flex-direction:column;gap:6px;padding:7px 0 2px}
.acm-readout{display:flex;justify-content:space-between;gap:8px;font-size:11.5px;color:var(--acm-fg);font-variant-numeric:tabular-nums;min-height:15px}
.acm-readout .acm-where{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.acm-readout .acm-pink{color:var(--acm-muted);flex:0 0 auto}
.acm-warn{color:var(--acm-amber);font-size:11.5px;line-height:15px;height:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.acm-back{display:flex;align-items:center;gap:8px}
.acm-back>span{width:40px;color:var(--acm-muted)}
.acm-track{position:relative;height:34px;background:var(--acm-stage);border:1px solid var(--acm-line);border-radius:6px;touch-action:none;cursor:pointer}
.acm-track .acm-fill{position:absolute;top:15px;height:2px;left:8px;right:8px;background:var(--acm-line)}
.acm-track .acm-inner{position:absolute;left:8px;right:8px;top:0;bottom:0}
.acm-track .acm-key{position:absolute;top:10px;width:12px;height:12px;margin-left:-6px;transform:rotate(45deg);background:var(--acm-teal);border:1px solid #0c0f10;cursor:ew-resize}
.acm-track .acm-key.on{background:#bfe9e4}
.acm-track .acm-head{position:absolute;top:2px;bottom:2px;width:2px;margin-left:-1px;background:var(--acm-fg);pointer-events:none}
.acm-track .acm-head::before{content:"";position:absolute;top:-2px;left:-4px;width:10px;height:6px;background:var(--acm-fg);border-radius:2px}
.acm-row{display:flex;align-items:center;gap:6px}
.acm-row>.acm-keynav,.acm-row>.acm-btn{flex:none;white-space:nowrap}
.acm-btn{border:1px solid var(--acm-line);background:var(--acm-head);color:var(--acm-fg);font:inherit;font-size:12.5px;padding:6px 10px;border-radius:6px;cursor:pointer;line-height:1.1}
.acm-btn:disabled{opacity:.4;cursor:default}
.acm-btn.primary{background:var(--acm-teal-dim);border-color:var(--acm-teal-dim)}
.acm-keynav{display:inline-flex;border:1px solid var(--acm-line);border-radius:6px;background:var(--acm-head);overflow:hidden}
.acm-keynav button{width:32px;height:29px;border:0;background:transparent;color:var(--acm-fg);padding:0;display:grid;place-items:center;cursor:pointer}
.acm-keynav button+button{border-left:1px solid var(--acm-line)}
.acm-keynav button:disabled{color:var(--acm-muted);opacity:.35;cursor:default}
.acm-keynav button:not(:disabled):hover{background:var(--acm-panel)}
.acm-keynav svg{width:14px;height:14px}
.acm-keynav .dia svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.6}
.acm-keynav .dia.on svg{fill:#bfe9e4;stroke:#bfe9e4}
.acm-keynav .arr svg{fill:currentColor}
.acm-keynav.transport .play{width:36px;background:var(--acm-teal-dim);font-size:12px}
.acm-keynav.transport .play:hover{background:var(--acm-teal)}
.acm-keynav.transport .loop{color:var(--acm-muted)}
.acm-keynav.transport .loop svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}
.acm-keynav.transport .loop[aria-pressed="true"]{color:#bfe9e4;background:var(--acm-teal-dim)}
.acm-time{margin-left:auto;font-size:11.5px;color:var(--acm-muted);font-variant-numeric:tabular-nums;white-space:nowrap}
.acm-help{position:absolute;top:36px;left:6px;right:6px;z-index:5;background:#172023;border:1px solid var(--acm-line);border-radius:8px;
padding:10px 12px 12px;box-shadow:0 8px 28px rgba(0,0,0,.55);max-height:calc(100% - 44px);overflow:auto}
.acm-help-h{display:flex;justify-content:space-between;align-items:center;font-weight:600;font-size:13px;margin-bottom:6px}
.acm-help-x{border:0;background:transparent;color:var(--acm-muted);font-size:20px;line-height:1;cursor:pointer;padding:0 2px}
.acm-help dl{margin:0;display:grid;grid-template-columns:auto 1fr;gap:7px 12px;font-size:12.5px;line-height:1.4}
.acm-help dt{color:var(--acm-teal);font-weight:600}.acm-help dd{margin:0}
.acm-nav,.acm-keynav,.acm-btn,.acm-helpbtn{-webkit-touch-callout:none;-webkit-user-select:none;user-select:none}
.acm-tip{position:fixed;z-index:10000;max-width:250px;background:#0a0d0e;color:#e3e9e8;border:1px solid #34413f;border-radius:6px;
padding:5px 8px;font:12px/1.35 system-ui,sans-serif;pointer-events:none;box-shadow:0 4px 14px rgba(0,0,0,.5)}
.acm-face [hidden],.acm-tip[hidden]{display:none!important}
`;
function ensureCss() {
if (document.getElementById("ausboss-camera-move-css")) return;
const style = document.createElement("style");
style.id = "ausboss-camera-move-css";
style.textContent = CSS;
document.head.append(style);
}
const el = (tag, cls, attrs = {}) => {
const e = document.createElement(tag);
if (cls) e.className = cls;
for (const [k, v] of Object.entries(attrs)) {
if (k === "text") e.textContent = v;
else if (k === "html") e.innerHTML = v;
else e.setAttribute(k, v);
}
return e;
};
const NAV_ICONS = {
zoom: '<svg viewBox="0 0 24 24"><circle cx="10.5" cy="10.5" r="6"/><path d="M15 15l5 5"/></svg>',
pan: '<svg viewBox="0 0 24 24"><path d="M7.5 12.5V6.3a1.25 1.25 0 0 1 2.5 0v5M10 11.3V4.8a1.25 1.25 0 0 1 2.5 0v6.5M12.5 11.3V5.6a1.25 1.25 0 0 1 2.5 0v6M15 11.8V8a1.25 1.25 0 0 1 2.5 0v5.5c0 3.9-2.6 6.8-6.3 6.8h-.6c-2.1 0-3.6-.9-4.8-2.6l-2.4-3.5a1.35 1.35 0 0 1 2.1-1.7l2 2.3"/></svg>',
shot: '<svg viewBox="0 0 24 24"><rect x="2.8" y="7.3" width="12.6" height="9.4" rx="1.8"/><path d="M15.4 10.6 21 7.8v8.4l-5.6-2.8"/></svg>',
};
const NAV_TIPS = { zoom: "Zoom: drag up to push in, down to pull back", pan: "Pan: drag to slide the camera", shot: "Back to your own shot" };
const GZ = 64;
// Blender's names for the scene's directions: X to the right, Y into the picture, Z up.
const AXES = [
{ n: "X", v: [1, 0, 0], col: "#e05561", pos: { az: 90, el: 0 }, neg: { az: -90, el: 0 } },
{ n: "Y", v: [0, 0, 1], col: "#86b84a", pos: null, neg: { az: 0, el: 0 } }, // nothing to see from behind the scene
{ n: "Z", v: [0, -1, 0], col: "#4b8fdf", pos: { az: 0, el: LIM.el[1] }, neg: { az: 0, el: LIM.el[0] } },
];
const SUB = [[-0.25, -0.25], [0.25, -0.25], [-0.25, 0.25], [0.25, 0.25]];
const VIEW = { az: -58, el: 28 }; // the corner map looks at the scene from the left
// ---------- tooltips: hover on a computer, press and hold a button on a phone ----------
let tipEl = null, tipTimer = 0, tipFor = null, tipXY = [0, 0], tipHeld = false;
function tip() {
if (!tipEl || !tipEl.isConnected) {
tipEl = el("div", "acm-tip", { role: "tooltip" });
tipEl.hidden = true;
document.body.append(tipEl);
}
return tipEl;
}
function placeTip(target) {
const t = target.dataset.tip, box = tip();
if (!t || !target.isConnected) return hideTip();
box.textContent = t;
box.hidden = false;
const r = target.getBoundingClientRect(), tw = box.offsetWidth, th = box.offsetHeight, atPtr = target.dataset.tipAt === "pointer";
if (target.closest(".acm-nav")) { // the column top right: the tip opens to its left and covers no other button
box.style.left = `${Math.max(8, r.left - tw - 8)}px`;
box.style.top = `${r.top + r.height / 2 - th / 2}px`;
return;
}
const ax = atPtr ? tipXY[0] : r.left + r.width / 2;
const below = atPtr ? tipXY[1] + 22 : r.bottom + 8, above = atPtr ? tipXY[1] - th - 12 : r.top - th - 8;
box.style.left = `${clamp(ax - tw / 2, 8, innerWidth - tw - 8)}px`;
box.style.top = `${below + th > innerHeight - 8 ? above : below}px`;
}
function hideTip() { clearTimeout(tipTimer); if (tipEl) tipEl.hidden = true; tipFor = null; }
const armTip = (target, ms) => { clearTimeout(tipTimer); tipFor = target; tipTimer = setTimeout(() => placeTip(target), ms); };
function wireTips(root) {
const targetOf = (e) => e.target?.closest?.("[data-tip]") ?? null;
root.addEventListener("pointermove", (e) => {
if (e.pointerType !== "mouse") return;
tipXY = [e.clientX, e.clientY];
if (e.buttons) { if (tipFor) hideTip(); return; }
const t = targetOf(e);
if (!t) { if (tipFor) hideTip(); return; }
if (t !== tipFor) { hideTip(); armTip(t, t.dataset.tipAt === "pointer" ? 900 : 450); }
else if (t.dataset.tipAt === "pointer") { hideTip(); armTip(t, 900); } // over a picture the tip waits until the mouse rests
}, true);
root.addEventListener("pointerleave", hideTip);
root.addEventListener("pointerdown", (e) => {
hideTip();
if (e.pointerType === "mouse") return;
const t = targetOf(e);
if (!t || t.dataset.tipAt === "pointer") return;
const x0 = e.clientX, y0 = e.clientY;
armTip(t, 500);
const done = () => { removeEventListener("pointermove", move, true); removeEventListener("pointerup", up, true); removeEventListener("pointercancel", up, true); };
const move = (m) => { if (Math.hypot(m.clientX - x0, m.clientY - y0) > 8) { clearTimeout(tipTimer); done(); } };
const up = () => {
if (tipEl && !tipEl.hidden) { tipHeld = true; setTimeout(() => { tipHeld = false; }, 400); setTimeout(hideTip, 1600); } else clearTimeout(tipTimer);
done();
};
addEventListener("pointermove", move, true); addEventListener("pointerup", up, true); addEventListener("pointercancel", up, true);
}, true);
// A hold that showed a tip is not a click.
root.addEventListener("click", (e) => { if (tipHeld) { tipHeld = false; e.preventDefault(); e.stopPropagation(); } }, true);
root.addEventListener("contextmenu", (e) => { if (e.target.closest?.(".acm-nav, .acm-keynav, .acm-btn")) e.preventDefault(); });
}
// ---------- one frame of the clip, as points ----------
async function loadFrame(entry, info, view, blobOf) {
const W = info.w, H = info.h;
const picture = new Image();
picture.decoding = "async";
const pictureReady = new Promise((ok, fail) => { picture.onload = ok; picture.onerror = () => fail(new Error("picture")); });
picture.src = view(entry.color);
const depthBlob = await blobOf(entry.depth);
const depthBitmap = await createImageBitmap(depthBlob, { colorSpaceConversion: "none", premultiplyAlpha: "none" });
await pictureReady;
const canvas = document.createElement("canvas");
canvas.width = W; canvas.height = H;
const g = canvas.getContext("2d", { willReadFrequently: true });
g.drawImage(picture, 0, 0, W, H);
const rgba = new Uint32Array(g.getImageData(0, 0, W, H).data.buffer.slice(0));
g.clearRect(0, 0, W, H);
g.drawImage(depthBitmap, 0, 0, W, H);
const { z, valid } = unpackDepth(g.getImageData(0, 0, W, H).data, W * H, info.zmin, info.zmax, info.far);
// the corner map leaves out what is very far away
const zs = [];
for (let i = 0; i < W * H; i += 5) if (valid[i]) zs.push(z[i]);
zs.sort((a, b) => a - b);
const zcap = Math.min(zs[Math.floor(zs.length * 0.97)] || info.far, (zs[zs.length >> 1] || 1) * 3.5);
return { ...entry, W, H, z, valid, rgba, zcap, picture };
}
// ---------- the face ----------
// opts: read() -> camera text, write(text), run() -> Promise<boolean>,
// view(fileEntry) -> url, onSize() when the floor changes.
export function mountCameraFace(opts) {
ensureCss();
const root = el("div", "acm-face");
const stage = el("div", "acm-stage");
const pic = el("canvas", "acm-pic", { "data-tip": "Drag to turn · Shift-drag to pan · wheel to zoom · click a spot to circle round it", "data-tip-at": "pointer" });
const picCtx = pic.getContext("2d");
const pivotMark = el("div", "acm-pivot");
const nav = el("div", "acm-nav");
const gizmo = el("canvas", "", { "data-tip": "Drag to turn. Click an axis to look from that side.", role: "img", "aria-label": "Axis ball: drag to turn the camera, tap an axis to look from that side" });
gizmo.width = GZ * 2; gizmo.height = GZ * 2;
const gctx = gizmo.getContext("2d");
nav.append(gizmo);
const inset = el("div", "acm-inset");
const out = el("canvas");
out.width = 360; out.height = 225;
const outCtx = out.getContext("2d");
const outImg = outCtx.createImageData(out.width, out.height);
const outZ = new Float32Array(out.width * out.height);
inset.append(out, el("span", "acm-tag", { text: "where the camera is" }));
const helpBtn = el("button", "acm-helpbtn", { text: "?", "aria-label": "How to steer", "aria-expanded": "false", "data-tip": "How to steer" });
const empty = el("div", "acm-empty");
stage.append(pic, pivotMark, inset, nav, helpBtn, empty);
const controls = el("div", "ausboss-camera-move-controls");
const readout = el("div", "acm-readout");
const where = el("span", "acm-where");
const pink = el("span", "acm-pink", { "data-tip": "How much of the frame the camera never saw. The LoRA paints it in." });
readout.append(where, pink);
// Always one line tall, empty or not, so a warning never pushes the controls down.
const warn = el("div", "acm-warn");
const backRow = el("div", "acm-back");
const back = makeScrubInput({
value: 0, min: LIM.back[0], max: LIM.back[1], step: 0.05, fineStep: 0.01, decimals: 2, unit: "×", unitWidth: 14, width: 132,
title: "How far the camera pulls back from what it circles. Below 0 pushes in.",
onChange: (v) => { stopPlay(); setCam({ ...camAt(keys, p), back: v }); },
});
backRow.append(el("span", "", { text: "Back" }), back.root);
const track = el("div", "acm-track", { "data-tip": "Click to put a key here. Drag to move the time bar, or drag a key to change its time.", "data-tip-at": "pointer" });
const inner = el("div", "acm-inner");
const head = el("div", "acm-head");
inner.append(head);
track.append(el("div", "acm-fill"), inner);
const row = el("div", "acm-row");
const transport = el("span", "acm-keynav transport", { role: "group", "aria-label": "Playback" });
const toStartBtn = el("button", "arr", { "aria-label": "Back to the start", "data-tip": "Back to the start", html: '<svg viewBox="0 0 14 14"><path d="M2.5 2.5h1.6v9H2.5zM11.5 2.5v9L5 7z"/></svg>' });
const play = el("button", "play", { text: "▶", "aria-label": "Play", "data-tip": "Play" });
const loop = el("button", "loop", { "aria-label": "Play on a loop", "aria-pressed": "false", "data-tip": "Play on a loop", html: '<svg viewBox="0 0 16 16"><path d="M3 9.5V7a2.5 2.5 0 0 1 2.5-2.5H12M10 2.5l2 2-2 2M13 6.5V9a2.5 2.5 0 0 1-2.5 2.5H4M6 13.5l-2-2 2-2"/></svg>' });
transport.append(toStartBtn, play, loop);
const keynav = el("span", "acm-keynav", { role: "group", "aria-label": "Keys" });
const prevKey = el("button", "arr", { "aria-label": "Previous key", "data-tip": "Previous key", html: '<svg viewBox="0 0 14 14"><path d="M10 2.5v9L3.5 7z"/></svg>' });
const toggleKey = el("button", "dia", { "aria-label": "Add a key here", "data-tip": "Add a key here", html: '<svg viewBox="0 0 16 16"><path d="M8 1.8 14.2 8 8 14.2 1.8 8z"/></svg>' });
const nextKey = el("button", "arr", { "aria-label": "Next key", "data-tip": "Next key", html: '<svg viewBox="0 0 14 14"><path d="M4 2.5v9L10.5 7z"/></svg>' });
keynav.append(prevKey, toggleKey, nextKey);
const reset = el("button", "acm-btn", { text: "Start over", "data-tip": "One key again, on your own shot" });
const time = el("span", "acm-time", { "data-tip": "Frame at the time bar" });
row.append(transport, keynav, reset, time);
controls.append(readout, warn, backRow, track, row);
const help = el("div", "acm-help");
help.hidden = true;
root.append(stage, controls, help);
wireTips(root);
// ---------- state ----------
let keys = [], pivotSpec = null, lastText = null, p = 0, playing = false, lastT = 0, dirty = true, alive = true;
let info = null, frames = [], shown = null, pivot = [0, 0, 2], pinkPct = 0, status = "idle", statusNote = "";
let flashText = "", flashUntil = 0, gBalls = [], gHot = false, hovered = false, overFace = false, looping = false, needsWrite = false;
let buf = null; // z, colour and source pixel of the moved view
const fallbackLens = { W: 416, H: 234, fx: 380, fy: 380, cx: 208, cy: 117 };
function readCamera() {
const text = opts.read();
if (text === lastText) return;
lastText = text;
const parsed = parseCamera(text);
keys = parsed.keys;
pivotSpec = parsed.pivot;
resolvePivot();
dirty = true;
}
function writeSoon() { needsWrite = true; dirty = true; }
function flushWrite() {
if (!needsWrite) return;
needsWrite = false;
const text = cameraText({ keys, pivot: pivotSpec });
lastText = text;
if (text !== opts.read()) opts.write(text);
}
function setCam(cam) { setKeyAt(keys, p, cam); writeSoon(); }
function lens() { return shown ?? (info ? { ...fallbackLens, ...info.frames[0], W: info.w, H: info.h } : fallbackLens); }
function resolvePivot() {
if (!info) { pivot = [0, 0, 2]; return; }
if (!pivotSpec) { pivot = info.pivot_auto?.slice() ?? [0, 0, 2]; return; }
if (info.pivot_from && Math.abs(info.pivot_from.u - pivotSpec.u) < 1e-4 && Math.abs(info.pivot_from.v - pivotSpec.v) < 1e-4
&& Math.abs(info.pivot_from.t - pivotSpec.t) < 1e-4) { pivot = info.pivot.slice(); return; }
const f = nearestLoaded(pivotSpec.t);
const found = f && pivotFromPixel(f, pivotSpec.u, pivotSpec.v);
if (found) pivot = found;
}
// ---------- frames ----------
function nearestFrame(t) {
let best = null;
for (const f of frames) if (!best || Math.abs(f.t - t) < Math.abs(best.t - t)) best = f;
return best;
}
function nearestLoaded(t) {
let best = null;
for (const f of frames) if (f.data && (!best || Math.abs(f.t - t) < Math.abs(best.t - t))) best = f;
return best?.data ?? null;
}
async function loadAll(payload) {
const order = [...frames].sort((a, b) => Math.abs(a.t - p) - Math.abs(b.t - p));
let failed = 0, next = 0;
// Four at a time, the frames nearest the time bar first.
const worker = async () => {
while (next < order.length) {
const f = order[next++];
if (info !== payload) return;
try { f.data = await loadFrame(f.entry, payload, opts.view, opts.blob); } catch { failed++; }
if (info !== payload) return;
if (f.data && status !== "ready") { status = "ready"; resolvePivot(); }
dirty = true;
}
};
await Promise.all([worker(), worker(), worker(), worker()]);
if (info !== payload) return;
if (failed === frames.length) { info = null; frames = []; shown = null; status = "idle"; statusNote = "The last run's pictures are gone. Run it again to see your clip."; dirty = true; }
}
// ---------- the camera's view ----------
function fitPicture() {
const L = lens(), sw = stage.clientWidth, sh = stage.clientHeight;
if (!sw || !sh) return null;
const s = Math.min(sw / L.W, sh / L.H), w = L.W * s, h = L.H * s, x = (sw - w) / 2, y = (sh - h) / 2;
Object.assign(pic.style, { left: `${x}px`, top: `${y}px`, width: `${w}px`, height: `${h}px` });
inset.hidden = !shown || sw < 300 || sh < MIN_STAGE;
return { x, y, w, h };
}
function renderPreview(cam, fit) {
const f = shown;
if (!f) { pic.hidden = true; pivotMark.hidden = true; return; }
pic.hidden = false;
const { W, H, z, rgba, fx, fy, cx, cy } = f;
if (!buf || buf.W !== W || buf.H !== H) {
buf = { W, H, zbuf: new Float32Array(W * H), cbuf: new Uint32Array(W * H), ibuf: new Int32Array(W * H), fill: new Uint8Array(W * H), img: picCtx.createImageData(W, H) };
pic.width = W; pic.height = H;
buf.img = picCtx.createImageData(W, H);
}
const { zbuf, cbuf, ibuf, fill, img } = buf;
const { R, C } = pose(cam, pivot);
const [r0, r1, r2, r3, r4, r5, r6, r7, r8] = R, [Cx, Cy, Cz] = C;
zbuf.fill(Infinity); ibuf.fill(-1);
for (let y = 0, i = 0; y < H; y++) for (let x = 0; x < W; x++, i++) {
const zz = z[i], c = rgba[i];
for (let s = 0; s < 4; s++) {
const X = (x + 0.5 + SUB[s][0] - cx) / fx * zz - Cx, Y = (y + 0.5 + SUB[s][1] - cy) / fy * zz - Cy, Z = zz - Cz;
const zc = r2 * X + r5 * Y + r8 * Z;
if (zc <= 1e-3) continue;
const u = fx * (r0 * X + r3 * Y + r6 * Z) / zc + cx, v = fy * (r1 * X + r4 * Y + r7 * Z) / zc + cy;
if (u < 0 || v < 0 || u >= W || v >= H) continue;
const idx = (v | 0) * W + (u | 0);
if (zc < zbuf[idx]) { zbuf[idx] = zc; cbuf[idx] = c; ibuf[idx] = i; }
}
}
for (let i = 0; i < W * H; i++) fill[i] = zbuf[i] < Infinity ? 1 : 0;
// fill one-pixel cracks: a gap mostly surrounded by picture takes their average (two passes, like the node)
for (let pass = 0; pass < 2; pass++) {
const was = fill.slice(), src = cbuf.slice();
let any = false;
for (let y = 1; y < H - 1; y++) for (let x = 1; x < W - 1; x++) {
const i = y * W + x;
if (was[i]) continue;
let n = 0, r = 0, g = 0, b = 0;
for (let oy = -1; oy <= 1; oy++) for (let ox = -1; ox <= 1; ox++) {
const q = i + oy * W + ox;
if (!was[q]) continue;
const c = src[q]; n++; r += c & 255; g += (c >>> 8) & 255; b += (c >>> 16) & 255;
}
if (n >= 5) { cbuf[i] = 0xFF000000 | ((b / n) << 16) | ((g / n) << 8) | (r / n); fill[i] = 1; any = true; }
}
if (!any) break;
}
const o = new Uint32Array(img.data.buffer);
let holes = 0;
for (let i = 0; i < W * H; i++) { if (fill[i]) o[i] = cbuf[i]; else { o[i] = MAG; holes++; } }
picCtx.putImageData(img, 0, 0);
pinkPct = 100 * holes / (W * H);
const q = toCam(R, C, pivot);
if (fit && q[2] > 1e-3) {
pivotMark.hidden = false;
pivotMark.style.left = `${fit.x + fit.w * (fx * q[0] / q[2] + cx) / W}px`;
pivotMark.style.top = `${fit.y + fit.h * (fy * q[1] / q[2] + cy) / H}px`;
} else pivotMark.hidden = true;
}
// ---------- the corner map: your scene from the side, and the camera in it ----------
function viewer() {
const P = pivot, d = Math.hypot(P[0], P[1], P[2]), T = [P[0] * 0.55, P[1] * 0.55, P[2] * 0.55];
const R = rot(VIEW.az, VIEW.el), b = mv(R, [0, 0, -d * 2.3]);
return { R, C: [T[0] + b[0], T[1] + b[1], T[2] + b[2]], f: 0.95 * out.width };
}
function proj(V, P) {
const q = toCam(V.R, V.C, P);
return q[2] <= 1e-3 ? null : [V.f * q[0] / q[2] + out.width / 2, V.f * q[1] / q[2] + out.height / 2, q[2]];
}
function frustum(V, cam, color, width) {
const L = lens(), { R, C } = pose(cam, pivot), s = Math.hypot(...pivot) * 0.3;
const corners = [[0, 0], [L.W, 0], [L.W, L.H], [0, L.H]].map(([x, y]) => {
const l = mv(R, [(x - L.cx) / L.fx * s, (y - L.cy) / L.fy * s, s]);
return proj(V, [C[0] + l[0], C[1] + l[1], C[2] + l[2]]);
});
const a = proj(V, C);
if (!a || corners.some((c) => !c)) return;
outCtx.strokeStyle = color; outCtx.lineWidth = width; outCtx.beginPath();
for (const c of corners) { outCtx.moveTo(a[0], a[1]); outCtx.lineTo(c[0], c[1]); }
outCtx.moveTo(corners[0][0], corners[0][1]);
for (const c of [...corners.slice(1), corners[0]]) outCtx.lineTo(c[0], c[1]);
outCtx.stroke();
// a small roof on the top edge, so up and down never mix
outCtx.fillStyle = color; outCtx.beginPath(); outCtx.moveTo(corners[0][0], corners[0][1]); outCtx.lineTo(corners[1][0], corners[1][1]);
const up = mv(R, [0, -L.H / 2 / L.fy * s * 1.35, s]), ut = proj(V, [C[0] + up[0], C[1] + up[1], C[2] + up[2]]);
if (ut) { outCtx.lineTo(ut[0], ut[1]); outCtx.closePath(); outCtx.globalAlpha = 0.6; outCtx.fill(); outCtx.globalAlpha = 1; }
}
function renderOutside(cam) {
if (inset.hidden) return;
const w = out.width, h = out.height, V = viewer(), o = new Uint32Array(outImg.data.buffer);
o.fill(0xFF100D0C); outZ.fill(Infinity);
const f = shown;
if (f) {
const { W, H, z, valid, rgba, zcap, fx, fy, cx, cy } = f, [r0, r1, r2, r3, r4, r5, r6, r7, r8] = V.R, [Cx, Cy, Cz] = V.C;
for (let y = 0; y < H; y += 2) for (let x = 0; x < W; x += 2) {
const i = y * W + x, zz = z[i];
if (!valid[i] || zz > zcap) continue;
const dx = (x + 0.5 - cx) / fx * zz - Cx, dy = (y + 0.5 - cy) / fy * zz - Cy, dz = zz - Cz, zc = r2 * dx + r5 * dy + r8 * dz;
if (zc <= 1e-3) continue;
const u = V.f * (r0 * dx + r3 * dy + r6 * dz) / zc + w / 2, v = V.f * (r1 * dx + r4 * dy + r7 * dz) / zc + h / 2;
const sz = clamp(Math.round(V.f * (zz / fx * 2.2) / zc), 1, 4), x0 = (u - sz / 2) | 0, y0 = (v - sz / 2) | 0;
for (let yy = y0; yy < y0 + sz; yy++) {
if (yy < 0 || yy >= h) continue;
for (let xx = x0; xx < x0 + sz; xx++) { if (xx < 0 || xx >= w) continue; const q = yy * w + xx; if (zc < outZ[q]) { outZ[q] = zc; o[q] = rgba[i]; } }
}
}
}
outCtx.putImageData(outImg, 0, 0);
frustum(V, { ...K0 }, "rgba(200,210,210,.55)", 1.5);
const project = (c) => proj(V, pose(c, pivot).C);
outCtx.strokeStyle = "rgba(79,179,169,.9)"; outCtx.lineWidth = 2; outCtx.setLineDash([5, 4]); outCtx.beginPath();
let first = true;
for (let i = 0; i <= 48; i++) {
const q = project(camAt(keys, i / 48));
if (!q) { first = true; continue; }
if (first) outCtx.moveTo(q[0], q[1]); else outCtx.lineTo(q[0], q[1]);
first = false;
}
outCtx.stroke(); outCtx.setLineDash([]);
for (const k of keys) {
const q = project(k);
if (!q) continue;
outCtx.save(); outCtx.translate(q[0], q[1]); outCtx.rotate(Math.PI / 4);
outCtx.fillStyle = Math.abs(k.t - p) < SAME_KEY ? "#bfe9e4" : "#4fb3a9"; outCtx.strokeStyle = "#0c0f10"; outCtx.lineWidth = 1.5;
outCtx.fillRect(-5, -5, 10, 10); outCtx.strokeRect(-5, -5, 10, 10); outCtx.restore();
}
const pv = proj(V, pivot), cc = project(cam);
if (pv && cc) { outCtx.strokeStyle = "rgba(79,179,169,.45)"; outCtx.lineWidth = 1; outCtx.beginPath(); outCtx.moveTo(cc[0], cc[1]); outCtx.lineTo(pv[0], pv[1]); outCtx.stroke(); }
frustum(V, cam, "#4fb3a9", 2.5);
}
// ---------- the axis ball ----------
function drawGizmo(cam) {
const g = gctx, c = GZ / 2, L = 21, R = rot(cam.az, cam.el);
g.setTransform(2, 0, 0, 2, 0, 0); g.clearRect(0, 0, GZ, GZ);
g.fillStyle = gHot ? "rgba(146,162,161,.26)" : "rgba(12,15,16,.42)"; g.beginPath(); g.arc(c, c, c - 1, 0, 7); g.fill();
gBalls = [];
for (const a of AXES) for (const sg of [1, -1]) {
const w = [a.v[0] * sg, a.v[1] * sg, a.v[2] * sg]; // the direction as the camera sees it
const q = [R[0] * w[0] + R[3] * w[1] + R[6] * w[2], R[1] * w[0] + R[4] * w[1] + R[7] * w[2], R[2] * w[0] + R[5] * w[1] + R[8] * w[2]];
gBalls.push({ a, sg, x: c + q[0] * L, y: c + q[1] * L, z: q[2], view: sg > 0 ? a.pos : a.neg });
}
gBalls.sort((m, n) => n.z - m.z); // far balls first, near ones on top
for (const b of gBalls) {
if (b.sg > 0) { g.strokeStyle = b.a.col; g.lineWidth = 2; g.beginPath(); g.moveTo(c, c); g.lineTo(b.x, b.y); g.stroke(); }
g.beginPath(); g.arc(b.x, b.y, b.sg > 0 ? 7 : 6, 0, 7);
if (b.sg > 0) {
g.fillStyle = b.a.col; g.fill();
g.fillStyle = "#0c0f10"; g.font = "bold 9px system-ui, sans-serif"; g.textAlign = "center"; g.textBaseline = "middle"; g.fillText(b.a.n, b.x, b.y + 0.5);
} else { g.globalAlpha = 0.35; g.fillStyle = b.a.col; g.fill(); g.globalAlpha = 1; g.strokeStyle = b.a.col; g.lineWidth = 1.2; g.stroke(); }
}
}
// ---------- gestures ----------
const picRect = () => pic.getBoundingClientRect();
function panBy(cam, dx, dy) { // the spot you circle stays under your finger
const L = lens(), r = picRect(), d = Math.hypot(...pivot), zz = Math.max(0.05 * d, pivot[2] + cam.back * d);
const du = dx * L.W / (r.width || 1), dv = dy * L.H / (r.height || 1);
return { px: (cam.px || 0) - du * zz / (L.fx * d), py: (cam.py || 0) - dv * zz / (L.fy * d) };
}
function dragTo(kind, cam, dx, dy) {
const r = picRect(), w = r.width || stage.clientWidth || 1, h = r.height || stage.clientHeight || 1;
if (kind === "orbit") setCam({ ...cam, az: cam.az - dx * 90 / w, el: cam.el + dy * 90 / w }); // the picture follows your finger
else if (kind === "pan") setCam({ ...cam, ...panBy(cam, dx, dy) });
else if (kind === "zoom") setCam({ ...cam, back: cam.back + dy / h * 2.4 }); // up = in, down = back
}
const kindFor = (e) => (e.shiftKey ? "pan" : (e.ctrlKey || e.metaKey) ? "zoom" : "orbit");
(() => {
const pts = new Map();
let g = null;
const begin = (kind, carry) => {
const v = [...pts.values()], cam = camAt(keys, p);
if (v.length >= 2) {
const [a, b] = v;
g = { kind: "pinch", cam, d: Math.max(8, Math.hypot(a[0] - b[0], a[1] - b[1])), mx: (a[0] + b[0]) / 2, my: (a[1] + b[1]) / 2, moved: true, multi: true };
} else if (v.length === 1) g = { kind, cam, x: v[0][0], y: v[0][1], moved: Boolean(carry), multi: Boolean(carry) };
else g = null;
};
pic.addEventListener("mousedown", (e) => { if (e.button === 1) e.preventDefault(); }); // no auto-scroll on middle click
pic.addEventListener("pointerdown", (e) => {
if (e.button > 1) return;
e.preventDefault(); e.stopPropagation();
pic.setPointerCapture(e.pointerId); pts.set(e.pointerId, [e.clientX, e.clientY]); stopPlay();
begin(kindFor(e));
});
pic.addEventListener("pointermove", (e) => {
if (!pts.has(e.pointerId) || !g) return;
pts.set(e.pointerId, [e.clientX, e.clientY]);
if (g.kind === "pinch") {
const [a, b] = [...pts.values()], d = Math.max(8, Math.hypot(a[0] - b[0], a[1] - b[1]));
const c = { ...g.cam, back: g.cam.back - Math.log(d / g.d) * 0.9 };
Object.assign(c, panBy(g.cam, (a[0] + b[0]) / 2 - g.mx, (a[1] + b[1]) / 2 - g.my));
setCam(c);
return;
}
const dx = e.clientX - g.x, dy = e.clientY - g.y;
if (!g.moved && Math.hypot(dx, dy) < 5) return;
g.moved = true;
dragTo(g.kind, g.cam, dx, dy);
});
const up = (e) => {
if (!pts.has(e.pointerId)) return;
pts.delete(e.pointerId);
if (pts.size === 0) {
if (g && !g.moved && !g.multi && g.kind === "orbit" && e.type === "pointerup" && e.button === 0) pickPivot(e);
g = null;
return;
}
begin("orbit", true); // a finger left: carry on from here
};
pic.addEventListener("pointerup", up);
pic.addEventListener("pointercancel", up);
})();
function pickPivot(e) { // tap: circle round what you tapped
const f = shown, r = picRect();
if (!f || !buf || !r.width) return;
const x = ((e.clientX - r.left) / r.width * f.W) | 0, y = ((e.clientY - r.top) / r.height * f.H) | 0;
if (x < 0 || y < 0 || x >= f.W || y >= f.H) return;
let best = -1;
for (let rr = 0; rr <= 3 && best < 0; rr++) for (let oy = -rr; oy <= rr && best < 0; oy++) for (let ox = -rr; ox <= rr; ox++) {
const xx = x + ox, yy = y + oy;
if (xx < 0 || yy < 0 || xx >= f.W || yy >= f.H) continue;
const i = buf.ibuf[yy * f.W + xx];
if (i >= 0) { best = i; break; }
}
if (best < 0 || !f.valid[best]) return; // sky: nothing to circle
const spec = { u: ((best % f.W) + 0.5) / f.W, v: (Math.floor(best / f.W) + 0.5) / f.H, t: f.t };
const found = pivotFromPixel(f, spec.u, spec.v);
if (!found) return;
pivotSpec = spec; pivot = found;
writeSoon();
}
stage.addEventListener("wheel", (e) => {
e.preventDefault(); e.stopPropagation(); stopPlay();
const unit = e.deltaMode === 1 ? 33 : e.deltaMode === 2 ? 400 : 1, c = camAt(keys, p);
if (e.shiftKey) { const a = (e.deltaY || e.deltaX) * unit; setCam({ ...c, ...panBy(c, 0, -a * 0.4) }); return; } // Shift + wheel pans up and down
setCam({ ...c, back: c.back + e.deltaY * unit * (e.ctrlKey ? 0.01 : 0.0008) }); // wheel, and a trackpad pinch (Ctrl), zoom
}, { passive: false });
stage.addEventListener("gesturestart", (e) => e.preventDefault()); // Safari: never zoom the page instead
stage.addEventListener("pointerdown", (e) => { if (e.button === 1) { e.preventDefault(); e.stopPropagation(); } });
stage.addEventListener("pointerenter", () => { hovered = true; });
stage.addEventListener("pointerleave", () => { hovered = false; });
// Blender's numpad, while the pointer is over the picture.
const onKey = (e) => {
if (!hovered || !alive || e.altKey || e.target?.closest?.("input, textarea, select, [contenteditable]")) return;
const c = camAt(keys, p), w = picRect().width || 300, step = 15, pan = e.ctrlKey || e.metaKey;
const go = { Numpad4: [-1, 0], Numpad6: [1, 0], Numpad8: [0, 1], Numpad2: [0, -1] }[e.code];
if (go) setCam(pan ? { ...c, ...panBy(c, -go[0] * w * 0.1, go[1] * w * 0.1) } : { ...c, az: c.az + go[0] * step, el: c.el + go[1] * step });
else if (e.code === "NumpadAdd" || e.code === "NumpadSubtract") setCam({ ...c, back: c.back + (e.code === "NumpadAdd" ? -0.1 : 0.1) });
else if (e.code === "Numpad1" && !pan) setCam({ ...c, az: 0, el: 0 }); // front
else if (e.code === "Numpad3") setCam({ ...c, az: pan ? -90 : 90, el: 0 }); // right, Ctrl: left
else if (e.code === "Numpad7") setCam({ ...c, az: 0, el: pan ? LIM.el[0] : LIM.el[1] }); // top, Ctrl: bottom
else if (e.code === "Numpad0") setCam({ ...K0 }); // your own shot
else if (e.key === "Escape" && !help.hidden) { openHelp(false); }
else return;
e.preventDefault(); e.stopPropagation(); stopPlay();
};
window.addEventListener("keydown", onKey, true);
// Space plays and pauses, Home goes to the start, while the pointer is over the face.
// Held there, Space would otherwise start a canvas pan.
root.addEventListener("pointerenter", () => { overFace = true; });
root.addEventListener("pointerleave", () => { overFace = false; });
const onPlayKey = (e) => {
if (!overFace || !alive || e.altKey || e.ctrlKey || e.metaKey || e.repeat && e.code === "Space") return;
if (e.target?.closest?.("input, textarea, select, button, [contenteditable]")) return;
if (e.key === "Home") toStart();
else if (e.code === "Space") play.click();
else return;
e.preventDefault(); e.stopPropagation();
};
window.addEventListener("keydown", onPlayKey, true);
// the axis ball: drag turns, a tap on an axis looks from that side
(() => {
let g = null;
gizmo.addEventListener("pointerenter", () => { gHot = true; dirty = true; });
gizmo.addEventListener("pointerleave", () => { if (!g) { gHot = false; dirty = true; } });
gizmo.addEventListener("pointerdown", (e) => {
if (e.button > 1) return;
e.preventDefault(); e.stopPropagation();
gizmo.setPointerCapture(e.pointerId); stopPlay(); gHot = true; dirty = true;
g = { id: e.pointerId, x: e.clientX, y: e.clientY, cam: camAt(keys, p), moved: false };
});
gizmo.addEventListener("pointermove", (e) => {
if (!g || e.pointerId !== g.id) return;
const dx = e.clientX - g.x, dy = e.clientY - g.y;
if (!g.moved && Math.hypot(dx, dy) < 4) return;
g.moved = true;
dragTo("orbit", g.cam, dx * 1.5, dy * 1.5);
});
const end = (e) => {
if (!g || e.pointerId !== g.id) return;
if (!g.moved && e.type === "pointerup" && (e.pointerType === "mouse" || !tipEl || tipEl.hidden)) {
const r = gizmo.getBoundingClientRect(), x = (e.clientX - r.left) * GZ / r.width, y = (e.clientY - r.top) * GZ / r.height;
let hit = null;
for (const b of gBalls) if (Math.hypot(b.x - x, b.y - y) <= 9) hit = b;
if (hit?.view) setCam({ ...camAt(keys, p), ...hit.view });
else if (hit) flash("Nothing behind: the camera never saw the back.");
}
g = null;
if (e.pointerType !== "mouse") gHot = false;
dirty = true;
};
gizmo.addEventListener("pointerup", end);
gizmo.addEventListener("pointercancel", end);
})();
for (const kind of ["zoom", "pan", "shot"]) {
const b = el("button", kind, { html: NAV_ICONS[kind], "data-tip": NAV_TIPS[kind], "aria-label": NAV_TIPS[kind] });
nav.append(b);
if (kind === "shot") { b.onclick = (e) => { e.stopPropagation(); stopPlay(); setCam({ ...K0 }); }; continue; }
let g = null;
b.addEventListener("pointerdown", (e) => {
if (e.button > 1) return;
e.preventDefault(); e.stopPropagation();
b.setPointerCapture(e.pointerId); stopPlay(); b.classList.add("on");
g = { id: e.pointerId, x: e.clientX, y: e.clientY, cam: camAt(keys, p) };
});
b.addEventListener("pointermove", (e) => {
if (!g || e.pointerId !== g.id) return;
const m = kind === "pan" ? 1 : 1.5;
dragTo(kind, g.cam, (e.clientX - g.x) * m, (e.clientY - g.y) * m);
});
const end = (e) => { if (g && e.pointerId === g.id) { g = null; b.classList.remove("on"); } };
b.addEventListener("pointerup", end);
b.addEventListener("pointercancel", end);
}
function flash(text) { flashText = text; flashUntil = performance.now() + 2600; dirty = true; setTimeout(() => { dirty = true; }, 2700); }
// ---------- the ? : the whole how-to, out of the way until asked for ----------
function buildHelp() {
help.innerHTML = "";
const h = el("div", "acm-help-h", { text: "How to steer" });
const x = el("button", "acm-help-x", { text: "×", "aria-label": "Close" });
x.onclick = () => openHelp(false);
h.append(x);
const dl = el("dl");
const rows = [
["Picture", "Drag to turn the camera round. Pinch or scroll to pull back or push in; two fingers or Shift pan. Tap a spot to circle round it."],
["Top right", "Drag the axis ball to turn, or tap an axis to look from that side. Drag the magnifier to zoom and the hand to pan. The camera puts you back on your own shot."],
["Keys", "Click the time bar to put a key there, or drag the time bar and move the camera. ◆ adds or removes a key, ◀ ▶ jump between keys. Drag a key along the bar to change its time."],
["Play", "⏮ goes back to the start, ▶ plays, and the loop button plays it round and round."],
["Mouse", "Like Blender: middle-drag turns, Shift pans, Ctrl zooms, the wheel zooms. Numpad 4 6 8 2 turn, + − zoom, 1 3 7 front, right, top, 0 your own shot. Space plays, Home goes to the start."],
["Pink", "What the camera never saw. The CrossView-Warp LoRA paints it in."],
];
for (const [k, v] of rows) dl.append(el("dt", "", { text: k }), el("dd", "", { text: v }));
help.append(h, dl);
}
function openHelp(open) { if (open) buildHelp(); help.hidden = !open; helpBtn.setAttribute("aria-expanded", String(open)); }
helpBtn.addEventListener("pointerdown", (e) => e.stopPropagation());
helpBtn.onclick = (e) => { e.stopPropagation(); openHelp(help.hidden); };
const closeHelp = (e) => { if (!help.hidden && !help.contains(e.target) && !helpBtn.contains(e.target)) openHelp(false); };
document.addEventListener("pointerdown", closeHelp, true);
// ---------- the time bar ----------
let keyEls = [];
function drawTrack() {
for (const k of keyEls) k.remove();
keyEls = [];
for (const k of keys) {
const d = el("div", `acm-key${Math.abs(k.t - p) < SAME_KEY ? " on" : ""}`);
d.style.left = `${k.t * 100}%`;
inner.insertBefore(d, head);
keyEls.push(d);
}
head.style.left = `${p * 100}%`;
const count = info?.count ?? 0;
time.textContent = count ? `${Math.round(p * (count - 1)) + 1} / ${count}` : `${Math.round(p * 100)}%`;
// the diamond is filled while the time bar sits on a key, hollow when it does not
const on = keyAt(keys, p) >= 0;
toggleKey.classList.toggle("on", on);
const tipText = !on ? "Add a key here" : keys.length > 1 ? "Remove this key" : "The last key stays";
toggleKey.dataset.tip = tipText; toggleKey.setAttribute("aria-label", tipText);
toggleKey.disabled = on && keys.length < 2;
prevKey.disabled = !keys.some((k) => k.t < p - SAME_KEY);
nextKey.disabled = !keys.some((k) => k.t > p + SAME_KEY);
}
(() => {
let drag = null;
const tAt = (e) => { const r = inner.getBoundingClientRect(); return clamp((e.clientX - r.left) / (r.width || 1), 0, 1); };
const set = (e) => { let t = tAt(e); for (const k of keys) if (Math.abs(k.t - t) < 0.03) t = k.t; p = t; dirty = true; };
track.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
e.stopPropagation();
track.setPointerCapture(e.pointerId); stopPlay();
const r = inner.getBoundingClientRect();
let hit = null, best = 11; // press on a key to grab it; anywhere else moves the time bar
for (const k of keys) { const d = Math.abs(r.left + k.t * r.width - e.clientX); if (d < best) { best = d; hit = k; } }
drag = { key: hit, x: e.clientX, moved: false };
if (hit) { p = hit.t; dirty = true; } else set(e);
});
track.addEventListener("pointermove", (e) => {
if (!drag) return;
if (!drag.key) { if (Math.abs(e.clientX - drag.x) >= 4) drag.moved = true; set(e); return; }
if (!drag.moved && Math.abs(e.clientX - drag.x) < 4) return;
drag.moved = true; // a key slides between its neighbours and never jumps over one
const i = keys.indexOf(drag.key), lo = i > 0 ? keys[i - 1].t + 0.02 : 0, hi = i < keys.length - 1 ? keys[i + 1].t - 0.02 : 1;
drag.key.t = clamp(tAt(e), lo, hi); p = drag.key.t;
writeSoon();
});
// A click on an empty spot of the bar puts a key there; a drag only moves the time bar.
const end = (e) => {
if (drag && !drag.key && !drag.moved && e.type === "pointerup" && keyAt(keys, p) < 0) setCam(camAt(keys, p));
drag = null;
};
track.addEventListener("pointerup", end);
track.addEventListener("pointercancel", end);
})();
const playLabel = (on) => { play.textContent = on ? "❚❚" : "▶"; play.dataset.tip = on ? "Pause" : "Play"; play.setAttribute("aria-label", play.dataset.tip); };
function stopPlay() { if (playing) { playing = false; playLabel(false); } }
play.onclick = () => {
if (playing) { stopPlay(); return; }
if (p >= 0.999) p = 0;
playing = true; lastT = performance.now(); playLabel(true);
};
loop.onclick = () => {
looping = !looping;
loop.setAttribute("aria-pressed", String(looping));
loop.dataset.tip = looping ? "Looping: click to play once" : "Play on a loop";
loop.setAttribute("aria-label", loop.dataset.tip);
};
const toStart = () => { p = 0; lastT = performance.now(); dirty = true; }; // playing carries on from the start
toStartBtn.onclick = toStart;
toggleKey.onclick = () => { // add a key at the time bar, or remove the one it sits on
stopPlay();
const i = keyAt(keys, p);
if (i < 0) setCam(camAt(keys, p));
else if (keys.length > 1) { keys.splice(i, 1); writeSoon(); }
};
const jump = (dir) => {
stopPlay();
const k = dir < 0 ? [...keys].reverse().find((x) => x.t < p - SAME_KEY) : keys.find((x) => x.t > p + SAME_KEY);
if (k) { p = k.t; dirty = true; }
};
prevKey.onclick = () => jump(-1);
nextKey.onclick = () => jump(1);
reset.onclick = () => {
stopPlay();
keys.length = 0; keys.push({ t: 0, ...K0 });
pivotSpec = null; resolvePivot(); p = 0;
writeSoon();
};
for (const b of [play, prevKey, toggleKey, nextKey, reset]) b.addEventListener("pointerdown", (e) => e.stopPropagation());
// ---------- before there is a clip to look at ----------
function drawEmpty() {
const show = status !== "ready";
empty.hidden = !show;
if (!show) return;
const key = `${status}|${statusNote}`;
if (empty.dataset.key === key) return;
empty.dataset.key = key;
empty.innerHTML = "";
if (status === "running") {
empty.append(el("b", "", { text: "Reading the depth…" }), el("span", "", { text: "MoGe looks at every frame once. A long clip takes a while." }));
return;
}
if (status === "loading") { empty.append(el("b", "", { text: "Loading your clip…" })); return; }
const go = el("button", "acm-btn primary", { text: "Show my clip", "data-tip": "Runs this node and the depth before it, not the whole workflow" });
go.addEventListener("pointerdown", (e) => e.stopPropagation());
go.onclick = async (e) => {
e.stopPropagation();
status = "running"; statusNote = ""; dirty = true;
let ok = false;
try { ok = await opts.run(); } catch { ok = false; }
if (!ok && status === "running") { status = "idle"; statusNote = "It did not run. Pick a clip in Load Video, and check MoGe Inference is connected."; dirty = true; }
};
empty.append(el("b", "", { text: "Steer the camera on your clip" }), el("span", "", { text: statusNote || "Run the depth once to see your first frame here." }), go);
}
// ---------- loop ----------
function frame(now) {
if (!alive) return;
requestAnimationFrame(frame);
if (!root.isConnected) return;
readCamera();
if (playing) {
const dur = Math.max(1, (info?.count ?? 121) / FPS);
p += (now - lastT) / 1000 / dur; lastT = now; dirty = true;
if (p >= 1) { if (looping) p %= 1; else { p = 1; stopPlay(); } }
}
flushWrite();
if (!dirty) return;
if (root.offsetParent === null && !root.getClientRects().length) return;
dirty = false;
const cam = camAt(keys, p);
const want = nearestLoaded(p);
// a new run's frames take over as they load; until then the last ones stay up
if (want && want !== shown) shown = want;
if (!info) shown = null;
const fit = fitPicture();
drawEmpty();
renderPreview(cam, fit);
drawGizmo(cam);
renderOutside(cam);
drawTrack();
where.textContent = describe(cam);
pink.textContent = shown ? `pink ${pinkPct.toFixed(0)}%` : "";
const over = pastLoraRange(cam), fl = performance.now() < flashUntil;
const warnText = fl ? flashText : over ? "Past the LoRA's range: it learned up to 45° of turn, 30° up and 20° down." : "";
if (warn.textContent !== warnText) { warn.textContent = warnText; warn.dataset.tip = warnText; }
if (!warnText) delete warn.dataset.tip;
back.set(cam.back);
}
const resize = new ResizeObserver(() => { dirty = true; });
resize.observe(stage);
requestAnimationFrame(frame);
return {
root,
// What a run of the node sent back: the frames to look at.
takeScene(payload) {
if (!payload || !Array.isArray(payload.frames) || !payload.frames.length) return;
info = payload;
frames = payload.frames.map((entry) => ({ t: entry.t, entry, data: null }));
shown = null;
if (status !== "ready") status = "loading";
resolvePivot();
dirty = true;
loadAll(payload);
},
runFailed(note) {
if (status !== "running") return;
status = "idle"; statusNote = note || "It did not run. Pick a clip in Load Video, and check MoGe Inference is connected."; dirty = true;
},
running() { if (status !== "ready") { status = "running"; dirty = true; } },
refresh() { lastText = null; dirty = true; },
get hasScene() { return Boolean(info); },
// The least height the face needs: the picture's least, and the controls as they measure.
floor() { return MIN_STAGE + (controls.offsetHeight || CONTROLS_HEIGHT); },
destroy() {
alive = false;
resize.disconnect();
window.removeEventListener("keydown", onKey, true);
window.removeEventListener("keydown", onPlayKey, true);
document.removeEventListener("pointerdown", closeHelp, true);
hideTip();
},
};
}
-331
View File
@@ -1,331 +0,0 @@
// Camera Move: the shot on the node, and on the box (subgraph node) that holds it.
//
// The face itself is face.mjs. This file mounts it, keeps it pointed at
// wherever the camera keys are kept, runs the node alone when the face asks
// for the clip, and hands it what a run sent back.
//
// Where the keys are kept:
// - on the node, in its own `camera` setting;
// - once `camera` is brought up to a box (a subgraph input linked to it),
// in the box's own copy of the setting, one copy per box. The face then
// sits on the box too, and the node inside shows that box's keys.
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange, showToast } from "../shared/index.mjs";
import { executionIdOf, isSwitchedOff } from "../shared/graph_ids.mjs";
import { SUBGRAPH_INPUT_ID, boxesOf, hostBox, workflowNodes } from "../shared/lora_box.mjs";
import { WIDGET_FRAME, ensureNodeMinHeight, fillNodeHeight } from "../shared/panel_layout.mjs";
import { hideWidget } from "../shared/widget_visibility.mjs";
import { CONTROLS_HEIGHT, MIN_STAGE, MIN_WIDTH, mountCameraFace } from "./face.mjs";
import { mountBoxCard } from "./box_card.mjs";
import { CARD_WIDGET } from "../shared/widget_card.mjs";
const NODE_CLASS = "AUSBOSS_NODES_CameraMove";
const CAMERA = "camera";
const UI_KEY = "ausboss_camera_scene";
const FACE_WIDGET = "ausboss_camera_move";
const OPENING_WIDTH = 460;
const OPENING_ROOM = 90; // a new node opens this much taller than its floor, so a wide clip fills the picture
const MIN_HEIGHT = MIN_STAGE + CONTROLS_HEIGHT + WIDGET_FRAME;
const mounts = new Set();
const rootGraph = () => app.rootGraph ?? app.graph;
const isMove = (node) => node?.type === NODE_CLASS || node?.comfyClass === NODE_CLASS;
const same = (a, b) => a != null && b != null && String(a) === String(b);
const linkOf = (graph, id) => (id == null || !graph ? null : graph.getLink?.(id) ?? graph.links?.get?.(id) ?? graph.links?.[id] ?? null);
const nodeOf = (graph, id) => graph?.getNodeById?.(id) ?? (graph?.nodes ?? graph?._nodes ?? []).find((node) => same(node?.id, id)) ?? null;
const ownCamera = (node) => node?.widgets?.find((widget) => widget?.name === CAMERA) ?? null;
const boxCopy = (box, name) => (box?.widgets ?? []).find((widget) => widget?.name === name && typeof widget.value === "string") ?? null;
// The subgraph input that brings a node's camera in from its box, or null.
function cameraInputName(node) {
const graph = node?.graph;
const index = (node?.inputs ?? []).findIndex((input) => input?.name === CAMERA);
if (!graph || index < 0) return null;
const link = linkOf(graph, node.inputs[index].link);
if (!link || !same(link.origin_id, SUBGRAPH_INPUT_ID)) return null;
return graph.inputs?.[link.origin_slot]?.name ?? null;
}
// The first Camera Move inside a box whose camera comes up to the box: { name, inner }.
function boxCamera(box) {
const subgraph = box?.subgraph;
for (const input of subgraph?.inputs ?? []) {
for (const id of input?.linkIds ?? []) {
const link = linkOf(subgraph, id);
const inner = link && nodeOf(subgraph, link.target_id);
if (isMove(inner) && inner.inputs?.[link.target_slot]?.name === CAMERA) return { name: input.name, inner };
}
}
return null;
}
// Where a face reads and writes the keys, and which run of the node it shows.
function sourceOf(mount) {
const root = rootGraph();
if (mount.box) {
const widget = boxCopy(mount.box, mount.inputName);
if (widget) hideWidget(widget);
const at = executionIdOf(root, mount.box);
return { widget, id: at ? `${at}:${mount.inner.id}` : null, holder: mount.box };
}
const node = mount.inner;
const fed = cameraInputName(node);
if (fed) {
const box = hostBox(node.graph, boxesOf(node.graph, workflowNodes(root)), null);
const widget = box && boxCopy(box, fed);
const at = box && executionIdOf(root, box);
if (widget) return { widget, id: at ? `${at}:${node.id}` : executionIdOf(root, node), holder: box };
}
return { widget: ownCamera(node), id: executionIdOf(root, node), holder: node };
}
const viewRoute = (file) => `/view?${new URLSearchParams({ filename: file.filename, type: file.type || "temp", subfolder: file.subfolder || "" })}`;
const viewUrl = (file) => api.apiURL(viewRoute(file));
// The depth picture's exact bytes, through ComfyUI's own request helper.
const viewBlob = async (file) => {
const response = await api.fetchApi(viewRoute(file));
if (!response.ok) throw new Error(`depth ${response.status}`);
return response.blob();
};
function locatorOf(node) {
const graph = node?.graph;
if (!graph || node.id == null) return null;
const root = graph.rootGraph ?? graph;
return graph !== root && graph.id ? `${graph.id}:${node.id}` : String(node.id);
}
function restore(mount) {
if (mount.face.hasScene) return;
const scene = app.nodeOutputs?.[locatorOf(mount.inner)]?.[UI_KEY]?.[0];
if (scene) mount.face.takeScene(scene);
}
function mount(host, { box = null, inputName = null, inner = host } = {}) {
const entry = { host, box, inputName, inner, face: null, widget: null };
const face = mountCameraFace({
read: () => String(sourceOf(entry).widget?.value ?? ""),
write: (text) => {
const widget = sourceOf(entry).widget;
if (!widget) return;
widget.value = text;
app.canvas?.setDirty?.(true, false);
notifyAusbossChange();
},
view: viewUrl,
blob: viewBlob,
run: async () => {
const { id, holder } = sourceOf(entry);
if (!id || typeof app.queuePrompt !== "function") return false;
if (isSwitchedOff(rootGraph(), holder) || isSwitchedOff(rootGraph(), inner)) {
showToast({ severity: "warn", summary: "Camera Move", detail: "This node is switched off (bypassed or muted), so it cannot run." });
return false;
}
entry.askedAt = performance.now();
// At the front of the queue: the clip shows up once the job running now is done.
const queued = await app.queuePrompt(-1, 1, [id]);
// Refused: an empty loader, a missing link, or a check that stopped it (it says why in a note).
// Nothing was queued, so the face must not wait for a picture that never comes.
if (queued === false) return false;
return true;
},
});
entry.face = face;
const widget = host.addDOMWidget(box ? `${FACE_WIDGET}:${inputName}` : FACE_WIDGET, FACE_WIDGET, face.root, { serialize: false, hideOnZoom: false });
keepDomWidgetWidthAuto(widget);
// Never saved with the workflow: options.serialize only keeps it out of the prompt.
widget.serialize = false;
// The floor follows the controls as they measure on this screen (fonts differ between phones).
fillNodeHeight(widget, { minWidth: MIN_WIDTH, minHeight: () => face.floor() + WIDGET_FRAME, minNodeSize: [MIN_WIDTH, MIN_HEIGHT], exactMinWidth: true });
entry.widget = widget;
mounts.add(entry);
restore(entry);
holdFloor(host);
return entry;
}
// A node or box kept shorter than the face needs (a saved size, a font that
// runs taller on this screen) would cut the controls off at its bottom edge.
function holdFloor(host) {
requestAnimationFrame(() => requestAnimationFrame(() => {
if (ensureNodeMinHeight(host)) host.setDirtyCanvas?.(true, true);
}));
}
function unmount(entry) {
mounts.delete(entry);
entry.face.destroy();
try { entry.host.removeWidget?.(entry.widget); } catch { entry.widget?.onRemove?.(); }
}
// A new node or box opens with room for the picture; a saved size wins.
function openRoomy(host) {
const floor = Number(host.computeSize?.()?.[1]);
if (!Number.isFinite(floor) || host.size?.[1] > floor + 40) return;
host.setSize?.([Math.max(host.size?.[0] ?? 0, OPENING_WIDTH), floor + OPENING_ROOM]);
}
function build(node) {
if (node.__ausbossCameraMove) return node.__ausbossCameraMove;
hideWidget(ownCamera(node));
node.__ausbossCameraMove = mount(node);
return node.__ausbossCameraMove;
}
// The settings card and any LoRA rows sit on top; the shot comes last and
// takes the rest of the box, so its buttons stay on the bottom edge.
function faceLast(box) {
// A box makes `widgets` afresh on every read (its settings, then the panels
// added to it), so the panels are put in order in their own list: any other
// panel (a LoRA Loader's rows), the settings card, then the face. Nodes 2.0
// lays them out in the order they were made, which is the same.
const widgets = Array.isArray(box._extraWidgets) ? box._extraWidgets : box.widgets ?? [];
const order = [widgets.find((w) => w?.name === CARD_WIDGET), box.__ausbossCameraBox?.widget].filter(Boolean);
const want = [...widgets.filter((w) => !order.includes(w)), ...order];
if (want.every((w, i) => widgets[i] === w)) return false;
widgets.splice(0, widgets.length, ...want);
box.setDirtyCanvas?.(true, true);
return true;
}
// A box row reads its options from the widget inside. A node with a card
// (Image Resize) hides its own rows by setting options.hidden there, and
// Nodes 2.0 would then hide the box's row too. The box keeps every setting
// brought up to it in view, except the camera text the face stands in for.
const SHOWN = new WeakMap();
function shownOptions(options) {
if (!options || typeof options !== "object" || !options.hidden) return options;
let view = SHOWN.get(options);
if (!view) {
view = new Proxy(options, { get: (target, key) => (key === "hidden" ? false : Reflect.get(target, key)) });
SHOWN.set(options, view);
}
return view;
}
function showSettings(box, keep) {
for (const widget of box.widgets ?? []) {
if (widget === keep || widget.__ausbossCameraShown || !widget.options?.hidden) continue;
const own = Object.getOwnPropertyDescriptor(widget, "options");
if (!own?.get || !own.configurable || widget.__ausbossVisibilityStash) continue;
Object.defineProperty(widget, "options", { ...own, get() { return shownOptions(own.get.call(this)); } });
widget.__ausbossCameraShown = true;
}
}
// A box shows the face while the camera of a Camera Move inside comes up to it.
function syncBox(box) {
if (!box?.isSubgraphNode?.() || !box.graph) return;
const found = boxCamera(box);
const have = box.__ausbossCameraBox;
if (have && (!found || have.inner !== found.inner || have.inputName !== found.name || !boxCopy(box, found.name))) {
unmount(have);
box.__ausbossCameraBox = null;
box.setSize?.([box.size[0], box.computeSize?.()[1] ?? box.size[1]]);
}
if (box.__ausbossCameraBox) {
if (mountBoxCard(box, boxCopy(box, box.__ausbossCameraBox.inputName), faceLast)) holdFloor(box);
faceLast(box);
showSettings(box, boxCopy(box, box.__ausbossCameraBox.inputName));
return;
}
if (!found || !boxCopy(box, found.name)) return;
hideWidget(boxCopy(box, found.name));
// The card before the face: Nodes 2.0 lays a box's panels out in the order they were made.
mountBoxCard(box, boxCopy(box, found.name), faceLast);
box.__ausbossCameraBox = mount(box, { box, inputName: found.name, inner: found.inner });
faceLast(box);
showSettings(box, boxCopy(box, found.name));
if (!box.__ausbossCameraSized) openRoomy(box);
box.__ausbossCameraSized = true;
box.setDirtyCanvas?.(true, true);
}
function watchBox(box) {
if (!box?.isSubgraphNode?.() || box.__ausbossCameraWatch) return;
const abort = new AbortController();
box.__ausbossCameraWatch = abort;
const later = () => requestAnimationFrame(() => syncBox(box));
for (const type of ["widget-promoted", "widget-demoted", "input-added", "removing-input"]) {
box.subgraph?.events?.addEventListener?.(type, later, { signal: abort.signal });
}
chainCallback(box, "onConfigure", (info) => { if (info?.size) box.__ausbossCameraSized = true; later(); holdFloor(box); });
chainCallback(box, "onRemoved", () => {
abort.abort();
if (box.__ausbossCameraBox) unmount(box.__ausbossCameraBox);
box.__ausbossCameraBox = null;
});
later();
}
// What a run sent back reaches every face that shows that run of the node.
api.addEventListener("executed", ({ detail }) => {
try {
const scene = detail?.output?.[UI_KEY]?.[0];
if (!scene) return;
const ids = new Set([String(detail.node ?? ""), String(detail.display_node ?? "")]);
for (const entry of mounts) if (ids.has(String(sourceOf(entry).id))) entry.face.takeScene(scene);
} catch {
// Never in the way of a run.
}
});
const stopped = (note) => { for (const entry of mounts) entry.face.runFailed(note); };
api.addEventListener("execution_error", ({ detail }) => {
if (/OutOfMemory/i.test(String(detail?.exception_type ?? ""))) {
stopped("The graphics card is full. Close other jobs using it, then try again.");
return;
}
const why = String(detail?.exception_message ?? "").split("\n")[0].slice(0, 160);
stopped(why ? `The run stopped: ${why}` : null);
});
api.addEventListener("execution_interrupted", () => stopped("The run was stopped."));
// A run that ended without sending the clip (it never reached the node)
// would leave a face waiting for good. Once the queue is empty, it stops.
api.addEventListener("status", ({ detail }) => {
if (detail?.exec_info?.queue_remaining !== 0) return;
const now = performance.now();
for (const entry of mounts) if (now - (entry.askedAt ?? 0) > 1500) entry.face.runFailed();
});
app.registerExtension({
name: "ausboss.camera_move",
onNodeOutputsUpdated() {
for (const entry of mounts) restore(entry);
},
nodeCreated(node) {
if (node?.isSubgraphNode?.()) watchBox(node);
},
loadedGraphNode(node) {
if (node?.isSubgraphNode?.()) watchBox(node);
},
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== NODE_CLASS) return;
chainCallback(nodeType.prototype, "onNodeCreated", function () {
build(this);
});
chainCallback(nodeType.prototype, "onAdded", function () {
if (this.__ausbossCameraSized) return;
this.__ausbossCameraSized = true;
openRoomy(this);
});
chainCallback(nodeType.prototype, "onConfigure", function (info) {
if (info?.size) this.__ausbossCameraSized = true;
const entry = build(this);
hideWidget(ownCamera(this));
queueMicrotask(() => { entry.face.refresh(); restore(entry); });
holdFloor(this);
});
chainCallback(nodeType.prototype, "onConnectionsChange", function () {
this.__ausbossCameraMove?.face.refresh();
// The camera may have just come up to a box, or gone back inside.
for (const box of boxesOf(this.graph, workflowNodes(rootGraph()))) requestAnimationFrame(() => syncBox(box));
});
chainCallback(nodeType.prototype, "onRemoved", function () {
const entry = this.__ausbossCameraMove;
if (entry) unmount(entry);
this.__ausbossCameraMove = null;
});
},
});
+1 -1
View File
@@ -12,7 +12,7 @@
// title it loads into the node, dropped on the node's panel (the picture, the
// controls) it missed the node. The drop listeners here hand it to the node
// under the pointer.
import { app } from "../../../scripts/app.js";
import { app } from "/scripts/app.js";
import {
dragCarriesFiles, graphDragStarts, panelHost, panelKeepsWheel, panelNeedsDropHelp, panelRoot,
} from "../shared/canvas_passthrough.mjs";
+2 -2
View File
@@ -1,5 +1,5 @@
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
import { AUSBOSS_JS_VERSION, BRAND, BRAND_BODY, chainCallback } from "../shared/index.mjs";
import { nodeByExecutionId } from "../shared/graph_ids.mjs";
import { isOwnRun, ownRuns } from "../shared/prompt_scope.mjs";
+2 -2
View File
@@ -1,5 +1,5 @@
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { isForeignRun, outputKey } from "../shared/prompt_scope.mjs";
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
import { lookupLink } from "../shared/graph_links.mjs";
+1 -9
View File
@@ -7,17 +7,9 @@ to point at the part you are talking about.
Use it to explain a step in a workflow you share. It never runs and has no
wires.
## Tile presets
Right-click the Callout and choose **Title tile** to give your existing text a black heading with a teal accent. **Set YouTube link tile** and **Set Discord link tile** fill in the AusBoss link and label. Click the label to open the link, or drag the tile to move it. Choose **Plain callout** to return to the standard style with the current text.
- Press **Edit** on a tile to open a larger editor. A link tile has separate **Button text** and **Link address** fields. Existing mixed text stays in a text box.
- Double-click a title to edit it. **Save** or `Ctrl+Enter` keeps the change; **Cancel** or `Esc` leaves the original text.
- Drag the visible bottom-right grip to resize. You can also focus the grip and use arrow keys; hold **Shift** for smaller changes. Resizing keeps the tile in place and supports Undo.
## Writing
- On a plain callout, double-click the note, or press **Edit**, to type. Click outside the box to
- Double-click the note, or press **Edit**, to type. Click outside the box to
keep the text. **Esc** drops what you typed.
- A blank line starts a new point. Each point gets a thin teal line beside it.
- Put two stars around a word to stress it: `**Resize is on.**`
-7
View File
@@ -1,7 +0,0 @@
# Callout Tile
A compact title or colored link button for a workflow. Choose **Title tile**, **Set YouTube link tile**, or **Set Discord link tile** from its menu. Press **Edit** to change its text or link; you can also double-click a title. Drag its visible corner to resize.
It uses the same text, arrows and links as [Callout](AUSBOSS_NODES_Callout.md). **Plain callout** returns to the ordinary note face. Nothing runs and it has no outputs.
Tiles save with their own node type. If ComfyUI reports this node missing, install or update **ComfyUI-AusBoss**, restart ComfyUI, and reload the workflow. An older pack cannot draw the tile correctly. Keep the missing node so its text and settings are preserved.
-82
View File
@@ -1,82 +0,0 @@
# Camera Move
Moves the camera of your clip. You steer it on the node's picture, the way
you turn a model in a 3D viewer, and set keys on the time bar. What the old
camera never saw comes out pink, and the CrossView-Warp LoRA for MiniMax H3
paints it in.
## How to use it
1. Load your clip, scale it to the size you will render, and run it through
**Load MoGe Model** and **MoGe Inference** (core ComfyUI nodes).
2. Connect MoGe Inference's geometry to Camera Move.
3. Press **Show my clip** on the node. It runs the depth once and shows your
first frame.
4. Steer:
- Drag the picture to turn the camera round.
- Pinch or scroll to pull back or push in. Two fingers, or Shift-drag, pan.
- Tap a spot to circle round it.
- Top right: drag the axis ball to turn, or tap an axis to look from that
side. Drag the magnifier to zoom and the hand to pan. The camera button
puts you back on your own shot.
5. Keys: click the time bar to put a key there, using the camera at that
moment. Or drag the time bar, then move the camera, and a key appears
there. The diamond adds or removes a key; the arrows either side jump
between keys. Drag a key along the bar to change its time.
⏮ goes back to the start, ▶ plays, and the loop button plays it round
and round.
6. Run the workflow. `guide` goes to the H3 guide at frame 0.
The keys move the camera from where your clip's own camera was. If your
clip's camera already moves (walks down an aisle, for example), that move
stays, and your keys add to it. After the last key the camera holds that
turn, but it still travels along with your clip.
The line under the picture turns amber when a key goes past where the LoRA
was trained: 45° of turn, 30° up, 20° down. Past that it may leave pink.
### Mouse and keys (like Blender)
Middle-drag turns, Shift + middle-drag pans, Ctrl + middle-drag zooms, and
the wheel zooms. On the numpad: 4 6 8 2 turn, Ctrl with them pans, + and −
zoom, 1 is the front, 3 the right side (Ctrl: left), 7 the top (Ctrl:
bottom), 0 your own shot. Space plays and pauses and Home goes back to the
start, while the pointer is over the node.
The wheel over the picture zooms the camera, not the graph. Everywhere else
on the node it zooms the graph as usual.
## Inputs and outputs
| | |
|---|---|
| `moge_geometry` | Depth from MoGe Inference, one per frame. |
| `camera` | The keys, written by the node's picture. You never type here. |
| `guide` | Your clip from the moved camera, pink where nothing was seen. |
| `holes` | White where the guide is pink. |
## Technical details
- Each key holds a moment `t` (0 to 1 over the clip), a turn `az` and a rise
`el` in degrees, `back` (how far the camera pulls back, in distances to the
pivot; below 0 pushes in), and a pan `px`, `py` (same unit). The camera
passes through each key without slowing down, and only eases at the first
and last key. Each value follows a monotone cubic through the keys
(Fritsch-Butland slopes), so it never runs past a key: a value that turns
back or holds at a key stops there.
- The camera turns round the pivot: a spot you tap (saved as a place on one
frame, `pivot: {u, v, t}`) or, by default, the nearest fifth of what sits in
the middle of the first frame, never further than 3 m.
- `R = Ry(-az) · Rx(-el)`, `C = pivot − R·pivot + R·[px·d, py·d, −back·d]`
with `d = |pivot|`, in OpenCV axes (x right, y down, z forward).
- MoGe's depth scale and lens wobble from frame to frame, which shook the
moved picture. Every frame is scaled so its median depth follows a
smoothed line (15 frames wide) through all of them, and every frame uses
the clip's median lens.
- Each pixel is drawn as 4 points, nearest wins, and gaps mostly surrounded
by picture (5 of 8 neighbours, two passes) are filled. Everything else
stays magenta (1, 0, 1).
- The node is an output node so its picture can run it on its own. Each run
writes up to 61 small frames (416 px on the long side) with their depth to
ComfyUI's temp folder; the depth is a PNG with a log scale in red (high
byte) and green (low byte), 0 for no depth.
-1
View File
@@ -64,7 +64,6 @@ Pick **Erase** on the switch. A row of tools opens above the picture:
away. Restore paints it back. Holding **Ctrl**, or using the right
mouse button, does the other one for as long as you hold it.
- **Size** is the brush's width, in pixels of the picture.
- The dot beside **Size** previews one brush dab. It grows with Size and its edge fades as you increase Soft.
- **Soft** is the brush's edge: 0 is a hard edge, 100 fades all the way
from the middle.
- Drag left and right on **Size** or **Soft**, on the word, the number or
+4 -41
View File
@@ -89,42 +89,14 @@ Connect a video, then pick what it is for:
`@1 dances on a rooftop at sunset.` The clothes can come from words or from an
outfit picture.
- **swap the person**: your person takes over from the one in the video. The
place, the moves and the camera stay. With two person pictures, the first
replaces the person on the left and the second replaces the person on the
right. To choose who goes, type one line per picture, for example
`@1 replaces the man in the hat`. Put spoken words in quotes and name
the speaker. If a voice comes from off screen, say so in the line.
- **change something**: keep the video and describe the change, for example
`She wears a red raincoat`. Be specific about what should change.
place, the moves and the camera stay. You can leave the box empty.
- **keep going**: more of the same clip, from where it ends. Say what happens
next, or leave the box empty and it just carries on. Connect the whole clip:
the node sends H3 only the last 2 seconds.
- **new voice**: the same clip with a new voice. Connect the voice, and type
what they say in quotes.
If your swap keeps the video's audio, connect it to **video_sound** and turn on
**More → Clip's own sound**. The writer uses the clip to estimate when each
line is spoken and keeps only the incoming people's words in the swap prompt.
Type a time such as `3.2s:` to set it yourself. This switch starts off.
With an active video, **swap the person**, **change something** and **new voice**
use the incoming clip to set the frame count. **Length** says **From the clip**;
set the clip's length upstream. The bar does not show a guessed duration or offer
retiming controls for these jobs. H3 rounds the frame count to a supported length.
Other video jobs, or turning the video off, use the editable **Length** again.
For a swap, you can optionally describe the replacement on its own line:
```
the woman with black hair walking toward the camera
@1 looks like a woman with blonde hair and a black hoodie.
```
The first line chooses who goes; the `@1 looks like` line describes the connected
replacement person in Picture 1. Use the number of that picture, with its job set
to **person** or **character sheet**. This helps describe the intended appearance;
it does not guarantee an exact identity match. The line is optional: existing
workflows and scripts keep their previous behavior when it is absent.
Swap and new voice ignore **Length** and keep the video's own length.
## What you can type
@@ -153,10 +125,8 @@ they are.
## What you can plug in
- **picture_1** to **picture_4**: your pictures.
- **video** and **video_sound**: the frames and the sound of a video. **keep
going** sends the last part of the sound to H3. A swap uses the sound to find
speech timing when **Clip's own sound** is on. To keep source audio in a swap
or **change something**, route the original loader's audio to the save node.
- **video** and **video_sound**: the frames and the sound of a video. Only keep
going uses the sound.
- **voice**: a clip of someone talking, about 10 seconds, plus words in quotes
in your script. It goes to whoever speaks first. It works with your own voice,
or one you have permission to use. Put the spoken line last: with time left
@@ -180,12 +150,6 @@ H3's width and height: the clip takes the shape of your video, or else of your
first picture. **song_under** goes into `audio2` of Audio Merge, with the clip's
sound in `audio1`.
**keeps_clip_sound** is on when a video is connected and **Clip's own sound** is
on, or the video job is **change something**. Use it to select the source audio
for the finished clip. It is the last output; the existing outputs keep their
positions. Keeping source audio still needs the workflow to pass that audio to
its save node.
Nothing plugged in means nothing is added. So a page can turn each one on and off
with [Workflow Switches](#AUSBOSS_NODES_WorkflowSwitches): a bypassed loader reads
as not plugged in.
@@ -244,7 +208,6 @@ as not plugged in.
| `song_under` | output | AUDIO | With a voice and a song, the song to mix under the finished clip. |
| `frames` | output | INT | The clip's length in frames. |
| `width`, `height` | output | INT | The shape of your video, or else of your first picture. |
| `keeps_clip_sound` | output | BOOLEAN | Whether the finished clip should use the video's own sound. |
## Technical details
+32 -32
View File
@@ -10,7 +10,7 @@ original back over the result.
## Quick start
1. Choose a file above the preview, or click **Load**. Dropping a
1. Pick a picture under **Image source**, or click **Upload**. Dropping a
picture onto the node works too.
2. Drag the handles on the picture. Cyan squares crop. Orange diamonds add space
on one side. Orange corners make the whole canvas bigger or smaller. The
@@ -28,25 +28,22 @@ uses what the node shows.
From top to bottom:
- **File picker**: choose **Inputs** for uploaded pictures or **Outputs** for renders, newest first. The list shows the
picture under the pointer and filters as you type. **Load** adds one. A
- **Image source**: the pictures in ComfyUI's input folder. The list shows the
picture under the pointer and filters as you type. **Upload** adds one. A
picture ComfyUI made, named like `photo.png [output]`, loads too, as it does
in core Load Image. The gear holds the node's display options.
- **The picture**, with its handles.
- **The size line**: each step that sets the output size, ending with the size
the run gives you.
- **Crop, pad & resize**: a collapsible group (closed to start, with the fill
swatch and a short summary on its bar) holding:
- **Aspect ratio** and **Fit to ratio** (Crop or Pad), above the orientation button, the ratios (1:1, 4:3, 3:2, 16:9,
21:9) and the padlock.
- **Align** and the two **Center** buttons.
- **Fill** and **Feather**.
- **Resize**, with **Megapixels** and **Step** alongside it when Resize is on.
- **The ratio row**: the orientation button, the ratios (1:1, 4:3, 3:2, 16:9,
21:9) and the padlock.
- **Fit** (crop | pad) and **Divisible by**.
- **Fill**, **Feather**, **Resize** and the two **Center** buttons.
- **Open editor**, **Reset crop** and **Reset**.
Crop and padding have no number boxes. You set them by dragging, and the size
line shows the result. Picking another picture starts the framing over
(rotation, crop and padding); Fill, Feather, Align, Resize and a lit
(rotation, crop and padding); Fill, Feather, Divisible by, Resize and a lit
ratio stay.
### Ratios, Fit and the padlock
@@ -54,7 +51,8 @@ ratio stay.
Tap a ratio under the picture to pad the picture to it: every pixel stays and
fill bands are added around it, centred. Set **Fit** to crop and the ratio trims
the picture instead. Tap the lit ratio again to go back to the whole picture.
You can set **Fit to ratio** before picking a ratio. If a ratio is already lit, switching between Crop and Pad applies it immediately.
**Fit** only acts on a lit ratio, so it is dimmed and says "pick a ratio first"
until you tap one.
- **A lit ratio is the shape the canvas has now.** Drag a handle to another
shape and the ratio goes dark; the row says **Custom** and the size line under
@@ -87,11 +85,11 @@ sides at once, so the picture keeps its place in the middle. You can press or le
go of Alt during the drag. A corner stops when one of its two sides has no padding
left to take away.
**Center** moves the crop to the middle of the rotated source, side to side or
top to bottom. If there is padding on that axis, the same click also balances
the bands around the crop. The crop and output keep their sizes, and the
generated mask follows the new placement. Each button dims when its axis is
as centered as the pixels allow.
The two **Center** buttons next to Fill, Feather and Resize put the picture in
the middle of the canvas: one side to side, one top to bottom. They move padding from
one side to the other, so the canvas keeps its size and a lit ratio stays lit. A
button is dimmed when the picture is already in the middle or that way has no
padding to move. On a narrow node they sit on a line of their own.
The orange diamonds move one side at a time, and the padlock works on them as
described above. The corners keep the shape with or without the padlock. The
@@ -114,9 +112,9 @@ green rotate knob moves aside when a corner handle sits where it usually goes.
**Method** (`resize_method`) is in the editor, under **Resize output**.
The edge between your picture and the fill stays one clean line at any size.
### Align
### Divisible by
**Align** (`canvas_multiple`) makes the width and height divide evenly by
**Divisible by** (`canvas_multiple`) makes the width and height divide evenly by
the number you pick. Some models need sizes divisible by 8, 16 or 32. At 1 it is
off. Its arrows go 1, 8, 16, 24 and so on; hold Shift to step by 1.
@@ -132,23 +130,24 @@ or bottom.
The line under the picture names every step that sets the output size, in the
order the run applies them, with the size the run emits last and brightest:
`crop 2080×1170 → pad 2208×1298 → round to 64 2240×1344 → resize 1344×768`. A step
that changes nothing is left out, and "round to" is **Align** at work. An
that changes nothing is left out, and "round to" is **Divisible by** at work. An
amber line warns when rounding each side to the Step stretches the picture by
more than 1% (`Resize makes the picture 1.5% taller`); its tooltip names a
Step that avoids it, and with Fit on pad, **Pad to keep shape** adds a few pixels of
padding so nothing stretches. It also warns when the resize undoes **Align**. Hover the line for the same breakdown line by line; the editor's right
more than 1% (`1.5% taller: each side rounds to 32 px`); its tooltip names a
Step that avoids it, and with Fit on pad, **Even out** adds a few pixels of
padding so nothing stretches. It also warns when the resize undoes **Divisible
by**. Hover the line for the same breakdown line by line; the editor's right
panel shows it too.
The amber line keeps still while you drag a handle or a number. If the drag
makes it untrue it goes away, and the line for the new size comes back a
moment after you let go. The space for the warning stays under the dimensions. On a narrow node, hover
over shortened text to read it in full.
moment after you let go. With Resize on its row is always on the node, with
text or empty, so the controls under it stay where they are.
### Reset crop and Reset
**Reset crop** shows the whole picture again without changing rotation or
padding. **Reset** clears rotation, crop and padding and turns the padlock off;
Fill, Feather and **Align** stay.
Fill, Feather and **Divisible by** stay.
## Drawing a mask
@@ -168,7 +167,8 @@ your picture stays under them in **image**, **original** and
the teal.
See-through parts of a PNG show in teal the same way, because the node paints
them too. To hide the teal, click the gear next to **Load** and turn off **Show the mask on the picture**. The choice applies
them too. To hide the teal, click the gear at the top right of the image
source box and turn off **Show the mask on the picture**. The choice applies
to every Image Crop + Rotate + Pad in this browser.
## Outputs
@@ -232,18 +232,18 @@ for fine steps). The editor adds a few things the node does not show:
- **Rotate → Degrees** (`rotation_degrees`): type an exact turn, clockwise.
Each step is 1°, or 0.1° with Shift. **Reset rotation** sets it back to 0.
- **Padding & mask → Reset padding** removes all padding. The same section has
Fill, Feather, Align and a **Center** row.
Fill, Feather, Divisible by and a **Center** row.
- **Resize output → Method** (`resize_method`): how the resize samples the
picture. `lanczos`, the default, is the sharp one.
- **More**: ratios you added in `ausboss_presets.json` that no button shows.
The list only appears when you have some.
- **Reset view** undoes zoom and pan. **Reset transform**
clears rotation, crop, padding, fill, feather and Align; the picture,
clears rotation, crop, padding, fill, feather and Divisible by; the picture,
resize and stitch settings stay.
- **Preview**: a small picture of the finished output while you work.
A ratio replaces the existing crop and padding and keeps rotation, fill and
resize settings. **Align** and the resize **Step** can slightly change
resize settings. **Divisible by** and the resize **Step** can slightly change
the fitted shape. The size box on the stage sits clear of the handles.
### Handles and gestures
@@ -280,8 +280,8 @@ The editor's right side holds the stitcher's settings, the same as on the clip n
- `fill_color`: `#RGB` or `#RRGGBB`, three numbers (`R, G, B`), one gray
number, or a colour name. Anything it cannot read becomes mid-gray.
- **Corners.** A corner follows the pointer along the canvas diagonal, and the
shape it keeps is the canvas before **Align** rounds it. With
Align on, **Center** counts the fill it adds, so the bands you see
shape it keeps is the canvas before **Divisible by** rounds it. With
Divisible by on, **Center** counts the fill it adds, so the bands you see
come out even.
- **Resize.** It works like core's *Scale Image to Total Pixels*: the budget is
`megapixels × 1024 × 1024`, the shape is kept, and each side rounds to a
@@ -82,7 +82,7 @@ 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 thin strip counts.** More than 8 px of padding on the other
axis, such as a few pixels left over from a drag, means the source no
longer spans it. **Align** on the Crop + Rotate + Pad nodes and
longer spans it. **Divisible by** on the Crop + Rotate + Pad nodes and
Load Image + Pad's **Multiple** never add a strip on a side you did not
pad. The warning reports the spare pixels on each axis.
+5 -2
View File
@@ -35,6 +35,7 @@ all. **Remove this LoRA** is red. **Delete all LoRAs…** is a separate red, tra
with confirmation; it clears this node's rows and keeps all files on disk.
Cancel or Escape keeps the stack. Inserting keeps the existing rows and their settings in order;
the new slot uses the gear menu's default strength (initially 1).
The stack holds up to 64 rows.
One strength drives both
model and CLIP by default; the gear menu's **Separate model / CLIP
strength** switch shows a second box per row. The switch is per-node — new
@@ -188,7 +189,9 @@ their order, strengths and switches. The same filename in different folders
counts as separate LoRAs.
The choice is one undo step. Closing the picker without choosing changes
nothing. Typing a filename and pressing Enter picks one file; use the arrow
nothing. If a folder would take the stack past 64 rows, nothing is added
and a message says how much room is needed. Remove rows or pick a smaller
folder. Typing a filename and pressing Enter picks one file; use the arrow
keys to highlight a folder before Enter if you want the whole folder.
Pointing at a folder with the mouse does not choose it for Enter.
Left and Right collapse and expand a highlighted folder.
@@ -253,7 +256,7 @@ Details that keep the absorb faithful:
rows from a loader without a connected CLIP import with CLIP strength 0
- connected auxiliary outputs, such as trigger words, block absorption because
bypassing their source would break those connections
- linked LoRA settings leave the sources active
- linked LoRA settings and stacks beyond the row limit leave the sources active
- already-bypassed or muted loaders contribute no rows
- a toast summarizes what was imported, bypassed, remapped, and skipped
+2 -10
View File
@@ -120,8 +120,6 @@ or an area you painted.
## The tools on the node
- **Reload** (the round arrow) shows the picture coming in now: see
**Which picture the picks belong to**, below.
- **Undo** takes back the last change, or a Clear. Ctrl+Z works as well: it
is ComfyUI's own undo, and every change counts.
- **Clear** picks nothing.
@@ -154,8 +152,7 @@ or an area you painted.
- **More → Smooth** (`smooth`): rounds off a ragged outline by this many
pixels.
- **More → Fill small holes** (`fill_holes`): closes pinholes inside the
picked area. Larger openings stay open, like a mug's handle. Off by
default, so a hole you pick stays a hole.
picked area. Larger openings stay open, like a mug's handle.
- **More → Invert** (`invert`): turns the mask round. On, the mask is
everything except what you picked.
- **More → If nothing** (`if_nothing`): **stop the run** or **empty mask**,
@@ -212,12 +209,7 @@ nothing instead.
Picks belong to the picture they were made on. Load another picture and the
node shows it with nothing picked, and says the old picks are not used.
If the workflow runs before the node has shown the new picture, the run
stops and says the picks were made on another picture. The new picture
shows on the node then, ready to pick from.
- **The reload button** (the round arrow beside Undo) shows the picture
coming in now. Use it after you change the picture before this node, for
example with an edit. It runs only this node and what comes before it.
stops and says the picks were made on another picture.
- With a Load Image wired straight in, or through Reroute nodes, the
picture shows as soon as you choose a file.
-8
View File
@@ -8,14 +8,6 @@ Pick a picture size by shape and megapixels, or by dragging. Use it in place of
- **Gear:** set the **Snap step**, edit the **Ratio rail**, choose the **Latent family** (`latent`) and **Batch size** (`batch_size`), and hide the **Dot grid** or **MP cost curves**. **Snap step** (8, 16, 32 or 64; starts at 32) is the grid that drags, arrow keys and chips land on. The **Ratio rail** is the list of ratio chips, typed as `1:1, 21:9`. The display options apply to every Latent Size node in this browser; latent family and batch size (1 to 64) belong to this node.
- **Node size:** drag the node's corner to make it wider or taller, so it lines up with the nodes around it. The controls stretch with it, and the canvas preview takes any extra height. It can be as narrow as 320 pixels.
## Workflow resolutions
In the gear, choose **This workflow → Workflow resolutions → Edit list**. Enter one exact width × height per line, up to 24. The list belongs to this node and travels with the saved workflow. An empty list leaves the normal editor unchanged.
- **Recommendations:** leave **Limit choices to this list** off. The Sizes row shows your quick picks, and users can still type, drag or choose another shape.
- **Strict model sizes:** turn the limit on. The Shape row shows only the exact ratios in the list. Select a ratio to see its allowed sizes. For example, 576×1024 and 720×1280 share 9:16; 480×864 is a separate 5:9 option. Landscape is available only if you list a landscape size. The canvas stays visible, but it cannot be stretched. The swap button works only if the exact reverse size is listed.
- On enabling the limit, an unlinked size stays selected if allowed; otherwise the first listed size is selected. A linked width or height is checked at run time, before the latent is allocated. An unsupported pair produces an error naming the allowed sizes. For video workflows, connect these width and height outputs to the video latent node.
## Outputs and links
Outputs are **width**, **height**, and **latent**, in that order. The node has four input sockets above the panel: width, height, latent and batch_size. Linking width or height locks the controls that would change both dimensions; the other dimension remains editable. Linking latent or batch_size sets the latent family or batch size from another node, and the gear then shows that setting as linked. A linked value is read when the workflow runs, so the preview keeps showing the size stored on the node.
+3 -3
View File
@@ -41,9 +41,9 @@ file's name. A caption on the `caption_text` input writes the paired `.txt`.
workflow in EXIF. **JXL** (`jxl lossless`, lossless JPEG XL) 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** (`save_metadata`): **On** stores the prompt and
- **Embed workflow** (`save_metadata`): **yes** 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.
back into ComfyUI; **no** writes a clean file for sharing or datasets.
`--disable-metadata` on the server wins.
- **Preview** (`preview`): the small switch under the card shows the saved
image on the node. Off, the picture's box goes away and the node is
@@ -52,7 +52,7 @@ file's name. A caption on the `caption_text` input writes the paired `.txt`.
still lists it. When a run saves more than one image (a batch, or a list
such as a folder of images), arrows beside the switch (‹ 2 / 5 ›) step
through them.
- **Preview steps** (`step_preview`, off by default): the switch left of
- **Steps** (`step_preview`, off by default): the switch left of
**preview** shows the picture while it is being made, one step at a time,
where the saved image goes, with a **STOP** button on the bar. Stop a run
that is going wrong after the first steps instead of waiting for the end;
+4 -4
View File
@@ -9,9 +9,9 @@ duration, shows a tall video whole, and has a **LOOP** control. `gif` and
`webp` are shown as pictures with just the file name, since a video player
cannot play either.
**Preview steps** above the player (`step_preview`, off by default) shows the clip's first frame in
**STEPS** (`step_preview`, off by default) shows the clip's first frame in
the player while it is being made, one step at a time, with a **STOP**
button on the player. Stop a clip that is going wrong after the first steps
button beside it. Stop a clip that is going wrong after the first steps
instead of waiting for the end; a stopped run saves nothing. The saved
video replaces the steps. The [Step Preview](AUSBOSS_NODES_StepPreview.md)
page has the details.
@@ -37,7 +37,7 @@ page has the details.
- **Ping-pong** (`pingpong`): Plays the clip forward then backward so it loops seamlessly.
Roughly doubles the frame count and the encode time. The first and last
frames are not repeated at the turnaround, so the loop does not hitch.
- **Embed workflow** (`save_metadata`): On by default. Turn it off to share a file
- **Metadata** (`save_metadata`): On by default. Turn it off to share a file
without your prompt and node graph embedded in it. If ComfyUI was started
with `--disable-metadata`, nothing is embedded whatever this says.
- **audio** (optional): A track to mux into the file, e.g. Load Video's
@@ -83,7 +83,7 @@ metadata, so an embedded workflow is not available for them.
The node's face follows the table: widgets the chosen format ignores are
hidden rather than left to look meaningful. The CRF field leaves the
**FPS · CRF** row for `mov prores`, `mkv ffv1` and `gif` (their rows read
*ignored* above), and the **Embed workflow** row disappears for `gif` and `webp`,
*ignored* above), and the **Metadata** row disappears for `gif` and `webp`,
which cannot carry it. Hidden is not removed:
the widget keeps its position and its value, saved workflows store and restore
it exactly as before, and switching the format back brings it back with the
+9 -10
View File
@@ -11,22 +11,21 @@ generated-area `mask`, a `stitcher` for Stitch Inpaint, the selected
## Source and frame
- **Inputs**: Pick an input video (the list plays the clip under the pointer, muted, and filters as you type), click **Load** in the compact source card, or
- **Uploads**: Pick an input video (the list plays the clip under the pointer, muted, and filters as you type), click **Upload** in the compact source card, or
drop a video file onto the node. Picking another video keeps Fill, Feather,
**Align**, the Resize settings and the lit ratio button (the new frame is
**Divisible by**, the Resize settings and the lit ratio button (the new frame is
fitted to it the way **Fit** is set); rotation, crop, padding and the playhead
start over.
- **Load** streams to the input folder, so videos larger than the buffered image-upload limit can be selected here or dropped onto the node.
- **Inputs** / **Outputs** (`source_mode`: `input folder` / `local path`): the source card's switch. Inputs lists ComfyUI's input folder. Outputs lists the videos in ComfyUI's output folder, newest first, so a video you just rendered can be picked without uploading it again. Both lists preview the video under the pointer.
- **Canvas row** under the Fit row: fill swatch, feather amount and the Resize off | on switch on the node face; turning Resize on opens the megapixel budget and its Step. The line under the picture names each step that sets the output size, as on the image node.
- **Upload** streams to the input folder, so videos larger than the buffered image-upload limit can be selected here or dropped onto the node.
- **Uploads** / **Outputs** (`source_mode`: `input folder` / `local path`): the source card's switch. Uploads lists ComfyUI's input folder. Outputs lists the videos in ComfyUI's output folder, newest first, so a video you just rendered can be picked without uploading it again. Both lists preview the video under the pointer.
- **local_path**: The video picked in the Outputs list, named the way ComfyUI names a file
in its output folder: `clip.mp4 [output]`, `video/take 3.mov [output]`. It is read in
place, without an upload copy. A whole path such as `/home/me/ComfyUI/output/clip.mp4`
is not read, for queued runs and the editor's live preview alike.
- **Play / Pause** beside the scrubber previews the video. Pause on the frame you want to output.
- **Playhead**: The rail directly under the node's preview is the timeline: press or drag it to
- **Playhead**: The rail under the node's preview is the timeline: press or drag it to
scrub, or type a frame in the **FRAME** box; the frame on the stage is the frame the
node outputs (`frame_index`, with `frame_time` kept in step).
- **Crop, pad & resize** (collapsible group under the timeline): folds the ratio buttons, **Fit to ratio**, **Align** and **Center**, **Fill** and **Feather**, and the **Resize** off | on switch, megapixel budget and **Step** into one drawer, showing the fill swatch and a live summary on its header when closed. The line under the picture names each step that sets the output size, as on the image node.
- **seek_mode / frame_time**: Time-based seeking for graphs that drive the position by
link; the editor itself always works in frames.
@@ -34,19 +33,19 @@ The editor has the same rail full width, plus first/last, ±1, ±25, ±50, ±100
## Transform and outputs
Rotation, crop, padding, feathering, fill, **Align**, the megapixel resize, gestures, `image`, and `mask` match the image node. The output batch contains one frame.
Rotation, crop, padding, feathering, fill, **Divisible by**, the megapixel resize, gestures, `image`, and `mask` match the image node. The output batch contains one frame.
The handles work on both the node and the editor. Drag an orange corner to make
the canvas bigger or smaller in its own shape (hold **Alt**, Option on a Mac, to
change all four sides at once), and press **Center** next to Fill and Feather to
center the crop and balance any padding, side to side or top to bottom. Both work as on the
put the frame in the middle, side to side or top to bottom. Both work as on the
image node. The ratio buttons, **Fit**,
the padlock and the orientation button work as on the image node: a tap pads (or,
with Fit on crop, crops) the frame to a ratio, a lit ratio is the shape the canvas
has now, and the padlock keeps that shape while you drag (the row says **Held**).
**Reset crop** restores the full crop while keeping rotation and padding. **Reset**
clears rotation, crop and padding and turns the padlock off; fill, feather,
**Align** and the playhead stay. **Align** makes the width and
**Divisible by** and the playhead stay. **Divisible by** makes the width and
height divide evenly by the number you pick: padding grows by a few pixels, and
a side with no padding is trimmed by a few instead. Some models need sizes
divisible by 8, 16 or 32; 1 turns it off.
+27 -29
View File
@@ -11,20 +11,33 @@ model that paints black regions.
## Source and window
- **Inputs**: Pick an existing input video (the list plays the clip under the pointer, muted, and filters as you type), click **Load**, or drop a video file
- **Uploads**: Pick an existing input video (the list plays the clip under the pointer, muted, and filters as you type), 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. Choosing another video keeps the canvas
settings - Fill, Feather, **Align**, Resize, Every nth, Frames for, Length - and
settings - Fill, Feather, **Divisible by**, Resize, Every nth, Frames for, Length - and
fits the new clip to the lit ratio button, the way **Fit** is set. Rotation, crop,
padding, IN/OUT and the playhead start over.
- **Canvas row** (under the Fit row): the fill swatch, the feather amount and
the Resize off | on switch, right on the node; turning Resize on opens the megapixel
budget and its **Step**. These are what a video outpaint model keys on - the LTX IC-LoRA paints
**pure black** at sizes rounded to 32 - so a wrong value shows here before a
render is wasted. Feather only softens the mask and the stitch; the frames always
meet the fill with a hard edge. The line under the picture names each step that
sets the size (`576×1024 → pad 1821×1024 → resize 1280×704`) and warns in amber
when the Step stretches the picture by more than 1% (`2.2% wider: each side rounds
to 32 px`); the tooltip names a Step that avoids it, and in pad mode **Even out**
adds a few pixels of padding so nothing stretches. While you drag, that amber
line keeps still and comes back for the new size when you let go. The bands are drawn in the real
fill colour with a faint hatch, so black bands show on the black stage. A fresh clip node starts
with black and feather 0 and the editor's **Reset transform** returns to them;
the image nodes keep their grey fill and feathered mask.
- **Outputs** (`source_mode` / `local_path`): Pick a video from ComfyUI's output folder,
such as one you just rendered. The list shows the newest first and previews the video
under the pointer. The video is read in place, without an upload copy. In an API prompt
it is named the way ComfyUI names a file there: `clip.mp4 [output]`. A whole path such
as `/home/me/ComfyUI/output/clip.mp4` is not read, for queued runs and the editor's live
preview alike.
- **Play / Pause** beside the scrubber previews the selected frames and stops at OUT. Press Play at OUT to start from IN again.
- **Timeline** (right under the preview and size line): The rail on the node and in the editor is one timeline. Press or
- **Timeline**: The rail on the node and in the editor is one timeline. Press or
drag anywhere on it to scrub the **playhead**, with Length on or off; the stage shows
that frame. Drag the **IN** or **OUT** handle to trim: the playhead rides on the handle, so what you see
is the first (IN) or last (OUT) frame the run keeps, and it stays there when you let
@@ -40,21 +53,6 @@ model that paints black regions.
**Play**. Arrow keys step one frame (10 with Shift), Home and End jump to the
ends, and Space plays or pauses. **Save & close** keeps your edits; **Cancel** or
Escape puts everything back, and asks first if anything changed.
- **Crop, pad & resize** (collapsible group under the timeline): folds the ratio
buttons, **Fit to ratio**, **Align** and **Center**, **Fill** and **Feather**, and
the **Resize** switch, megapixel budget and **Step** into one drawer. While closed,
its header bar shows the fill colour swatch and a live summary of the active
ratio, feather, alignment and resize settings. These are what a video outpaint model keys on - the LTX IC-LoRA paints
**pure black** at sizes rounded to 32 - so a wrong value shows here before a
render is wasted. Feather only softens the mask and the stitch; the frames always
meet the fill with a hard edge. The line under the picture names each step that
sets the size (`576×1024 → pad 1821×1024 → resize 1280×704`) and warns in amber
when the Step stretches the picture by more than 1% (`Resize makes the picture 2.2% wider`); the tooltip names a Step that avoids it, and in pad mode **Pad to keep shape**
adds a few pixels of padding so nothing stretches. While you drag, that amber
line keeps still and comes back for the new size when you let go. The bands are drawn in the real
fill colour with a faint hatch, so black bands show on the black stage. A fresh clip node starts
with black and feather 0 and the editor's **Reset transform** returns to them;
the image nodes keep their grey fill and feathered mask.
- **Length** (`max_frames`): the one other way to set how long the clip is. **Off**
(the default): OUT ends the clip. **On**: a number of output frames from IN; OUT
sits that many frames after IN and follows it when you drag IN, and dragging OUT or
@@ -82,8 +80,8 @@ Crop squares, padding diamonds, the orange corners and the rotation handle also
work directly on the node preview. A corner makes the canvas bigger or smaller in
its own shape, for example to zoom out evenly before a video outpaint; hold **Alt**
(Option on a Mac) to change all four sides at once. **Center**, next to Fill and
Feather, centers the crop on the source and balances any padding on the selected axis.
The generated mask follows the crop and padding. The graph still owns wheel zoom and middle-button
Feather, puts the clip in the middle of the canvas side to side or top to bottom
without changing the canvas size. The graph still owns wheel zoom and middle-button
pan. Tap a ratio
to pad the clip to it (or crop it, with **Fit** on crop); tap the lit ratio again to
go back to the whole frame. A lit ratio is the shape the canvas has now: drag a
@@ -94,31 +92,31 @@ ratio or turn it"); otherwise it names the lit ratio where it acted (`pad to 16:
1821×1024`). The padlock keeps the shape while
you drag, with new bands split evenly, and the row says **Held**; Reset and tapping
the lit ratio turn it off, and it does nothing on the untouched clip. **Fit** is
ready to set before picking a ratio. The orientation button turns the shape on its side
dimmed until a ratio is lit. The orientation button turns the shape on its side
(16:9 to 9:16), and dragging the picture itself moves it inside its padding: the
cursor and small arrows show which ways it can go.
**Reset crop** restores the full crop without changing rotation, padding or trim;
with the padlock on it also takes away the bands the lock added.
**Reset** on the node clears rotation, crop and padding and turns the padlock off;
fill, feather, **Align** and the timeline stay.
fill, feather, **Divisible by** and the timeline stay.
**Reset transform** in the editor resets rotation, crop, padding, fill, feather and
**Align**; it keeps the source, current frame, trim, Length, resize and stitch
settings. **Align** makes the width and height divide evenly by the number
**Divisible by**; it keeps the source, current frame, trim, Length, resize and stitch
settings. **Divisible by** makes the width and height divide evenly by the number
you pick: padding grows by a few pixels, and a side with no padding is trimmed by
a few instead. Some models need sizes divisible by 8, 16 or 32; 1 turns it off.
The **Load** button and file drop use a streaming route into ComfyUI's input folder,
Video Upload and file drop use a streaming route into ComfyUI's input folder,
so the buffered image-upload size limit does not prevent long-video uploads.
The editor's sidebar uses the node's own ratio row, padlock and **Fit** switch, and
the rotation is a number box (Shift for 0.1°). A ratio replaces the old crop and
padding, keeping rotation and resize settings.
Pixel rounding can differ by one pixel between opposite bands. **Align** and
Pixel rounding can differ by one pixel between opposite bands. **Divisible by** and
the resize **Step** can slightly change the final aspect ratio.
## Transform and outputs
Rotation, crop, padding, feathering, fill, **Align**, and the editor's handles
Rotation, crop, padding, feathering, fill, **Divisible by**, 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. The frames are resized by themselves and the
@@ -163,7 +161,7 @@ Five optional sockets sit on the left, above the editor:
| `start_frame` | Zero-based source start frame. Overrides timeline IN. |
| `end_frame` | Exclusive source end frame. 0 means the end of the source. Overrides timeline OUT. |
| `fixed_frames` | Exact output frames from IN; the source must be long enough. Connected, it sets the length: OUT, Length and Frames for step aside. |
| `frame_load_cap` | Maximum frames after rate conversion and Every nth, before Frames for. 0 means unlimited. Connected, it sets the length in place of Length. When the count is known, dragging IN stops at the last start that fits the kept frames. |
| `frame_load_cap` | Maximum frames after rate conversion and Every nth, before Frames for. 0 means unlimited. Connected, it sets the length in place of Length. |
| `last_seconds` | Load only this many seconds from the end of the clip, or of the part before OUT. 0 loads all of it. Use it to carry on from a clip: a long clip then costs no more memory than a short one. |
Frame bounds use the source's frame-rate grid, independent of `force_rate`.
-23
View File
@@ -18,37 +18,14 @@ Nothing runs: the node has no outputs, so ComfyUI never executes it.
- **Title / Subtitle / Author** make the banner. **Layout → Banner** shows
only that top block (the title, plus the subtitle and author if you filled
them in), so the same node doubles as a label for a stage or column.
- **Layout → Info tile** puts a single button on the canvas. Click the tile to read the full note, including models and links. A book marks a guide and a download arrow marks a model list. Click its pencil to edit, or press **Edit** inside the opened note. Drag the visible bottom-right grip to resize the tile without moving it. Focus the grip and use arrow keys for keyboard resizing; **Shift** makes smaller changes.
- **Accent color** tints the banner bar, dots and buttons; empty keeps the
pack teal. It takes a six-digit hex such as `#00b4aa`.
- **How-to** is Markdown-lite: `#`/`##`/`###` headings, `**bold**`,
`*italic*`, `` `code` ``, `[links](https://…)`, `-` and `1.` lists, `---`
rules, fenced code. Only `http(s)` links become clickable.
## Formatting a how-to
Select a phrase in the How-to field and click **Teal**, **Green**, **Amber** or **Glow**. Click **Table** to insert an editable Markdown table. Open **Preview formatting** while you write. Use headings to break up steps and a pipe table for settings or file locations. Keep each row the same length. Use `\|` for a literal pipe inside a cell.
```markdown
## Choose a size
| Size | When to use it |
| --- | --- |
| 480 × 864 | [teal]First run[/teal] |
| 576 × 1024 | A larger portrait |
[green]Ready[/green] means the model was found.
[amber]Missing[/amber] means you still need the file.
```
For a short highlight, write `[glow]Preview the result[/glow]`. The glow is static. These four tags are AusBoss additions to Markdown. They style the enclosed plain text. Headings, lists, table cells and paragraphs can all contain them. Fenced code displays the original characters.
## Models
To give downloads their own button, choose **Layout → Info tile**, set the title to **Download models**, and add files under **Models**. To keep them with your instructions, add those same rows to the **How to use** note. Switching an existing card to an info tile keeps its model rows and links.
[Download the Krea 2 model tile example](https://raw.githubusercontent.com/ausboss/ComfyUI-AusBoss/main/js/docs/examples/model-download-tile.json) and open it as a workflow. Copy the tile into your graph and edit its rows.
Each row is a file, the model folder it belongs in, an optional size, a
download URL, and an optional note that shows in grey under the file name.
Rows group under a `📂 ComfyUI/models/<folder>` heading so the
@@ -1,7 +0,0 @@
# Workflow Note Tile
A compact help button that opens a full note. Click to read the instructions and model links; click the pencil to edit. Drag its visible corner to resize.
It uses the same note content as [Workflow Note](AUSBOSS_NODES_WorkflowNote.md). Choose **Card** or **Banner** in the editor to show the note directly on the canvas. Nothing runs and it has no outputs.
Tiles save with their own node type. If ComfyUI reports this node missing, install or update **ComfyUI-AusBoss**, restart ComfyUI, and reload the workflow. An older pack cannot draw the tile correctly. Keep the missing node so its instructions and settings are preserved.
+4 -5
View File
@@ -14,8 +14,8 @@ an upscale at the end.
2. Press **+ Switch from 2 selected** on the card.
3. Type a name and press Enter.
Click **off** to skip every node you picked, or **on** to run them again.
Clicking the selected half leaves it selected.
One click on its pill now turns every node you picked off, and the next click
turns them on again.
Groups work too. Every group on the canvas gets a switch of its own, unless
you change that under **Settings**. A group can also be part of a switch you
@@ -160,9 +160,8 @@ the models only that part needs.
## The edit view
- **off | on**: click **off** to turn off every node of the row, or **on** to
set them all to run. Clicking the selected half leaves it selected. A change
is one undo step.
- **off | on**: click anywhere on the pill to flip the switch. Off turns off
every node of the row, on sets them all to run. It is one undo step.
- **Mixed**: when only some of a row's nodes run (one bypassed by hand, say)
the pill is striped and a count shows how many run, such as **1/2**. Click
the half you want, off or on, to set them all. A settings switch reads **changed** when one of
-16
View File
@@ -31,7 +31,6 @@ help panel.
| Save pictures (PNG, lossless WebP or JPEG XL) | Save Image (`AUSBOSS_NODES_SaveImage`) |
| Run a workflow on each picture in a folder | Image Folder (`AUSBOSS_NODES_ImageFolder`) |
| Apply a stack of LoRAs | LoRA Loader (`AUSBOSS_NODES_LoraLoader`) |
| Write a MiniMax H3 or Pika H3 Flex prompt from plain words | H3 Prompt Writer (`AUSBOSS_NODES_H3PromptWriter`): [node help](../AUSBOSS_NODES_H3PromptWriter.md) |
| Krea 2 prompt, and Krea 2 outpainting | Krea 2 Encode (`AUSBOSS_NODES_Krea2Encode`), Krea 2 Outpaint Model Patch (`AUSBOSS_NODES_Krea2OutpaintModelPatch`) |
| Load or save a video | Load Video (`AUSBOSS_NODES_LoadVideo`), Save Video (`AUSBOSS_NODES_SaveVideo`) |
| Load a sound file and keep only part of it, up to a set length | Load Audio (`AUSBOSS_NODES_LoadAudio`) |
@@ -45,7 +44,6 @@ help panel.
| Time the whole run | Run Timer (`AUSBOSS_NODES_RunTimer`) |
| Turn parts of a workflow off and on | Workflow Switches (`AUSBOSS_NODES_WorkflowSwitches`): [its own agent guide](workflow_switches.md) |
| Explain a shared workflow on the canvas | Workflow Note (`AUSBOSS_NODES_WorkflowNote`), Callout (`AUSBOSS_NODES_Callout`) |
| Add compact help or colored link buttons | Workflow Note Tile (`AUSBOSS_NODES_WorkflowNoteTile`), Callout Tile (`AUSBOSS_NODES_CalloutTile`) |
| Lay out boxes of things and words on a canvas | Layout Boxes (`AUSBOSS_NODES_LayoutBoxes`) |
Every node's display name ends in 🆎 and sits under `🆎 AusBoss/<group>` in
@@ -65,20 +63,6 @@ the `AUSBOSS_NODES_...` key above.
- **Media loaders start blank** in the example workflows. Put in the file the
person wants to use before you queue.
## H3 swaps and clip duration
For a swap, keep the target selector on its own line. An optional separate line,
`@1 looks like a woman with blonde hair and a black hoodie.`, describes the
connected replacement person in Picture 1; use its actual picture number. This
can help express appearance but does not guarantee identity. Do not add it to
other jobs. Existing scripts without it keep their previous behavior.
An active video sets the duration for **swap the person**, **change something**
and **new voice**. The card says **From the clip** and hides numeric timeline
retiming; trim or limit the upstream clip instead. Do not infer the final frame
count from raw source metadata: upstream processing and H3 frame rounding apply.
Other jobs and video OFF still use **Length** normally.
## Example workflows
`example_workflows/` in this pack holds complete workflows, each with a
-51
View File
@@ -1,51 +0,0 @@
{
"id": "27945e9e-3e7b-49c5-bcb7-434d5de9e689",
"revision": 0,
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "AUSBOSS_NODES_WorkflowNoteTile",
"pos": [
0,
0
],
"size": [
300,
88
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"title": "Download models",
"properties": {
"Node name for S&R": "AUSBOSS_NODES_WorkflowNoteTile"
},
"widgets_values": [
"{\"v\": 1, \"title\": \"Download models\", \"subtitle\": \"Krea 2 Outpaint\", \"author\": \"\", \"layout\": \"tile\", \"accent\": \"\", \"body\": \"Get the four files below and put each one in its listed folder. Click **check files** after adding them.\", \"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\": \"\"}, {\"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\": \"by yijunwang2\"}], \"packs\": [], \"links\": []}"
],
"widgets_values_named": {
"note": "{\"v\": 1, \"title\": \"Download models\", \"subtitle\": \"Krea 2 Outpaint\", \"author\": \"\", \"layout\": \"tile\", \"accent\": \"\", \"body\": \"Get the four files below and put each one in its listed folder. Click **check files** after adding them.\", \"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\": \"\"}, {\"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\": \"by yijunwang2\"}], \"packs\": [], \"links\": []}"
},
"color": "#14424d",
"bgcolor": "#161f21"
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.9,
"offset": [
150,
115
]
},
"frontendVersion": "1.53.6"
},
"version": 0.4
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { app } from "../../../scripts/app.js";
import { app } from "/scripts/app.js";
import { showToast } from "../shared/index.mjs";
import { openTransformEditorForNode } from "../shared/transform_editor.mjs";
+1 -1
View File
@@ -6,7 +6,7 @@
// hand-over waits one microtask, so the frontend has rebuilt the whole graph
// before any panel moves.
import { app } from "../../../scripts/app.js";
import { app } from "/scripts/app.js";
import { chainCallback, isAusbossNode } from "../shared/index.mjs";
import { createFaceHandover } from "../shared/face_handover.mjs";
+1 -1
View File
@@ -33,7 +33,7 @@ function mountTransformPanel(node, panel) {
fillNodeHeight(widget, {
minWidth: PANEL_MIN_WIDTH,
minHeight: () => transformPanelFloor(node),
minNodeSize: [PANEL_MIN_WIDTH, 396],
minNodeSize: [PANEL_MIN_WIDTH, 470],
exactMinWidth: true,
});
return widget;
+2 -2
View File
@@ -16,8 +16,8 @@
// files to this site?"). A page can neither word that question nor skip it,
// so the node says what it means here while the file explorer is open.
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { BRAND, chainCallback, chainHandler, keepDomWidgetWidthAuto, notifyAusbossChange, showToast } from "../shared/index.mjs";
import { fillNodeHeight } from "../shared/panel_layout.mjs";
import { isForeignRun } from "../shared/prompt_scope.mjs";
+1 -1
View File
@@ -11,7 +11,7 @@
// last text. The labels themselves are never saved, so an older version of
// the pack shows the plain names.
import { app } from "../../../scripts/app.js";
import { app } from "/scripts/app.js";
import { isForeignRun } from "../shared/prompt_scope.mjs";
import { chainCallback } from "../shared/index.mjs";
import { savedLabel, sizeLabel, sizesFromExecuted } from "../shared/image_size.mjs";
+40 -16
View File
@@ -14,9 +14,8 @@
// switch lays each sampling step over the stage, with a Stop button on the
// bar (shared/step_preview.mjs).
import { panelSwitch } from "../shared/panel_controls.mjs";
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { isForeignRun } from "../shared/prompt_scope.mjs";
import { executionIdOf, nodeByExecutionId } from "../shared/graph_ids.mjs";
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
@@ -143,10 +142,16 @@ function ensureCss() {
.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:not(.preview-off) .ausboss-panel-switch.steps + .ausboss-panel-switch{margin-left:0;}
.ausboss-input-preview.preview-off .ausboss-panel-switch.steps{display:none;}
.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:not(.preview-off) .ausboss-input-preview-toggle.steps + .ausboss-input-preview-toggle{margin-left:0;}
.ausboss-input-preview.preview-off .ausboss-input-preview-toggle.steps{display:none;}
.ausboss-input-preview-tool.ausboss-steps-stop{visibility:hidden;}
.ausboss-input-preview-tool.ausboss-steps-stop.live{visibility:visible;pointer-events:auto;}
.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-readout{box-sizing:border-box;height:${BAR_HEIGHT}px;padding:0 7px;border:1px solid rgba(0,180,170,.5);border-radius:10px;background:rgba(0,180,170,.14);color:${BRAND};font:700 9.5px/18px "Segoe UI",sans-serif;white-space:nowrap;pointer-events:none;}
.ausboss-input-preview-readout:empty{display:none;}
.ausboss-input-preview-readout.warn{border-color:#c98a2b;background:rgba(201,138,43,.16);color:#ffd9a0;}
@@ -292,9 +297,18 @@ function previewEnabled(state) {
}
function makePreviewToggle(state, signal) {
const button = panelSwitch("Preview", state.switchTitle ?? "Show this node's result here. Off also skips writing the preview file to the temp folder.");
button.addEventListener("pointerdown", (event) => event.stopPropagation(), { signal });
button.addEventListener("click", (event) => {
const toggle = document.createElement("div");
toggle.className = "ausboss-input-preview-toggle";
toggle.title = state.switchTitle ?? "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;
@@ -303,7 +317,7 @@ function makePreviewToggle(state, signal) {
notifyAusbossChange();
}, { signal });
state.switchButton = button;
return button;
return toggle;
}
function installPreviewSwitch(state, signal) {
@@ -334,10 +348,18 @@ function installStepsSwitch(state, signal) {
if (!widget) return;
state.stepsWidget = widget;
hideWidget(widget);
const button = panelSwitch("Preview steps", "Show the picture here while it is being made, one step at a time, with a Stop button. Off by default.");
button.classList.add("steps");
button.addEventListener("pointerdown", (event) => event.stopPropagation(), { signal });
button.addEventListener("click", (event) => {
const toggle = document.createElement("div");
toggle.className = "ausboss-input-preview-toggle steps";
toggle.title = "Show the picture here while it is being made, one step at a time, with a Stop button. Off by default.";
const caption = document.createElement("span");
caption.textContent = "steps";
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 = widget.value !== true;
widget.value = next;
@@ -346,9 +368,9 @@ function installStepsSwitch(state, signal) {
notifyAusbossChange();
}, { signal });
state.stepsButton = button;
const previewToggle = state.switchButton;
if (previewToggle?.parentElement === state.bar) state.bar.insertBefore(button, previewToggle);
else state.bar.append(button);
const previewToggle = state.switchButton?.parentElement;
if (previewToggle?.parentElement === state.bar) state.bar.insertBefore(toggle, previewToggle);
else state.bar.append(toggle);
// Stop sits where the node's tools go; it keeps its place while hidden so
// the bar never shifts when a run starts.
@@ -386,6 +408,7 @@ function syncSteps(state, run = stepRun()) {
if (typeof state.placeholder === "boolean") state.hint.textContent = placeholderText(state.placeholder, state.noun, runningFor(state, run));
const on = stepsEnabled(state);
if (state.stepsButton) {
state.stepsButton.classList.toggle("on", state.stepsWidget?.value === true);
state.stepsButton.setAttribute("aria-checked", String(state.stepsWidget?.value === true));
}
state.stopButton?.classList.toggle("live", on && run.status === "running");
@@ -440,6 +463,7 @@ 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) {
+2 -2
View File
@@ -17,8 +17,8 @@
// it. Ctrl (or Alt) while dragging leaves the grid off. The grid starts off
// over a picture on `background`; a click on Grid is saved with the node.
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange, showToast } from "../shared/index.mjs";
import { keepKeyInField } from "../shared/canvas_passthrough.mjs";
import { describeSourcePreview, outputLocatorId } from "../shared/input_preview.mjs";
+1 -1
View File
@@ -1,4 +1,4 @@
import { app } from "../../../scripts/app.js";
import { app } from "/scripts/app.js";
import { BRAND, notifyAusbossChange, showToast } from "../shared/index.mjs";
import { enoughFor, lineUp, matchWidth, sameSize, sharedSides, spreadEvenly } from "../shared/line_up.mjs";
import { outlineOf } from "../shared/outline.mjs";
+6 -17
View File
@@ -6,9 +6,9 @@
// move IN or OUT, drag the middle to slide the pick, or click elsewhere to
// move it there. Play plays only what comes out. The widgets underneath
// (start_seconds, end_seconds, max_seconds) stay the source of truth.
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange, showToast } from "../shared/index.mjs";
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
import { keepKeyInField } from "../shared/canvas_passthrough.mjs";
import { WIDGET_FRAME, fillNodeHeight } from "../shared/panel_layout.mjs";
import { formatTimecode, parseTimecode } from "../shared/timecode.mjs";
@@ -102,7 +102,6 @@ function ensureAudioCss() {
style.id = "ausboss-load-audio-css";
style.textContent = `
.ausboss-audio-root .ausboss-video-stage{min-height:28px;background:#050b0b;cursor:pointer;touch-action:none;}
.ausboss-audio-error{pointer-events:auto;border:0;background:transparent;color:#e6bf87;font:11px system-ui;cursor:pointer;white-space:nowrap}
.ausboss-audio-root canvas.ausboss-audio-wave{position:absolute;inset:0;width:100%;height:100%;display:block;}
.ausboss-audio-root .ausboss-video-stage:not(.is-empty) .ausboss-video-status{display:none;}
.ausboss-audio-root .ausboss-audio-row{display:flex;align-items:center;gap:6px;flex:none;padding:0 2px;}
@@ -380,7 +379,7 @@ function buildPanel(node) {
canvas.className = "ausboss-audio-wave";
const status = document.createElement("div");
status.className = "ausboss-video-status";
status.textContent = "Choose a sound file";
status.textContent = "Choose or upload a sound file";
const playButton = makeToolButton("▶", "Play the part that comes out");
playButton.classList.add("ausboss-audio-play");
const loopButton = makeToolButton("LOOP", "Play it again and again");
@@ -470,7 +469,7 @@ function buildPanel(node) {
if (!name || typeof name !== "string") {
audio.removeAttribute("src");
stage.classList.add("is-empty");
status.textContent = "Choose a sound file";
status.textContent = "Choose or upload a sound file";
updateFace(state);
return;
}
@@ -489,17 +488,7 @@ function buildPanel(node) {
updateFace(state);
} catch (error) {
if (ticket !== state.loading) return;
const info = document.createElement("button");
info.type = "button";
info.className = "ausboss-audio-error";
info.textContent = "Audio preview unavailable ⓘ";
info.title = String(error?.message || error);
info.setAttribute("aria-label", "Audio preview unavailable. Show details.");
info.onclick = event => {
event.stopPropagation();
showToast({ summary: "Audio preview unavailable", detail: info.title });
};
status.replaceChildren(info);
status.textContent = `This sound could not be shown: ${error?.message || error}`;
}
};
state.refresh = refresh;
+2 -2
View File
@@ -1,5 +1,5 @@
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { isForeignRun } from "../shared/prompt_scope.mjs";
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
import { fillNodeHeight } from "../shared/panel_layout.mjs";
+2 -2
View File
@@ -1,5 +1,5 @@
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
import { keepKeyInField } from "../shared/canvas_passthrough.mjs";
import { WIDGET_FRAME, fillNodeHeight } from "../shared/panel_layout.mjs";
+28 -6
View File
@@ -1,5 +1,5 @@
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange, showToast } from "../shared/index.mjs";
import { WIDGET_FRAME, fillNodeHeight, holdVueNodeMinWidth } from "../shared/panel_layout.mjs";
import { hideInputsInDef, hideWidget as collapseWidget } from "../shared/widget_visibility.mjs";
@@ -20,6 +20,7 @@ import {
BYPASS_MODE,
DEFAULT_STEP,
FINE_STEP,
MAX_ROWS,
MUTE_MODE,
STRENGTH_MAX,
STRENGTH_MIN,
@@ -1077,6 +1078,10 @@ function openPicker(state, index, anchor, { keyboard = false } = {}) {
const { name, kind } = entry;
if (kind === "folder") {
const result = appendFolderRows(state.rows, folders.get(name) ?? [], state.settings?.default_strength ?? 1);
if (result.needed > result.room) {
loraToast(`This folder has ${result.needed} new LoRAs, but only ${result.room} of ${MAX_ROWS} rows are free. Nothing was added. Remove rows or choose a smaller folder.`);
return;
}
if (!result.added) {
loraToast("Every LoRA in this folder is already in this stack. Nothing was added.");
return;
@@ -1090,6 +1095,10 @@ function openPicker(state, index, anchor, { keyboard = false } = {}) {
if (!pointer) backToStack(firstAdded);
return;
}
if (adding && state.rows.length >= MAX_ROWS) {
loraToast(`This stack already has ${MAX_ROWS} rows. Remove a row before adding another LoRA.`);
return;
}
closePopup();
const strength = roundStrength(state.settings?.default_strength ?? 1);
const rows = adding
@@ -1105,6 +1114,10 @@ function openPicker(state, index, anchor, { keyboard = false } = {}) {
const addTicked = ({ pointer = false } = {}) => {
const result = appendFolderRows(state.rows, ticked, state.settings?.default_strength ?? 1);
if (result.needed > result.room) {
loraToast(`You ticked ${result.needed} new LoRAs, but only ${result.room} of ${MAX_ROWS} rows are free. Nothing was added. Untick some or remove rows.`);
return;
}
if (!result.added) {
loraToast("Every ticked LoRA is already in this stack. Nothing was added.");
return;
@@ -1756,6 +1769,7 @@ function openRowMenu(state, index, event) {
}
menu.append(el("div", "ausboss-lora-menu-rule"));
const insertEmpty = (position) => {
if (state.rows.length >= MAX_ROWS) return;
const strength = roundStrength(state.settings?.default_strength ?? 1);
const rows = state.rows.slice();
// A row added next to a grouped one joins its group.
@@ -1765,8 +1779,8 @@ function openRowMenu(state, index, event) {
commitRows(state, rows, { structural: true });
fitNode(state);
};
item("Insert empty LoRA above", () => insertEmpty(index));
item("Insert empty LoRA below", () => insertEmpty(index + 1));
item("Insert empty LoRA above", () => insertEmpty(index), state.rows.length >= MAX_ROWS);
item("Insert empty LoRA below", () => insertEmpty(index + 1), state.rows.length >= MAX_ROWS);
item("Move up", () => commitGroups(state, moveRowInStack(state.rows, index, -1)),
index === 0 && !row.group);
item("Move down", () => commitGroups(state, moveRowInStack(state.rows, index, 1)),
@@ -1777,7 +1791,7 @@ function openRowMenu(state, index, event) {
rows.splice(index + 1, 0, copy);
commitRows(state, rows, { structural: true });
fitNode(state);
});
}, state.rows.length >= MAX_ROWS);
item(count > 1 ? `Remove these ${count} LoRAs` : "Remove this LoRA", () => {
commitGroups(state, state.rows.filter((entry) => !ids.includes(entry.id)));
}).classList.add("ausboss-lora-danger");
@@ -1790,7 +1804,7 @@ function openStackMenu(state, event) {
const menu = el("div", "ausboss-lora-menu");
const item = (label, action, disabled = false) => menuItem(menu, label, action, disabled);
const add = state.panel.querySelector(".ausboss-lora-add, .ausboss-lora-blank");
item("+ Add LoRAs…", () => openPicker(state, null, add), !add);
item("+ Add LoRAs…", () => openPicker(state, null, add), !add || state.rows.length >= MAX_ROWS);
const ids = state.rows.filter((row) => selectedIds(state).has(row.id)).map((row) => row.id);
if (ids.length) {
item(ids.length > 1 ? `Group the ${ids.length} selected LoRAs` : "Group the selected LoRA", () => {
@@ -2475,6 +2489,11 @@ async function runImportChain(state) {
{ position: "before", deduplicate: false });
const downMerge = mergeImportedRows(upMerge.rows, downResolved,
{ position: "after", deduplicate: false });
// Never bypass a source if any of its rows would be lost to the stack limit.
if (state.rows.length + upMerge.added + downMerge.added > MAX_ROWS) {
loraToast(`Absorb needs more than ${MAX_ROWS} rows. Free some stack rows first; source loaders remain active.`);
return;
}
// Rows that patch model and CLIP differently need both boxes visible, or
// the difference is invisible and lost on the first scrub. The flip is
// node-local (unified stays the default for new nodes) and the toast
@@ -2651,6 +2670,7 @@ function installStackKeyboard(state) {
if (action.kind === "focus") {
focusStackRow(state, visibleRowIndex(state, index, action.index));
} else if (action.kind === "pick") {
if (atAdd && state.rows.length >= MAX_ROWS) return;
openPicker(state, atAdd ? null : index,
atAdd ? state.panel.querySelector(".ausboss-lora-add") : rowElement.querySelector(".ausboss-lora-name"),
{ keyboard: true });
@@ -2688,6 +2708,7 @@ function renderRows(state) {
panel.append(body);
const addRowAndPick = (event) => {
if (state.rows.length >= MAX_ROWS) return;
// detail is 0 for a click made with Enter or Space.
openPicker(state, null, event.currentTarget, { keyboard: event.detail === 0 });
};
@@ -2915,6 +2936,7 @@ function renderRows(state) {
const actions = el("div", "ausboss-lora-actions");
const add = el("button", "ausboss-lora-add", "+ LoRA");
add.type = "button";
add.disabled = state.rows.length >= MAX_ROWS;
add.addEventListener("click", addRowAndPick);
add.title = "Pick one LoRA, or click a folder name to add all its LoRAs.";
actions.append(add);
+3 -8
View File
@@ -4,8 +4,8 @@
// node, runs the node alone when the face asks for the picture or for what
// SAM 3 finds at a click, and hands it what a run sent back.
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { chainCallback } from "../shared/index.mjs";
import { executionIdOf, isSwitchedOff, nodeByExecutionId } from "../shared/graph_ids.mjs";
import { outputLocatorId } from "../shared/input_preview.mjs";
@@ -168,18 +168,13 @@ api.addEventListener("execution_error", ({ detail }) => {
api.addEventListener("execution_interrupted", () => settleAll());
// A look is one long, quiet run. The node sends what it has found every
// second or two, so things light up while it is still looking.
// A run that stops at the node (nothing picked on this picture) sends the
// picture that came in first, so a picture that changed upstream shows at
// once. It is shown like a kept note: it starts no run of its own.
api.addEventListener(LOOK_EVENT, ({ detail }) => {
try {
if (!faces.size || !detail?.note) return;
const id = String(detail.node ?? "");
const root = app.rootGraph ?? app.graph;
for (const face of faces) {
if (!face.alive || executionIdOf(root, face.node) !== id) continue;
if (detail.stopped) face.takeNote(detail.note, { restored: true });
else if (face.inFlight) face.takeNote(detail.note, { interim: true });
if (face.alive && face.inFlight && executionIdOf(root, face.node) === id) face.takeNote(detail.note, { interim: true });
}
} catch {
// Never in the way of a run.
+2 -2
View File
@@ -5,8 +5,8 @@
// setting turns it on. The chime is built once as a small WAV in memory
// and played through an ordinary audio element, so the pack ships no sound
// file and nothing is built until the first chime plays.
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { showToast } from "../shared/index.mjs";
import { noticesFromExecuted, queueRemaining, shouldChime } from "../shared/notify.mjs";
import { chimeSamples, wavBytes } from "./chime.mjs";
+2 -2
View File
@@ -17,8 +17,8 @@
// It also notes the runs a node starts for itself (Mask by Touch looking at
// its picture), so the Run Timer and the runtime badges can leave them out.
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { isAusbossNode } from "../shared/index.mjs";
import { nodeByExecutionId } from "../shared/graph_ids.mjs";
import { isNodeOwnRun, outputKey, ownRuns, runScope } from "../shared/prompt_scope.mjs";
+1 -1
View File
@@ -7,7 +7,7 @@
// position/size/title/colors/flags/mode/properties survive. Slots or
// widgets that no longer exist are skipped silently. Any failure rolls
// the graph back and keeps the original node.
import { app } from "../../../scripts/app.js";
import { app } from "/scripts/app.js";
import { linkSlots, restoreOutputLinks, snapshotLinks } from "../shared/graph_links.mjs";
import { isAusbossNode } from "../shared/index.mjs";
+1 -1
View File
@@ -8,7 +8,7 @@
// node, carry translated widgets, remap links, and roll back on failure.
// This is purely a graph rewrite; no third-party class name is ever
// registered on the backend.
import { app } from "../../../scripts/app.js";
import { app } from "/scripts/app.js";
import { linkSlots, restoreOutputLinks, snapshotLinks } from "../shared/graph_links.mjs";
import { BRAND, notifyAusbossChange, showToast } from "../shared/index.mjs";
import {
+70 -181
View File
@@ -21,8 +21,7 @@
// at the selected pixel budget and follow the explicit orientation toggle. Snap governs gestures only — typed
// values are never re-snapped.
import { CAPTION_TYPE, HINT_TYPE, compactAction } from "../shared/panel_controls.mjs";
import { app } from "../../../scripts/app.js";
import { app } from "/scripts/app.js";
import {
chainCallback,
keepDomWidgetWidthAuto,
@@ -30,10 +29,8 @@ import {
} from "../shared/index.mjs";
import { WIDGET_FRAME, fillNodeHeight, holdVueNodeMinWidth } from "../shared/panel_layout.mjs";
import { hideInputsInDef, hideWidget } from "../shared/widget_visibility.mjs";
import { markSocketless } from "../shared/widget_card_math.mjs";
import { makeScrubInput } from "../shared/scrub_input.mjs";
import {
closeSettingsMenu,
gearIconSvg,
loadSettings,
openSettingsMenu,
@@ -54,16 +51,12 @@ import {
offGrid,
orientRatio,
parseRail,
parseWorkflowSizes,
readWorkflowPolicy,
savedWorkflowPolicy,
workflowRatios,
quickSizes,
ratioLabel,
reduceRatio,
scrubDim,
sizeForMegapixels,
fitStage,
stageScale,
} from "../shared/resolution_math.mjs";
const NODE_CLASS = "AUSBOSS_NODES_Resolution";
@@ -139,19 +132,13 @@ function installStyles() {
.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-size-dialog{color-scheme:dark;width:min(365px,88vw);border:1px solid #345554;border-radius:10px;background:#162022;color:#d7e4e3;padding:18px;font:13px system-ui}
.ausboss-size-dialog::backdrop{background:#0008}
.ausboss-size-dialog textarea{display:block;box-sizing:border-box;width:100%;margin:8px 0;padding:8px;font:13px monospace}
.ausboss-size-dialog input{accent-color:#00b4aa}
.ausboss-size-dialog [role=alert]{color:#ffd49e}
.ausboss-size-dialog p{color:#adc1be;font:12px/1.45 system-ui;margin:7px 0 12px}
.ausboss-size-dialog footer{display:flex;justify-content:flex-end;gap:8px;margin-top:12px}
.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 { ${CAPTION_TYPE} white-space: nowrap; }
.ausboss-res-sechead .hint { ${HINT_TYPE}
.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;
@@ -187,12 +174,12 @@ function installStyles() {
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: var(--ausboss-res-inset,0) 0 0; margin: auto;
border: 1.5px solid #00b4aa; background: rgba(0,180,170,.10);
.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: var(--ausboss-res-inset,0) 0 0; margin: auto;
.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;
@@ -220,8 +207,6 @@ function installStyles() {
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[inert] .ausboss-scrub,.ausboss-res-field .ausboss-scrub[inert]{border-color:transparent;background:transparent}
.ausboss-res-field[inert] .ausboss-scrub-step,.ausboss-res-field .ausboss-scrub[inert] .ausboss-scrub-step{display:none}
.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;
@@ -229,7 +214,7 @@ function installStyles() {
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;
height: ${QUICK_H * 2 + 5}px; overflow-y: auto; align-content: start; }
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;
@@ -271,7 +256,6 @@ function getSize(state) {
}
function commitSize(state, width, height, { gesture = false } = {}) {
if (state.strict && (!isListed(state, width, height) || axisLinked(state, "width") || axisLinked(state, "height"))) return;
if (!axisLinked(state, "width")) state.widthWidget.value = clampDim(width);
if (!axisLinked(state, "height")) state.heightWidget.value = clampDim(height);
repaint(state);
@@ -366,7 +350,7 @@ function stageBox(state) {
// Equal-cost curves, log-sampled across the plausible width range so they
// sweep the whole quadrant (the design's rendering).
function paintRings(state, scale, shift) {
function paintRings(state, scale) {
const svg = state.rings;
svg.replaceChildren();
const show = state.settings?.show_mp_rings !== false;
@@ -374,7 +358,7 @@ function paintRings(state, scale, shift) {
const box = stageBox(state);
svg.setAttribute("viewBox", `0 0 ${box.w} ${box.h}`);
const cx = box.w / 2;
const cy = box.h / 2 + shift;
const cy = box.h / 2;
for (const mp of [0.5, 1, 2]) {
const points = [];
for (let i = 0; i <= 60; i += 1) {
@@ -412,7 +396,7 @@ function paintRings(state, scale, shift) {
// The quick list regenerates when the ratio or snap changes; repaint only
// re-highlights it otherwise.
function rebuildQuick(state, width, height, snap, custom, picks) {
function rebuildQuick(state, width, height, snap, custom) {
const wrap = state.quickWrap;
wrap.replaceChildren();
state.quickButtons = [];
@@ -425,8 +409,7 @@ function rebuildQuick(state, width, height, snap, custom, picks) {
wrap.append(chip);
return;
}
const list = picks ?? quickSizes(width, height, snap);
if (state.strict && !list.length) 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."));
@@ -437,7 +420,6 @@ function rebuildQuick(state, width, height, snap, custom, picks) {
chip.type = "button";
chip.title = `Apply ${size.width}×${size.height} exactly.`;
chip.addEventListener("click", () => {
if (picks && size.width !== size.height) state.node.properties.ausbossResolutionPortrait = size.height > size.width;
commitSize(state, size.width, size.height);
easeRefit(state);
});
@@ -448,98 +430,26 @@ function rebuildQuick(state, width, height, snap, custom, picks) {
}
}
function isListed(state, width, height) {
return state.sizes.some(([w, h]) => w === width && h === height);
}
function restorePolicy(state) {
const widget = state.policyWidget;
if (!widget) return;
const legacy = state.node.properties?.ausbossWorkflowSizes;
widget.value = savedWorkflowPolicy(widget.value, legacy);
if (state.node.properties) delete state.node.properties.ausbossWorkflowSizes;
try {
Object.assign(state, readWorkflowPolicy(widget.value), { policyError: false });
} catch {
Object.assign(state, { sizes: [], strict: true, policyError: true });
}
const { width, height } = getSize(state);
if (state.strict && state.sizes.length && !isListed(state, width, height)
&& !axisLinked(state, "width") && !axisLinked(state, "height")) {
[state.widthWidget.value, state.heightWidget.value] = state.sizes[0];
}
}
function editWorkflowSizes(state) {
closeSettingsMenu();
const dialog = el("dialog", "ausboss-size-dialog");
dialog.setAttribute("aria-label", "Workflow resolutions");
const label = el("label", "", "Workflow resolutions");
const area = el("textarea");
area.rows = 6;
area.placeholder = "576 × 1024";
area.value = state.sizes.map(pair => pair.join(" × ")).join("\n");
label.append(area);
const hint = el("p", "", "One width × height per line. These quick picks are saved with this node.");
const lock = el("label");
const checkbox = el("input");
checkbox.type = "checkbox";
checkbox.checked = state.strict;
lock.append(checkbox, " Limit choices to this list");
const description = el("p", "", "Only listed sizes and their exact aspect ratios can be selected. Linked dimensions are checked when the workflow runs.");
const error = el("div");
error.setAttribute("role", "alert");
const footer = el("footer");
const cancel = compactAction("Cancel");
const save = compactAction("Save sizes");
cancel.onclick = () => dialog.close();
save.onclick = () => {
let sizes;
try {
sizes = parseWorkflowSizes(area.value);
if (checkbox.checked && !sizes.length) throw new Error("Add a resolution, or turn off the limit to allow any size.");
} catch (e) {
error.textContent = e.message;
area.focus();
return;
}
state.policyWidget.value = sizes.length ? JSON.stringify({ strict: checkbox.checked, sizes }) : "";
restorePolicy(state);
buildPanel(state);
fitNode(state);
notifyAusbossChange();
dialog.close();
};
footer.append(cancel, save);
dialog.append(label, hint, lock, description, error, footer);
document.body.append(dialog);
dialog.addEventListener("close", () => dialog.remove(), { once:true });
dialog.showModal();
}
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.dimLabel.textContent = `${width} × ${height}`;
state.subMp.textContent = `${megapixels(width, height).toFixed(2)} MP`;
state.subRatio.textContent = state.strict ? `${ratio.x}:${ratio.y}` : ratioLabel(width, height);
state.offGridDot.style.display = !state.strict && offGrid(width, height, snap) ? "" : "none";
state.dots.style.display = state.settings?.show_grid === false ? "none" : "";
const box = stageBox(state);
const sideRoom = 14 + Math.max(state.dimLabel.offsetWidth, state.subMp.parentElement.offsetWidth);
const frame = state.frame = state.frozenFrame ?? fitStage(width, height, box.w, box.h, sideRoom);
const { scale, shift } = frame;
state.stage.style.setProperty("--ausboss-res-inset", `${2 * shift}px`);
state.rect.style.width = `${Math.max(8, width * scale)}px`;
state.rect.style.height = `${Math.max(8, height * scale)}px`;
paintRings(state, scale, shift);
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 = state.strict ? "Workflow ratios" : width === height
state.orientLabel.textContent = width === height
? `square · chips ${portrait ? "portrait" : "landscape"}`
: portrait ? "portrait" : "landscape";
const flipFit = portrait ? { w: 11, h: 15 } : { w: 15, h: 11 };
@@ -553,17 +463,14 @@ function repaint(state) {
// Ratio chips render in the selected orientation; filled = exact, dashed = near.
state.chipButtons.forEach(({ button, ratio: chipRatio, glyph, label }) => {
const oriented = state.strict ? chipRatio : orientRatio(chipRatio, portrait);
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 = state.strict ? `Show approved ${oriented.label} sizes` : `${oriented.label}: reshape to this ratio. The flip button on the left sets the orientation.`;
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 = state.strict
? (ratio.x === chipRatio.x && ratio.y === chipRatio.y ? { state: "exact" } : null)
: candidate?.flipped ? null : candidate;
button.setAttribute("aria-pressed", String(match?.state === "exact"));
const match = candidate?.flipped ? null : candidate;
button.classList.toggle("exact", match?.state === "exact");
button.classList.toggle("near", match?.state === "near");
});
@@ -572,52 +479,49 @@ function repaint(state) {
const widthLinked = axisLinked(state, "width");
const heightLinked = axisLinked(state, "height");
const linked = widthLinked || heightLinked;
state.flipButton.disabled = linked || state.strict;
state.swapButton.disabled = linked || (state.strict && (width === height || !isListed(state, height, width)));
state.budgetControl.root.inert = linked || state.strict;
state.budgetControl.root.style.opacity = linked || state.strict ? ".65" : "";
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));
for (const [field, wired] of [[state.widthField, widthLinked], [state.heightField, heightLinked]]) {
field.inert = wired || state.strict;
field.classList.toggle("linked", wired);
}
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", linked);
state.handles.se.classList.toggle("linked", widthLinked || heightLinked);
// Quick sizes follow the current ratio and snap grid. Frozen
// 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 group = state.strict && state.railRatios.find(r => r.x === ratio.x && r.y === ratio.y);
const picks = state.strict ? (group?.sizes ?? []) : state.sizes.length
? state.sizes.map(([w, h]) => ({ width: w, height: h })) : null;
const quickKey = picks ? JSON.stringify(picks) : onRail ? `${ratio.x}:${ratio.y}/${snap}` : `custom/${snap}`;
const quickKey = onRail ? `${ratio.x}:${ratio.y}/${snap}` : `custom/${snap}`;
if (state.quickKey !== quickKey) {
state.quickKey = quickKey;
rebuildQuick(state, width, height, snap, !picks && !onRail, picks);
rebuildQuick(state, width, height, snap, !onRail);
state.quickHint.textContent = onRail
? `for ${ratioLabel(width, height)} · /${snap}`
: `custom · /${snap}`;
}
state.quickHint.textContent = state.policyError ? "Invalid list · edit in settings"
: linked && state.strict ? "Linked size checked at run"
: state.strict ? `Allowed · ${ratio.x}:${ratio.y}` : picks ? "Workflow recommendations"
: onRail ? `for ${ratioLabel(width, height)} · /${snap}` : `custom · /${snap}`;
}
state.quickButtons.forEach(({ button, size }) => {
button.disabled = linked;
const selected = size.width === width && size.height === height;
button.classList.toggle("exact", selected);
button.setAttribute("aria-pressed", String(selected));
button.disabled = widthLinked || heightLinked;
button.classList.toggle("exact", size.width === width && size.height === height);
});
}
function showGhost(state, width, height) {
const { scale } = state.frame;
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 = "";
@@ -628,7 +532,7 @@ function hideGhost(state) {
}
function easeRefit(state) {
state.frozenFrame = null;
state.frozenScale = null;
state.rect.classList.add("easing");
repaint(state);
clearTimeout(state.easeTimer);
@@ -638,14 +542,15 @@ function easeRefit(state) {
// ---------- gestures ----------
function beginHandleDrag(state, handle, event) {
if (state.strict || event.button !== 0 || state.disposed) return;
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.frozenFrame = state.frame;
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();
@@ -658,7 +563,7 @@ function beginHandleDrag(state, handle, event) {
startHeight: start.height,
dx: (moveEvent.clientX - origin.x) / zoom,
dy: (moveEvent.clientY - origin.y) / zoom,
scale: state.frozenFrame.scale,
scale: state.frozenScale,
snap: currentSnap(state),
snapOn: !moveEvent.altKey,
// The corner scales at the exact locked ratio; Shift frees both axes.
@@ -739,7 +644,7 @@ function sectionHead(label, hint) {
function buildPanel(state) {
state.dragAbort?.abort();
state.dragging = false;
state.frozenFrame = null;
state.frozenScale = null;
const panel = state.panel;
panel.textContent = "";
state.quickKey = null; // force the quick list to regenerate
@@ -763,16 +668,19 @@ function buildPanel(state) {
state.flipButton = flip;
state.flipGlyph = flipGlyph;
state.chipButtons = [];
state.railRatios = state.strict ? workflowRatios(state.sizes) : parseRail(state.settings?.rail);
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);
if (state.strict) return ratio.sizes.find(s => s.width === size.width && s.height === size.height) ?? ratio.sizes[0];
const portrait = orientation(state);
if (state.settings?.chips_keep_budget) {
return applyRatioAtBudget(size.width, size.height, ratio, currentSnap(state), portrait);
@@ -793,15 +701,13 @@ function buildPanel(state) {
state.chipButtons.push({ button: chip, ratio, glyph, label });
}
chipBox.append(chipGrid);
if (!state.strict) shapeRow.append(flipBox);
shapeRow.append(chipBox);
shapeRow.append(flipBox, chipBox);
shapeSec.append(shapeRow);
panel.append(shapeSec);
// (2) STAGE
const stage = el("div", "ausboss-res-stage");
stage.tabIndex = state.strict ? -1 : 0;
if (state.strict) stage.title = "Choose from the workflow’s ratio and size buttons.";
stage.tabIndex = 0;
const dots = el("div", "ausboss-res-dots");
const rings = svgEl("svg");
rings.classList.add("ausboss-res-rings");
@@ -828,14 +734,14 @@ function buildPanel(state) {
? "Drag width - Alt unsnaps, Esc cancels"
: "Drag height - Alt unsnaps, Esc cancels";
grip.addEventListener("pointerdown", (event) => beginHandleDrag(state, handle, event));
if (!state.strict) rect.append(grip);
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 || state.strict) return;
if (!move) return;
event.preventDefault();
event.stopPropagation();
const step = event.shiftKey ? 8 : currentSnap(state);
@@ -897,13 +803,10 @@ function buildPanel(state) {
gear.addEventListener("click", () => {
openSettingsMenu({
scope: SETTINGS_SCOPE,
schema: [{ section: "This workflow" }, {
key: "workflow_sizes", type: "action", label: "Workflow resolutions", button: "Edit list",
onAction: () => editWorkflowSizes(state),
}, ...SETTINGS_SCHEMA.filter(e => !state.strict || !["default_snap", "chips_keep_budget", "rail"].includes(e.key)).map((entry) => {
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: "Latent Size settings",
initial: {
@@ -966,7 +869,7 @@ function buildPanel(state) {
state.resizeObserver?.disconnect();
state.resizeObserver = new ResizeObserver(() => {
holdVueNodeMinWidth(state.panel, MIN_WIDTH);
if (!state.frozenFrame) repaint(state);
if (!state.frozenScale) repaint(state);
});
state.resizeObserver.observe(stage);
@@ -1004,8 +907,6 @@ function installResolutionNode(node) {
// 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");
const policyWidget = node.widgets?.find(widget => widget.name === "workflow_sizes");
hideWidget(policyWidget);
if (latentWidget) hideWidget(latentWidget);
if (batchWidget) hideWidget(batchWidget);
@@ -1027,12 +928,9 @@ function installResolutionNode(node) {
heightWidget,
latentWidget,
batchWidget,
policyWidget,
sizes: [],
strict: false,
panel,
settings: loadSettings(SETTINGS_SCOPE, SETTINGS_SCHEMA),
frozenFrame: null,
frozenScale: null,
quickKey: null,
};
node.__ausbossResolution = state;
@@ -1055,7 +953,6 @@ function installResolutionNode(node) {
exactMinWidth: true,
});
restorePolicy(state);
buildPanel(state);
for (const widget of [widthWidget, heightWidget, latentWidget, batchWidget]) {
if (widget) chainCallback(widget, "callback", () => {
@@ -1083,18 +980,11 @@ function installResolutionNode(node) {
// 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;
restorePolicy(state);
buildPanel(state);
requestAnimationFrame(() => { if (!state.disposed) { syncPrimitiveLinks(state); repaint(state); } });
});
chainCallback(node, "onConnectionsChange", function () {
requestAnimationFrame(() => {
if (state.disposed) return;
syncPrimitiveLinks(state);
// A removed Primitive can leave its last value in the backing widget.
if (state.strict) restorePolicy(state);
repaint(state);
});
// 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;
@@ -1108,8 +998,7 @@ app.registerExtension({
name: "AusBoss.Resolution",
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== NODE_CLASS) return;
markSocketless(nodeData, ["workflow_sizes"]);
hideInputsInDef(nodeData, ["width", "height", "latent", "batch_size", "workflow_sizes"]);
hideInputsInDef(nodeData, ["width", "height", "latent", "batch_size"]);
chainCallback(nodeType.prototype, "onNodeCreated", function () {
installResolutionNode(this);
});
+2 -2
View File
@@ -19,8 +19,8 @@
// check once per input the check reads, and collapseRepeatedErrors keeps
// one per message on AusBoss nodes before the frontend shows them.
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { showToast } from "../shared/index.mjs";
import { nodeByExecutionId } from "../shared/graph_ids.mjs";
import {
+2 -2
View File
@@ -12,8 +12,8 @@
// 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 { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { BRAND, chainCallback, keepDomWidgetWidthAuto } from "../shared/index.mjs";
import { holdNodeMinWidth } from "../shared/panel_layout.mjs";
import { isOwnRun, ownRuns } from "../shared/prompt_scope.mjs";
+4 -5
View File
@@ -8,9 +8,8 @@
// 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 { PANEL_LABELS } from "../shared/panel_controls.mjs";
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { isForeignRun } from "../shared/prompt_scope.mjs";
import { keepKeyInField } from "../shared/canvas_passthrough.mjs";
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
@@ -276,13 +275,13 @@ function buildCard(node) {
embedSeg.setAttribute("role", "radiogroup");
const embedButtons = new Map();
const embedTitle = "Embed stores the prompt and workflow in the image for drag-back. Off writes a clean image for sharing or datasets.";
for (const [on, text] of [[false, PANEL_LABELS.off], [true, PANEL_LABELS.on]]) {
for (const [on, text] of [[false, "no"], [true, "yes"]]) {
const button = el("button", "", text);
button.type = "button"; button.title = embedTitle;
button.addEventListener("click", () => { if (setValue(node, "save_metadata", on)) notifyAusbossChange(); state.refresh(); });
embedSeg.append(button); embedButtons.set(on, button);
}
embedRow.append(el("span", "ausboss-save-embed-label", PANEL_LABELS.embed), embedSeg);
embedRow.append(el("span", "ausboss-save-embed-label", "Embed workflow"), embedSeg);
root.append(embedRow);
root.addEventListener("pointerdown", (event) => {
+9 -10
View File
@@ -1,6 +1,5 @@
import { panelSwitch } from "../shared/panel_controls.mjs";
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { isForeignRun, outputKey } from "../shared/prompt_scope.mjs";
import { chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
import { installNameTokens } from "../shared/name_tokens.mjs";
@@ -22,7 +21,7 @@ import {
const NODE_NAME = "AUSBOSS_NODES_SaveVideo";
const PREVIEW_WIDGET = "ausboss_save_video_viewer";
const PREVIEW_CHROME = 38;
const PREVIEW_CHROME = 12;
// Height the node opens at. It used to fall out of the panel's computeSize;
// with the panel free to follow the node, the default has to be stated. Same
// 16:9-ish stage the width-derived formula produced, plus the widget rows.
@@ -39,7 +38,7 @@ function getLoopEnabled(node) {
return !!node.properties.ausboss_save_video_loop;
}
// The Preview steps switch: the hidden `step_preview` widget (the card hides it).
// The STEPS button: the hidden `step_preview` widget (the card hides it).
// On, the player shows each step of the clip's first frame while it is
// being made, and a STOP button joins the tools for the run.
function stepsOn(state) {
@@ -48,7 +47,7 @@ function stepsOn(state) {
function syncSteps(state, run = stepRun()) {
const on = stepsOn(state);
state.stepsButton?.setAttribute("aria-checked", String(on));
state.stepsButton?.classList.toggle("active", on);
state.stopButton?.classList.toggle("live", on && run.status === "running");
const overlay = state.stepOverlay;
if (!on) {
@@ -131,11 +130,11 @@ function buildPreview(node) {
loopButton.classList.add("ausboss-steps-loop");
const stopButton = makeToolButton("STOP", "Stop this run now. The next run in the queue still starts.");
stopButton.classList.add("ausboss-steps-stop");
const stepsButton = panelSwitch("Preview steps", "Show the clip's first frame here while it is being made, one step at a time, with a Stop button. Off by default.");
const stepsButton = makeToolButton("STEPS", "Show the clip's first frame here while it is being made, one step at a time, with a Stop button. Off by default.");
tools.classList.add("ausboss-steps-tools");
tools.append(stopButton, loopButton);
tools.append(stopButton, stepsButton, loopButton);
stage.append(video, image, status, tools);
root.append(stepsButton, stage);
root.append(stage);
const widget = node.addDOMWidget(PREVIEW_WIDGET, "ausboss_video", root, {
serialize: false,
@@ -148,7 +147,7 @@ function buildPreview(node) {
widget.serialize = false;
fillNodeHeight(widget, {
minWidth: VIDEO_MIN_WIDTH,
minHeight: 158,
minHeight: 144,
minNodeSize: [VIDEO_MIN_WIDTH, 270],
exactMinWidth: true,
});
+1 -1
View File
@@ -11,7 +11,7 @@
// 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 { app } from "/scripts/app.js";
import { isForeignRun } from "../shared/prompt_scope.mjs";
import { keepKeyInField } from "../shared/canvas_passthrough.mjs";
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
-18
View File
@@ -45,24 +45,6 @@ const LINK = `#(${ID})`;
const WORDS_LINK_RE = /\[([^\[\]\n]+)\]\(([^\s()]+(?:\([^\s()]*\)[^\s()]*)*)\)/g;
const URL_MAX = 2000;
// Only a whole, ordinary web link gets the simple label/address form. Keep
// multi-paragraph or mixed-content notes in the text editor, without loss.
export function tileLinkFields(text) {
const value = String(text ?? "");
const match = /^\[([^\[\]\n]+)\]\((.+)\)$/.exec(value);
if (!match || !linkTarget(match[2])?.url) return null;
const blocks = parseBlocks(value);
if (blocks.length !== 1 || blocks[0].length !== 1
|| blocks[0][0].some(piece => piece.url !== match[2])) return null;
return { label: match[1], url: match[2] };
}
export function tileLinkText(label, url) {
const text = `[${label}](${url.trim()})`;
if (!tileLinkFields(text)) throw new Error("Enter a label without square brackets and a complete https:// or http:// address.");
return text;
}
// Where a link goes: { url }, { link } (a node's id) or { group } (a group's
// id), or null when the text is none of them. `raw` is what stands in the
// round brackets, or after an arrow's #.
+7 -36
View File
@@ -5,14 +5,14 @@
// a mask canvas holds what has been rubbed away, and the view shows the
// picture underneath with the top picture, less that mask, over it. Save
// sends the strokes to the server, which builds the mix at full size.
import { compactAction } from "./panel_controls.mjs";
import { api } from "../../../scripts/api.js";
import { api } from "/scripts/api.js";
import { BRAND, notifyAusbossChange, showToast } from "./index.mjs";
import { brushAdjusts, brushFlips, brushKey, brushWords, toolKey } from "./brush.mjs";
import { keepKeyInField } from "./canvas_passthrough.mjs";
import { makeScrubInput } from "./scrub_input.mjs";
import { makeSlideSwitch } from "./slide_switch.mjs";
import { extendStroke } from "./touch_math.mjs";
import { makeToolButton } from "./video_ui.mjs";
import {
CHECKER,
ERASE_OUTSIDE,
@@ -82,7 +82,6 @@ const CAN_FILTER = typeof CanvasRenderingContext2D !== "undefined" && "filter" i
export const ERASE_CSS = `
.ausboss-compare-erasebar{display:none;box-sizing:border-box;flex-wrap:wrap;align-items:center;gap:4px 6px;flex:none;min-height:${ERASE_BAR_HEIGHT}px;}
.ausboss-compare-root.is-erasing .ausboss-compare-erasebar{display:flex;}
.ausboss-compare-brush-swatch{box-sizing:border-box;flex:none;width:24px;height:24px;border-radius:4px;background:#0b1113;border:1px solid rgba(0,180,170,.3);}
.ausboss-compare-erasebar .ausboss-scrub{flex:none;}
.ausboss-compare-field{display:flex;align-items:center;gap:4px;flex:none;}
.ausboss-compare-label{flex:none;color:#9fb6b4;font-size:10px;line-height:22px;white-space:nowrap;user-select:none;}
@@ -248,32 +247,9 @@ function drawCheckerboard(er, ctx, fit) {
ctx.fillRect(fit.left, fit.top, fit.width, fit.height);
}
// Use the cursor's actual dab. A compressed size scale keeps both a
// two-pixel brush and a two-thousand-pixel brush legible in the toolbar.
function drawBrushSwatch(er) {
const size = brushSize(er);
const soft = brushSoft(er);
const back = er.back !== Boolean(er.flipKey);
const key = `${size}|${soft}|${back}`;
if (er.swatchKey === key) return;
er.swatchKey = key;
const swatch = er.swatch;
const side = swatch.width;
const fraction = Math.log(size / ERASE_SIZE[0]) / Math.log(ERASE_SIZE[1] / ERASE_SIZE[0]);
const diameter = side * (0.22 + 0.66 * fraction);
const dab = dabOf(er, size, soft, diameter / size, back ? BACK_TINT : RUB_TINT);
const ctx = swatch.getContext("2d");
ctx.clearRect(0, 0, side, side);
ctx.drawImage(dab.canvas, side / 2 - dab.reach, side / 2 - dab.reach);
swatch.title = `${back ? "Restore" : "Erase"} brush: ${size} px, ${soft}% soft`;
swatch.setAttribute("aria-label", swatch.title);
}
function draw(er) {
er.frame = 0;
if (!er.active) return;
drawBrushSwatch(er);
if (!er.refs) return;
if (!er.active || !er.refs) return;
sizeView(er);
const { canvas } = er;
const ctx = canvas.getContext("2d");
@@ -401,7 +377,7 @@ function syncTools(er) {
er.layersSwitch.set(layers ? "on" : "off");
const aligned = Boolean(er.refs?.aligned);
er.alignButton.disabled = !ready || er.aligning;
er.alignButton.textContent = er.aligning ? "Aligning" : "Align";
er.alignButton.textContent = er.aligning ? "ALIGNING" : "ALIGN";
er.alignButton.classList.toggle("active", aligned);
er.alignButton.title = !ready
? "Run the workflow first: Align needs the two pictures."
@@ -851,11 +827,6 @@ export function makeEraser({ node, root, stage, imageA, imageB, signal, onSaved,
return wrap;
};
const sizeField = scrubField("Size", er.sizeBox, SIZE_HELP);
er.swatch = document.createElement("canvas");
er.swatch.className = "ausboss-compare-brush-swatch";
er.swatch.width = er.swatch.height = 64;
er.swatch.setAttribute("role", "img");
sizeField.prepend(er.swatch);
const softField = scrubField("Soft", er.softBox, SOFT_HELP);
const TOP_HELP = "Which picture you start from. The brush shows the other one through it. Swapping keeps your paint.";
er.topSwitch = makeSlideSwitch({
@@ -875,7 +846,7 @@ export function makeEraser({ node, root, stage, imageA, imageB, signal, onSaved,
// Align, Undo and Clear stay together at the right end, also on a second line.
const steps = document.createElement("div");
steps.className = "ausboss-compare-steps";
er.undoButton = click(compactAction("Undo", "Take back the last stroke, or a Clear."), () => {
er.undoButton = click(makeToolButton("UNDO", "Take back the last stroke, or a Clear."), () => {
if (!er.history.length && !er.strokes.length) return;
// Paint that came with the workflow has no steps of its own to go back
// through: its last stroke is taken off.
@@ -885,13 +856,13 @@ export function makeEraser({ node, root, stage, imageA, imageB, signal, onSaved,
syncTools(er);
schedule(er);
});
er.clearButton = click(compactAction("Clear", "Take all the paint off. Undo brings it back."), () => {
er.clearButton = click(makeToolButton("CLEAR", "Take all the paint off. Undo brings it back."), () => {
if (!er.strokes.length) return;
change(er, []);
rebuildMask(er);
schedule(er);
});
er.alignButton = click(compactAction("Align", ""), () => align(er));
er.alignButton = click(makeToolButton("ALIGN", ""), () => align(er));
steps.append(er.alignButton, er.undoButton, er.clearButton);
er.bar.append(er.kindSwitch.root, sizeField, softField, topField, steps);
+1 -1
View File
@@ -2,7 +2,7 @@
// name, a small copy of it and its job, then the video and its job. A slot with nothing plugged in is a faint
// dashed chip, so it reads as optional. The widgets underneath stay the
// source of truth: a chip only changes their value.
import { app } from "../../../scripts/app.js";
import { app } from "/scripts/app.js";
import { BRAND, chainCallback } from "./index.mjs";
import { liftSocket } from "./widget_card.mjs";
import { commitWidgetValue, comboValues } from "./widget_card_math.mjs";
-16
View File
@@ -1,16 +0,0 @@
// The same live-input rule used by the writer timeline: muted/bypassed
// sources do not reach this node. This inspects the wire, never media bytes.
export function linkSource(node, name) {
const slot = node.inputs?.find((input) => input?.widget?.name === name || input?.name === name);
if (slot?.link === null || slot?.link === undefined) return null;
const graph = node.graph;
const link = graph?.links?.[slot.link] ?? graph?._links?.get?.(slot.link);
const source = graph?.getNodeById?.(link?.origin_id);
return (source?.mode ?? 0) === 0 ? source ?? null : null;
}
// Keep in step with Python KEEPS_ITS_LENGTH. Other jobs use seconds normally.
export function clipSetsDuration(node, values) {
return ["swap the person", "new voice", "change something"].includes(values.video_job)
&& linkSource(node, "video") !== null;
}
+2 -2
View File
@@ -4,12 +4,12 @@
// which makes this the right home for import-only utilities. Per-node
// entry points live in js/<node>/index.js and import from here.
import { app } from "../../../scripts/app.js";
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 = "2.21.2";
export const AUSBOSS_JS_VERSION = "2.20.5";
export const BRAND = "#00b4aa"; // AusBoss teal — keep node accents consistent.
export const BRAND_DARK = "#007f78";
+11 -7
View File
@@ -12,6 +12,7 @@ export const FINE_STEP = 0.01;
// large enough that a shaky click cannot change the value.
export const SCRUB_PIXELS_PER_STEP = 4;
export const SCRUB_DEAD_ZONE = 3;
export const MAX_ROWS = 64;
let rowSerial = 0;
@@ -39,7 +40,7 @@ export function newRow() {
export function normalizeRows(parsed) {
if (!Array.isArray(parsed)) return [];
return tidyGroups(parsed.map((entry) => {
return tidyGroups(parsed.slice(0, MAX_ROWS).map((entry) => {
const row = newRow();
if (entry && typeof entry === "object") {
row.id = typeof entry.id === "string" && entry.id ? entry.id : row.id;
@@ -599,8 +600,9 @@ export function loraPickerEntries(names, query = "", collapsedFolders = new Set(
return visit(tree);
}
// Append the folder in one step, preserving duplicates, disabled rows and
// empty slots already in the stack. Deduplicate new files by full path.
// Folder add is one append, or no change when the whole selection cannot
// fit. Existing rows (even duplicates, disabled rows and empty slots) stay
// untouched. Deduplicate by full path, never basename or moved-file guess.
export function appendFolderRows(rows, names, defaultStrength = 1) {
const seen = new Set(rows.map((row) => loraPath(row.name)));
const pending = [];
@@ -611,10 +613,12 @@ export function appendFolderRows(rows, names, defaultStrength = 1) {
seen.add(key);
pending.push(name);
}
if (!pending.length) return { rows, added: 0, skipped };
const room = Math.max(0, MAX_ROWS - rows.length);
const needed = pending.length;
if (needed > room || !needed) return { rows, added: 0, skipped, needed, room };
const strength = roundStrength(defaultStrength);
const addedRows = pending.map((name) => ({ ...newRow(), name, strength, strength_clip: strength }));
return { rows: [...rows, ...addedRows], added: addedRows.length, skipped };
return { rows: [...rows, ...addedRows], added: needed, skipped, needed, room };
}
// A suggested range is advisory: null/absent bounds never flag.
@@ -1027,9 +1031,9 @@ export function mergeImportedRows(existing, imported,
have.add(key);
added.push({ ...makeRow(), ...item });
}
const rows = position === "after"
const rows = (position === "after"
? [...(existing ?? []), ...added]
: [...added, ...(existing ?? [])];
: [...added, ...(existing ?? [])]).slice(0, MAX_ROWS);
return { rows, added: added.length, skipped };
}
+1 -1
View File
@@ -29,7 +29,7 @@ export function filterMedia(values, query) {
export function unlistedMedia(values, value) {
const text = String(value ?? "");
if (!text || !Array.isArray(values)) return "";
const bare = text.replace(/\s+\[input\]$/, "");
const bare = text.replace(/\s+\[(input|output)\]$/, "");
return values.includes(text) || values.includes(bare) ? "" : text;
}
+15 -27
View File
@@ -2,9 +2,8 @@
// bar as long as the clip, with the cuts as marks. The server reads the script with the same code a run uses,
// so the bar shows what will happen. It also colours the script box, so the
// words the node reads as instructions (@1, 3s:, a cut) stand out.
import { clipSetsDuration, linkSource } from "./h3_duration.mjs";
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { BRAND, chainCallback } from "./index.mjs";
import { dragRange, moveMoment, retime, rulerSeconds, stripCaption, stripRows, toggleCut, wiredJobs } from "./moment_strip_math.mjs";
import { attachScriptAssist } from "./script_assist.mjs";
@@ -48,6 +47,16 @@ function ensureCss() {
document.head.append(style);
}
// A bypassed or muted source doesn't reach the node when it runs, so it doesn't count.
function linkSource(node, name) {
const slot = node.inputs?.find((input) => input?.widget?.name === name || input?.name === name);
if (slot?.link === null || slot?.link === undefined) return null;
const graph = node.graph;
const link = graph?.links?.[slot.link] ?? graph?._links?.get?.(slot.link);
const source = graph?.getNodeById?.(link?.origin_id);
return (source?.mode ?? 0) === 0 ? source ?? {} : null;
}
function wired(node, name) {
return linkSource(node, name) !== null;
}
@@ -84,7 +93,7 @@ export function momentStrip(node) {
})),
cameras: choices("camera"),
looks: choices("look"),
seconds: clipSetsDuration(node, lastValues) ? 0 : Number(lastSeconds) || 8,
seconds: Number(lastSeconds) || 8,
}));
let lastValues = {};
@@ -266,38 +275,17 @@ export function momentStrip(node) {
body: JSON.stringify({ script, seconds, jobs, video }),
});
const report = response.ok ? await response.json() : null;
if (turn === asked && !clipSetsDuration(node, lastValues)) show(report);
if (turn === asked) show(report);
} catch {
if (turn === asked && !clipSetsDuration(node, lastValues)) show(null);
if (turn === asked) show(null);
}
};
let latest = null;
let fromClip = false;
const sync = (values) => {
latest = values;
lastValues = values;
const nextFromClip = clipSetsDuration(node, values);
if (fromClip !== nextFromClip) {
fromClip = nextFromClip;
// Also notice a source switched off without changing this node's wires.
queueMicrotask(() => node.__ausbossCard?.refresh?.());
}
colours.sync(values, [1, 2, 3, 4].filter((number) => wired(node, `picture_${number}`)));
if (fromClip) {
shown = null;
clearTimeout(timer);
asked += 1;
ruler.textContent = "";
track.textContent = "";
const message = el("ausboss-strip-words", "Length comes from the clip");
message.style.cssText = "display:flex;align-items:center;height:100%";
track.append(message);
note.textContent = "Set the clip's length upstream. Timing controls are unavailable here.";
note.title = note.textContent;
note.classList.remove("warn");
return;
}
if (wired(node, "script")) {
shown = null;
clearTimeout(timer);
-13
View File
@@ -1,13 +0,0 @@
// A tile needs its renderer. Give saved tiles a real node identity so an old
// install reports a missing node instead of stretching a plain note over it.
export const CALLOUT_TYPES = new Set(["AUSBOSS_NODES_Callout", "AUSBOSS_NODES_CalloutTile"]);
export const NOTE_TYPES = new Set(["AUSBOSS_NODES_WorkflowNote", "AUSBOSS_NODES_WorkflowNoteTile"]);
export function serializeNoteIdentity(saved, tile) {
const types = CALLOUT_TYPES.has(saved.type) ? [...CALLOUT_TYPES]
: NOTE_TYPES.has(saved.type) ? [...NOTE_TYPES] : null;
if (!types) return;
saved.type = types[tile ? 1 : 0];
// LiteGraph's serialized properties can still be the live object's properties.
saved.properties = { ...saved.properties, "Node name for S&R": saved.type };
}
-54
View File
@@ -1,54 +0,0 @@
// Shared controls for compact node panels. Only elements explicitly given one
// of these classes are styled; the owning panel decides where they sit.
import { BRAND } from "./index.mjs";
export const PANEL_LABELS = Object.freeze({
load: "Load", sources: { input: "Inputs", output: "Outputs" },
embed: "Embed workflow", off: "Off", on: "On",
});
const controlFont = 'system-ui, sans-serif';
export const CAPTION_TYPE = `font:600 11px/1.25 ${controlFont};color:#b2c6c3;`;
export const HINT_TYPE = `font:400 10px/1.25 ${controlFont};color:#95aba8;`;
export function ensurePanelControls() {
if (document.getElementById("ausboss-panel-controls")) return;
const style = document.createElement("style");
style.id = "ausboss-panel-controls";
style.textContent = `
.ausboss-compact-action{box-sizing:border-box;flex:none;height:22px;min-width:24px;padding:0 7px;border:1px solid #365957;border-radius:4px;background:#111b1c;color:#bdd8d5;font:600 11px/1 ${controlFont};white-space:nowrap;cursor:pointer;pointer-events:auto}
.ausboss-compact-action:hover:enabled,.ausboss-compact-action.active{color:#dbfffb;border-color:${BRAND};background:#173332}
.ausboss-compact-action:disabled{opacity:.5;cursor:default}
.ausboss-panel-switch{display:inline-flex;align-items:center;gap:6px;align-self:flex-end;flex:none;box-sizing:border-box;min-height:20px;margin-left:auto;padding:0 2px;background:transparent;border:0;color:#b2c6c3;font:600 11px/1 ${controlFont};cursor:pointer;user-select:none;pointer-events:auto}
.ausboss-panel-switch i{position:relative;width:24px;height:13px;border-radius:7px;background:#3a4445}
.ausboss-panel-switch i::after{content:"";position:absolute;top:2px;left:2px;width:9px;height:9px;border-radius:50%;background:#b6c4c3}
.ausboss-panel-switch[aria-checked=true] i{background:${BRAND}}
.ausboss-panel-switch[aria-checked=true] i::after{left:13px;background:#fff}
.ausboss-compact-action:focus-visible,.ausboss-panel-switch:focus-visible{outline:2px solid ${BRAND};outline-offset:1px}
`;
document.head.append(style);
}
export function compactAction(label, title = "") {
ensurePanelControls();
const button = document.createElement("button");
button.type = "button";
button.className = "ausboss-compact-action";
button.textContent = label;
button.title = title;
return button;
}
export function panelSwitch(label, title) {
ensurePanelControls();
const button = document.createElement("button");
button.type = "button";
button.className = "ausboss-panel-switch";
button.title = title;
button.setAttribute("role", "switch");
button.setAttribute("aria-checked", "false");
const thumb = document.createElement("i");
thumb.setAttribute("aria-hidden", "true");
button.append(label, thumb);
return button;
}
+7 -59
View File
@@ -129,17 +129,13 @@ export function ratioSizeNear(ratio, width, height, step = 1) {
// modifiers: snap (default true; false = 1px freedom),
// lockRatio (true = keep the grab-time ratio exactly).
// Fit the selection inside its handles. If both corner readouts fit beside
// the rectangle (or already fit above it), the full stage height is usable.
export function fitStage(width, height, boxW, boxH, cornerRoom = 126) {
const pad = 10; // the outer half of an 18px handle plus a pixel of air
const fit = top => Math.min(Math.max(1, boxW - 2 * pad) / width,
Math.max(1, boxH - top - pad) / height);
const full = fit(pad);
const header = 26;
if ((boxW - width * full) / 2 >= cornerRoom || (boxH - height * full) / 2 >= header)
return { scale: full, shift: 0 };
return { scale: fit(header), shift: (header - pad) / 2 };
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 = {
@@ -410,51 +406,3 @@ export function sizeForMegapixels(width, height, targetMp, step = DEFAULT_SNAP)
}
return best;
}
// A workflow's list keeps the author's order and the exact orientation of each
// pair. A near match, including a reversed pair, is a different model input.
export function parseWorkflowSizes(text) {
const lines = text.trim().split(/\n/).map(line => line.trim()).filter(Boolean);
if (lines.length > 24) throw new Error("Choose up to 24 resolutions.");
const sizes = [];
for (const line of lines) {
const match = /^(\d{1,4})\s*[x×]\s*(\d{1,4})$/i.exec(line);
const pair = match?.slice(1).map(Number);
if (!pair || pair.some(n => n < DIM_MIN || n > DIM_MAX)) {
throw new Error(`Enter a width × height between ${DIM_MIN} and ${DIM_MAX} px on each line.`);
}
if (!sizes.some(([w, h]) => w === pair[0] && h === pair[1])) sizes.push(pair);
}
return sizes;
}
export function workflowRatios(sizes) {
const ratios = new Map();
for (const [width, height] of sizes) {
const { x, y } = reduceRatio(width, height);
const label = `${x}:${y}`;
if (!ratios.has(label)) ratios.set(label, { x, y, label, sizes: [] });
ratios.get(label).sizes.push({ width, height });
}
return [...ratios.values()];
}
export function readWorkflowPolicy(value) {
if (value === "") return { sizes: [], strict: false };
const invalid = () => { throw new Error("Invalid workflow resolutions."); };
if (typeof value !== "string" || value.length > 2048) invalid();
const policy = JSON.parse(value);
if (typeof policy?.strict !== "boolean" || !Array.isArray(policy.sizes)
|| !policy.sizes.length || policy.sizes.length > 24
|| !policy.sizes.every(pair => Array.isArray(pair) && pair.length === 2
&& pair.every(n => Number.isInteger(n) && n >= DIM_MIN && n <= DIM_MAX))) invalid();
return { sizes: policy.sizes, strict: policy.strict };
}
// The panel used to occupy the appended widget's position in older saves.
// Keep any other malformed value intact so strict settings cannot fail open.
export function savedWorkflowPolicy(value, legacy) {
if (value != null && value !== "" && value !== "image") return value;
return Array.isArray(legacy) && legacy.length ? JSON.stringify({ strict: true, sizes: legacy }) : "";
}
+2 -2
View File
@@ -9,8 +9,8 @@
//
// Runs queued from another workflow tab are left to that tab.
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { nodeByExecutionId } from "./graph_ids.mjs";
import { BRAND } from "./index.mjs";
import { outputRecordQuery } from "./input_preview.mjs";
-207
View File
@@ -1,207 +0,0 @@
import { app } from "../../../scripts/app.js";
// Classic paints the node behind the DOM widget. Override only drawing properties
// on this instance so saved colors, titles and the node type remain usable.
const originalDescriptors = new WeakMap();
export function setTileFace(node, root, enabled, onEdit) {
root.classList.toggle("ausboss-tile-face", enabled);
const previous = originalDescriptors.get(node);
if (enabled === Boolean(previous)) return;
if (enabled) {
const properties = {
title_mode: () => globalThis.LiteGraph?.NO_TITLE ?? 1,
renderingBgColor: () => "transparent",
drawBadges: () => skipBadges,
};
const descriptors = new Map();
for (const [key, get] of Object.entries(properties)) {
const own = Object.getOwnPropertyDescriptor(node, key);
descriptors.set(key, own);
Object.defineProperty(node, key, {
configurable: true,
get() {
if (!node.flags?.collapsed) return get();
if (own) return own.get ? own.get.call(node) : own.value;
return Reflect.get(Object.getPrototypeOf(node), key, node);
},
});
}
const listeners = new AbortController();
installTileDrag(node, root, listeners.signal, onEdit);
installTileResize(node, root, listeners.signal);
originalDescriptors.set(node, { descriptors, listeners });
} else {
previous.listeners.abort();
for (const [key, descriptor] of previous.descriptors) {
if (descriptor) Object.defineProperty(node, key, descriptor);
else delete node[key];
}
originalDescriptors.delete(node);
}
node.setDirtyCanvas?.(true, true);
}
function skipBadges() {}
// A normal node moves by its header. Give the tile that gesture while
// keeping a short click on a link or the info button as its usual action.
function installTileDrag(node, root, signal, onEdit) {
let press;
let lastClick;
let suppressClick = false;
const capture = { capture: true, signal };
root.addEventListener("pointerdown", (event) => {
suppressClick = false;
if (!event.isPrimary || event.button !== 0 || node.pinned || app.canvas?.read_only) return;
if (event.target.closest(".ausboss-callout-editor,.ausboss-callout-tools,.ausboss-note-edit,.ausboss-tile-resize")) return;
if (onEdit && !event.target.closest("a,button") && lastClick
&& event.timeStamp - lastClick.time < 500
&& Math.hypot(event.clientX - lastClick.x, event.clientY - lastClick.y) < 8) {
lastClick = null;
event.preventDefault();
event.stopPropagation();
onEdit();
return;
}
press = { id: event.pointerId, x: event.clientX, y: event.clientY, moving: false };
const target = event.target.closest("a,button") || root;
target.setPointerCapture(event.pointerId);
event.stopPropagation();
}, capture);
root.addEventListener("pointermove", (event) => {
if (event.pointerId !== press?.id) return;
const dx = event.clientX - press.x;
const dy = event.clientY - press.y;
if (!press.moving) {
if (Math.hypot(dx, dy) < 5) return;
press.moving = true;
node.graph?.beforeChange?.();
if (!node.selected) app.canvas?.selectNode?.(node, event.shiftKey);
const selected = Array.from(app.canvas?.selectedItems ?? []);
press.starts = (selected.includes(node) ? selected : [node])
.filter(item => item.pos && !item.pinned)
.map(item => ({ item, x: item.pos[0], y: item.pos[1] }));
}
const zoom = app.canvas?.ds?.scale || 1;
for (const start of press.starts) start.item.pos = [start.x + dx / zoom, start.y + dy / zoom];
node.setDirtyCanvas?.(true, true);
event.stopPropagation();
}, capture);
const finish = (event) => {
if (event.pointerId !== press?.id) return;
if (press.moving) {
node.graph?.afterChange?.();
suppressClick = true;
lastClick = null;
} else {
lastClick = { time: event.timeStamp, x: event.clientX, y: event.clientY };
}
press = null;
};
root.addEventListener("pointerup", finish, capture);
root.addEventListener("lostpointercapture", finish, capture);
root.addEventListener("click", (event) => {
if (suppressClick) {
event.preventDefault();
event.stopImmediatePropagation();
suppressClick = false;
} else if (!event.target.closest("a,button,.ausboss-callout-editor")) {
app.canvas?.selectNode?.(node, event.shiftKey);
}
}, capture);
root.addEventListener("dragstart", (event) => {
if (press) event.preventDefault();
}, { signal });
root.addEventListener("contextmenu", (event) => {
if (event.target.closest("textarea,input")) return;
event.preventDefault();
event.stopPropagation();
app.canvas?.processContextMenu?.(node, event);
}, { signal });
}
// Put the resize target on the visible corner. The native node corner lies
// outside a frameless tile and is almost impossible to find without a frame.
function installTileResize(node, root, signal) {
const face = root.querySelector(".ausboss-callout-card,.ausboss-note-card") || root;
const grip = document.createElement("button");
grip.type = "button";
grip.className = "ausboss-tile-resize";
grip.setAttribute("aria-label", "Resize tile");
grip.title = "Drag to resize. Arrow keys resize; Shift makes smaller changes.";
face.append(grip);
let drag;
const minimum = () => {
// Use the same floor as the native corner, including widget margins.
// Older saved tiles can be smaller: touching their grip must not jump.
const floor = node.computeSize?.() ?? [200, 72];
return floor.map((value, axis) => Math.min(value, node.size[axis]));
};
const resize = (width, height, floor) => {
node.setSize?.([Math.max(floor[0], width), Math.max(floor[1], height)]);
node.setDirtyCanvas?.(true, true);
};
grip.addEventListener("pointerdown", event => {
event.stopPropagation();
if (event.button !== 0 || node.pinned || app.canvas?.read_only) return;
event.preventDefault();
node.graph?.beforeChange?.();
app.canvas?.selectNode?.(node);
drag = { id: event.pointerId, x: event.clientX, y: event.clientY,
size: [...node.size], floor: minimum(), zoom: app.canvas?.ds?.scale || 1 };
grip.setPointerCapture(event.pointerId);
});
grip.addEventListener("pointermove", event => {
if (event.pointerId !== drag?.id) return;
event.stopPropagation();
resize(drag.size[0] + (event.clientX - drag.x) / drag.zoom,
drag.size[1] + (event.clientY - drag.y) / drag.zoom, drag.floor);
});
const finish = event => {
if (event.pointerId !== drag?.id) return;
drag = null;
node.graph?.afterChange?.();
};
grip.addEventListener("pointerup", finish);
grip.addEventListener("pointercancel", finish);
grip.addEventListener("lostpointercapture", finish);
grip.addEventListener("click", event => { event.preventDefault(); event.stopPropagation(); });
grip.addEventListener("keydown", event => {
if (!event.key.startsWith("Arrow")) return;
event.preventDefault();
event.stopPropagation();
if (node.pinned || app.canvas?.read_only) return;
const step = event.shiftKey ? 1 : 10;
node.graph?.beforeChange?.();
resize(node.size[0] + (event.key === "ArrowRight" ? step : event.key === "ArrowLeft" ? -step : 0),
node.size[1] + (event.key === "ArrowDown" ? step : event.key === "ArrowUp" ? -step : 0), minimum());
node.graph?.afterChange?.();
});
signal.addEventListener("abort", () => { if (drag) node.graph?.afterChange?.(); grip.remove(); }, { once: true });
}
// Nodes 2.0 always adds a title's height to --node-height, even when a tile
// hides the title. Remove that unused reservation: otherwise an 88px saved
// tile gains a 30px tail and overlaps the next tile. Vue also positions the
// host at saved y minus the title height. Restore that headerless origin
// with an independent translation so faces cannot cover group headings.
// Keep the saved size and
// the native selection/resize targets, rather than changing the graph.
// Its in-body pack badges also consume 20px that Classic paints outside the
// panel. Decorative tiles and callouts use that room for their own face.
// The node-body test id and direct mt-auto badge wrapper are from the pinned
// frontend's LGraphNode.vue/NodeBadges.vue; no other node body is affected.
export const TILE_FACE_CSS = `
.ausboss-tile-face :is(.ausboss-callout-card,.ausboss-note-head){pointer-events:auto;touch-action:none;cursor:grab;}
.ausboss-tile-face{user-select:none;}
.ausboss-tile-resize{position:absolute;right:1px;bottom:1px;z-index:4;width:22px;height:22px;padding:0;border:0;border-radius:3px;background:transparent;color:#9fb9b6;opacity:.65;cursor:nwse-resize;touch-action:none;pointer-events:auto;}
.ausboss-tile-resize::after{content:"";position:absolute;right:4px;bottom:4px;width:9px;height:9px;background:repeating-linear-gradient(135deg,transparent 0 3px,currentColor 3px 4px,transparent 4px 6px);clip-path:polygon(100% 0,100% 100%,0 100%);}
.ausboss-tile-resize:hover,.ausboss-tile-resize:focus-visible{opacity:1;background:#00b4aa33;outline:1px solid #00b4aa;outline-offset:-1px;}
.lg-node:has(.ausboss-tile-face){filter:none;}
.lg-node:has(.ausboss-tile-face):not([data-collapsed]){min-height:calc(var(--node-height) - ${globalThis.LiteGraph?.NODE_TITLE_HEIGHT ?? 30}px);translate:0 ${globalThis.LiteGraph?.NODE_TITLE_HEIGHT ?? 30}px;}
.lg-node:has(.ausboss-tile-face,.ausboss-callout) [data-testid^="node-body-"]>.mt-auto{display:none;}
.lg-node:has(.ausboss-tile-face) [data-testid="node-inner-wrapper"],
.lg-node:has(.ausboss-tile-face) .bg-component-node-background{background:transparent!important;box-shadow:none;}
.lg-node:has(.ausboss-tile-face) .lg-node-header{display:none;}
`;
+5 -21
View File
@@ -128,9 +128,8 @@ export function fixedFrameWindow(info, first, frames, outputFps) {
const tooLong = seconds > duration + 1e-7;
const span = Math.max(1, Math.ceil(seconds * info.fps - 1e-7));
const latest = Math.max(0, Math.floor((duration - seconds) * info.fps + 1e-7));
const latestStart = Math.min(latest, Math.max(0, info.count - span));
const head = clamp(Math.round(finite(first)), 0, latestStart);
return { first: head, last: Math.min(info.count - 1, head + span - 1), frames: count, seconds, tooLong, latestStart };
const head = clamp(Math.round(finite(first)), 0, Math.min(latest, Math.max(0, info.count - span)));
return { first: head, last: Math.min(info.count - 1, head + span - 1), frames: count, seconds, tooLong };
}
// What a run keeps out of the window after thinning, the cap and the snap
@@ -244,28 +243,13 @@ export function clipLengthPlan(info, values = {}) {
const exact = (frames) => {
const fixed = fixedFrameWindow(info, window.first, frames, values.outputFps === undefined ? info.fps / nth : values.outputFps);
if (!fixed || fixed.unresolved) return { ...base, requested: frames };
return { ...base, first: fixed.first, last: fixed.last, windowLast: fixed.last, frames: fixed.frames, requested: frames, latestStart: fixed.latestStart, tooLong: fixed.tooLong };
return { ...base, first: fixed.first, last: fixed.last, windowLast: fixed.last, frames: fixed.frames, requested: frames, tooLong: fixed.tooLong };
};
const capped = (cap) => {
const requested = cap > 0 ? snapFrameCount(cap, snap) : null;
// Limit edits to IN, never the stored start. A shared workflow may
// intentionally open with a shorter tail. The whole locked count must
// fit before the source end or an earlier saved/wired OUT.
const endFrame = frameWindow(info, 0, values.end).last;
let latestStart = null;
if (requested != null) {
if (!values.resampled) {
latestStart = Math.max(0, endFrame - lastFrameFor(0, requested, nth));
} else if (values.outputFps > 0) {
const duration = info.duration || info.count / info.fps;
const end = values.end > 0 ? Math.min(values.end, duration) : duration;
const lastTick = (requested - 1) / values.outputFps;
latestStart = clamp(Math.floor((end - lastTick - TRIM_EPSILON) * info.fps), 0, endFrame);
}
}
if (values.resampled) return { ...base, last: cap > 0 ? null : window.last, requested, latestStart };
if (values.resampled) return { ...base, last: cap > 0 ? null : window.last, requested };
const kept = keptFrames(window, nth, cap, snap);
return { ...base, last: kept.lastKept, frames: kept.frames, requested, latestStart, truncated: requested != null && kept.frames < requested };
return { ...base, last: kept.lastKept, frames: kept.frames, requested, truncated: requested != null && kept.frames < requested };
};
const stored = Math.max(0, Math.trunc(finite(values.maxFrames)));
if (mode === "fixed") return exact(Math.trunc(finite(values.fixedFrames)));
+12 -24
View File
@@ -11,8 +11,7 @@
// Undo is instant. And the edge settings are worked out here on a small copy
// of the picture, so Grow and Soften answer while they are dragged.
import { ensurePanelControls } from "./panel_controls.mjs";
import { api } from "../../../scripts/api.js";
import { api } from "/scripts/api.js";
import {
brushAdjusts,
brushCore,
@@ -95,9 +94,8 @@ export const NOTE_KEY = "ausboss_mask_by_touch";
const TOOLS_HEIGHT = 24;
const BAR_HEIGHT = 20;
const STAGE_MIN = 150;
// Wide enough for the tools over the picture (Reload, Undo, Clear and the eye
// included) and the bar under it: the arrows, a pick's name, Subject and Background.
export const PANEL_MIN_WIDTH = 428;
// Wide enough for the bar under the picture: the arrows, a pick's name, Subject and Background.
export const PANEL_MIN_WIDTH = 400;
// What the panel's element needs at its smallest: tools, stage and bar, two
// gaps and the padding under them.
const CHIPS_HEIGHT = 22;
@@ -105,9 +103,9 @@ const CHIPS_HEIGHT = 22;
const CHIP_ROWS = 3;
// A node this wide has room for the names in front of the brush's two
// numbers. A narrower one shows the numbers alone.
const LABELS_FROM = 470;
const LABELS_FROM = 440;
// The width a new node opens with: the names fit.
export const OPENING_WIDTH = 490;
export const OPENING_WIDTH = 460;
const PANEL_NEEDS = TOOLS_HEIGHT + STAGE_MIN + BAR_HEIGHT + CHIPS_HEIGHT + 12 + 6;
// Saved with the workflow: the brush size once it was set by hand, the snap
// switch, and how the picked area is shown.
@@ -135,8 +133,6 @@ const EYE_TITLES = {
// The eye beside Undo and Clear, drawn here: an almond and its pupil.
const EYE_ICON = '<svg width="16" height="16" viewBox="-8 -8 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true">'
+ '<path d="M-6.6 0Q0 -6 6.6 0Q0 6 -6.6 0Z"/><circle r="2" fill="currentColor" stroke="none"/></svg>';
const RELOAD_ICON = '<svg width="14" height="14" viewBox="-8 -8 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
+ '<path d="M5.6 -2.4A6 6 0 1 0 5.2 3"/><path d="M6 -6.2V-2.2H2"/></svg>';
// The brush size, across the picture, in picture pixels.
const BRUSH_SIZE = [4, 1000];
// A brush mark counts as touching an area within this many pixels of the
@@ -244,7 +240,7 @@ function ensureCss() {
.ausboss-touch.narrow .ausboss-touch-tools{gap:3px;}
.ausboss-touch.narrow .ausboss-touch-field.size .ausboss-scrub{width:56px !important;}
.ausboss-touch.narrow .ausboss-touch-field.soft .ausboss-scrub{width:48px !important;}
.ausboss-touch-btn.eye,.ausboss-touch-btn.reload{display:inline-flex;align-items:center;justify-content:center;width:26px;min-width:26px;padding:0;}
.ausboss-touch-btn.eye{display:inline-flex;align-items:center;justify-content:center;width:26px;min-width:26px;padding:0;}
.ausboss-touch-btn.eye.on{border-color:${BRAND};background:${BRAND};color:#04201f;}
.ausboss-touch-btn.arrow{min-width:20px;width:20px;padding:0;font-size:14px;line-height:16px;}
.ausboss-touch-swatch{box-sizing:border-box;flex:none;width:${BAR_HEIGHT}px;height:${BAR_HEIGHT}px;border-radius:4px;background:#0b1113;border:1px solid rgba(0,180,170,.3);pointer-events:auto;}
@@ -745,7 +741,7 @@ function settings(face) {
grow: Math.round(number("grow")),
soften: Math.max(0, number("soften")),
smooth: Math.max(0, Math.round(number("smooth"))),
fill: widgetByName(face.node, "fill_holes")?.value === true,
fill: widgetByName(face.node, "fill_holes")?.value !== false,
invert: widgetByName(face.node, "invert")?.value === true,
fine: fineChoice(widgetByName(face.node, "fine_edges")?.value),
};
@@ -1406,7 +1402,6 @@ function syncTools(face) {
const waiting = live && face.pending.length > 0;
face.undo.disabled = !live || (!some && !face.past.length && !waiting);
face.clear.disabled = !some && !waiting;
face.reload.disabled = !live || face.inFlight;
if (face.lookButton) {
const offer = live && face.snap && !face.held && !face.inFlight && !face.pic.scoped && Boolean(face.host.lookOnButton?.());
face.lookButton.style.display = offer ? "" : "none";
@@ -2881,9 +2876,9 @@ export function mountTouchStage(node, host) {
};
readProperties(face);
const showPicture = () => { if (!face.inFlight) { face.retries = 0; if (face.pic) face.pic.paused = false; requestRun(face, "scope"); } };
face.start = button("ausboss-touch-start", "Show picture",
"Runs only this node, so the picture shows here and its pieces light up under the pointer.", showPicture, signal);
"Runs only this node, so the picture shows here and its pieces light up under the pointer.",
() => { if (!face.inFlight) { face.retries = 0; if (face.pic) face.pic.paused = false; requestRun(face, "scope"); } }, signal);
wake.append(face.start, hint);
// Tools: what a press does, the brush, undo and clear; on the right, how
@@ -2943,9 +2938,8 @@ export function mountTouchStage(node, host) {
};
face.sizeField = field("Size", face.size, SIZE_HELP);
face.softField = field("Soft", face.softBox, SOFT_HELP);
ensurePanelControls();
face.undo = button("ausboss-compact-action", "Undo", "Take back the last pick, or a Clear.", () => undo(face), signal);
face.clear = button("ausboss-compact-action", "Clear", "Pick nothing. Undo brings it back.", () => {
face.undo = button("ausboss-touch-btn", "Undo", "Take back the last pick, or a Clear.", () => undo(face), signal);
face.clear = button("ausboss-touch-btn", "Clear", "Pick nothing. Undo brings it back.", () => {
dropPending(face);
face.active = 0;
face.fresh = 0;
@@ -2980,13 +2974,7 @@ export function mountTouchStage(node, host) {
if (event.shiftKey) face.setView(face.view === "cut" ? "mask" : face.view === "mask" ? "cut" : face.result === "cut" ? "mask" : "cut");
else face.setView(face.view === "tint" ? face.result : "tint");
}, { signal });
// The picture coming in changed (an edit made upstream): this shows the
// new one, the same way Show picture does.
face.reload = button("ausboss-touch-btn reload", "",
"Reload the picture: runs only this node, so a picture that changed upstream shows here.", showPicture, signal);
face.reload.innerHTML = RELOAD_ICON;
face.reload.setAttribute("aria-label", "Reload the picture");
tools.append(face.toolSwitch.root, face.sizeField, face.softField, el("div", "ausboss-touch-gap"), face.reload, face.undo, face.clear, face.eye);
tools.append(face.toolSwitch.root, face.sizeField, face.softField, el("div", "ausboss-touch-gap"), face.undo, face.clear, face.eye);
newButton.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
File diff suppressed because it is too large Load Diff
+1 -15
View File
@@ -485,7 +485,7 @@ export function sizeChainTokens(chain) {
if (chain.resized) tokens.push({ label: "resize", text: size(chain.resized) });
const warnings = [];
if (Math.abs(chain.stretch) > STRETCH_WARNING) {
warnings.push(`Resize makes the picture ${(Math.abs(chain.stretch) * 100).toFixed(1)}% ${chain.stretch > 0 ? "wider" : "taller"}`);
warnings.push(`${(Math.abs(chain.stretch) * 100).toFixed(1)}% ${chain.stretch > 0 ? "wider" : "taller"}: each side rounds to ${chain.steps ?? 1} px`);
}
// Ahead of a resize, what Divisible by adds is only fill for the model to
// paint, and what it trims is picture lost: the Step already rounds the
@@ -762,20 +762,6 @@ export function centredPadding(values, crop, axis) {
return Math.abs(then.near - then.far) < Math.abs(now.near - now.far) ? moved : null;
}
// Center the crop within the rotated source and balance its output padding
// on the same axis. Keep the resolved extent even for an open-ended crop.
export function centredTransform(values, source, axis) {
const crop = resolveCrop(values, source);
const pads = centredPadding(values, crop, axis);
const extent = axis === "x" ? "width" : "height";
const spare = source[extent] - crop[extent];
if (Math.abs(spare - 2 * crop[axis]) <= 1) return pads;
const moved = { ...pads, [`crop_${axis}`]: Math.floor(spare / 2) };
const next = resolveCrop({ ...values, ...moved }, source);
if (next[extent] !== crop[extent]) moved[`crop_${extent}`] = crop[extent];
return moved;
}
// The bands on either side of the picture along an axis as the output has
// them (Divisible by's fill included), for the centre buttons to say
// where the picture sits.
+4 -26
View File
@@ -55,14 +55,6 @@ function installCss() {
style.id = CSS_ID;
style.textContent = `
.ausboss-transform-trim{flex:none;display:flex;flex-direction:column;gap:8px;min-width:0;padding:9px 10px 8px;border:1px solid #164b49;border-radius:8px;background:#0e1718;color:#cadddb;font:11px system-ui;box-sizing:border-box;overflow:hidden}
.ausboss-transform-trim-transport{display:flex;align-items:center;gap:6px;min-width:0}
.ausboss-transform-trim-transport .ausboss-transform-trim-rail{flex:1;min-width:0}
.ausboss-transform-trim-play{display:grid;place-items:center;flex:none;width:28px;height:24px;padding:0;border:1px solid #325151;border-radius:4px;background:#101819;color:#a6c3c0;cursor:pointer}
.ausboss-transform-trim-play::before{content:"";width:10px;height:12px;background:currentColor;clip-path:polygon(8% 0,100% 50%,8% 100%)}
.ausboss-transform-trim-play.is-playing::before{box-sizing:border-box;clip-path:none;background:transparent;border-inline:3px solid currentColor}
.ausboss-transform-trim-play:focus-visible{outline:1px solid #00b4aa;outline-offset:1px}
.ausboss-transform-trim-play:hover,.ausboss-transform-trim-play.is-playing{border-color:#00b4aa;color:#e5fffc}
.ausboss-transform-trim-play:disabled{opacity:.45;cursor:default}
.ausboss-transform-trim-rail{position:relative;height:28px;margin:0 6px;touch-action:none;cursor:pointer;user-select:none}
.ausboss-transform-trim-rail.is-disabled{opacity:.45;cursor:default}
.ausboss-transform-trim-rail:before{content:"";position:absolute;left:0;right:0;top:11px;height:6px;border-radius:4px;background:#293637}
@@ -126,7 +118,7 @@ const pct = (fraction) => `${(fraction * 100).toFixed(3)}%`;
// (the clip node) or playhead only
// onSeek(frame, settled) the playhead moved; settled on release
// onCommit() a stored value settled (undo point)
export function mountTransformTrim({ get, set, has = () => true, driven = () => false, number = (name, fallback) => get(name, fallback), outputRate, metadata, trim = true, isPlaying, onTogglePlayback, onSeek, onCommit }) {
export function mountTransformTrim({ get, set, has = () => true, driven = () => false, number = (name, fallback) => get(name, fallback), outputRate, metadata, trim = true, onSeek, onCommit }) {
installCss();
const root = el("div", "ausboss-transform-trim");
const rail = el("div", "ausboss-transform-trim-rail");
@@ -140,11 +132,6 @@ export function mountTransformTrim({ get, set, has = () => true, driven = () =>
if (trim) rail.append(span, kept, grip);
rail.append(playhead);
const transport = el("div", "ausboss-transform-trim-transport");
const play = el("button", "ausboss-transform-trim-play");
play.type = "button";
play.addEventListener("click", onTogglePlayback);
transport.append(play, rail);
const info = () => clipInfo(metadata());
const everyNth = () => Math.max(1, Math.trunc(Number(number("every_nth", get("every_nth", 1))) || 1));
const snapRule = () => String(get("frame_snap", "free"));
@@ -238,8 +225,6 @@ export function mountTransformTrim({ get, set, has = () => true, driven = () =>
// A Length drags the whole window, stopping where OUT meets the end.
parked = Math.max(0, Math.min(clampFrame(frame, clip), latestFirstFor(clip, current.requested ?? get("max_frames", 1), nth)));
set("end_seconds", 0);
} else if (current.mode === "wired" && current.latestStart != null && !countAtRunTime()) {
parked = Math.min(clampFrame(frame, clip), current.latestStart);
} else {
parked = setTrimFrame({ first: current.first, last: current.windowLast }, "start", frame, clip).first;
}
@@ -309,11 +294,6 @@ export function mountTransformTrim({ get, set, has = () => true, driven = () =>
const sync = () => {
const clip = info();
const playing = isPlaying();
play.setAttribute("aria-label", playing ? "Pause" : "Play");
play.title = playing ? "Pause on this frame" : trim ? "Play the selected clip" : "Play the video to pick a frame";
play.classList.toggle("is-playing", playing);
play.disabled = !clip.count;
const head = playheadFrame();
rail.classList.toggle("is-disabled", !clip.count);
playhead.style.left = pct(fractionOfFrame(head, clip, "center"));
@@ -363,9 +343,7 @@ export function mountTransformTrim({ get, set, has = () => true, driven = () =>
: "Drag IN to move the clip; OUT follows at the Length. Shift + arrow moves one frame."
: current.mode === "free" ? "Drag OUT to trim; it lands on the last frame the run keeps." : "Drag OUT to change the Length; IN stays.";
handle.setAttribute("aria-valuemin", "0");
const maxFrame = edge === "start" && current.mode === "wired" && !unknown
? current.latestStart ?? current.windowLast : Math.max(0, clip.count - 1);
handle.setAttribute("aria-valuemax", String(maxFrame));
handle.setAttribute("aria-valuemax", String(Math.max(0, clip.count - 1)));
handle.setAttribute("aria-valuenow", String(frame));
handle.setAttribute("aria-valuetext", `frame ${frame}, ${formatTimecode(frameTime(frame, clip))}`);
}
@@ -558,7 +536,7 @@ export function mountTransformTrim({ get, set, has = () => true, driven = () =>
label.append(controls.frame.root);
timeRow.append(label, readout);
}
root.append(transport, timeRow);
root.append(rail, timeRow);
// Length: off, OUT ends the clip; on, a frame count from IN (max_frames,
// end left open), so OUT follows IN and the count survives a new source.
@@ -603,7 +581,7 @@ export function mountTransformTrim({ get, set, has = () => true, driven = () =>
count.root.style.display = active ? "" : "none";
const step = snapStep(snapRule());
count.setStep(step, step);
count.set(blocked ? (current.requested ?? current.frames ?? 0) : (current.requested ?? get("max_frames", 1)));
count.set(blocked ? (current.frames ?? current.requested ?? 0) : (current.requested ?? get("max_frames", 1)));
disableControl(count, blocked, lockTip("end"));
const fills = lengthFillsClip(info(), current, everyNth());
hint.textContent = blocked ? `from ${wired}` : current.mode === "fixed" ? "exact (older setting)" : fills ? "= the whole clip. Shorten it to move IN" : active ? "" : "OUT ends the clip";
+2 -1
View File
@@ -45,7 +45,8 @@ export function videoSourceState(mode, video, localPath) {
};
}
// Images store the folder annotation in their image widget. Only video uses local_path.
// 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 ?? "");
+1 -18
View File
@@ -226,8 +226,6 @@ function isFloatWidget(widget) {
// of the card
// { group: "advanced", label: "More" } - a disclosure; later rows with
// group: "advanced" sit under it
// A numeric row may have readOnlyText(node, values): nonempty text replaces
// its control without changing its widget, socket, value or row height.
// Rows may also carry `mute: (values) => boolean` and `muteTitle`: a muted
// row stays in place but dims and stops taking input, with the title as its
// hover hint (a setting the current mode does not use).
@@ -293,28 +291,13 @@ export function mountWidgetCard(node, { rows, minWidth = 300, first = false, hid
onSettle: () => notifyAusbossChange(),
});
into.append(control.root);
// Display an unused setting honestly while keeping its saved value and socket.
const readOnly = row.readOnlyText ? el("span", "ausboss-card-readonly") : null;
if (readOnly) {
readOnly.title = row.readOnlyTitle ?? "";
readOnly.setAttribute("role", "status");
into.append(readOnly);
}
const syncReadOnly = (vals) => {
if (!readOnly) return;
const text = row.readOnlyText(node, vals) || "";
readOnly.textContent = text;
readOnly.style.display = text ? "" : "none";
control.root.style.display = text ? "none" : "";
};
if (typeof row.limits !== "function") return (vals) => { syncReadOnly(vals); control.set(Number(vals[name]) || 0); };
if (typeof row.limits !== "function") return (vals) => control.set(Number(vals[name]) || 0);
// Limits that come from somewhere else are read at every refresh: a wire
// made or cut changes them. The stored value follows the range, the way
// ComfyUI's own Primitive node follows the input it is wired to.
let shown = null;
const hint = el("span", "ausboss-card-range");
return (vals) => {
syncReadOnly(vals);
const outside = row.limits(node) ?? null;
const range = narrowedRange(widget?.options, outside);
const key = outside ? `${range.min}|${range.max}|${outside.step}` : "";
+4 -42
View File
@@ -7,7 +7,7 @@
// text, so every URL is filtered to http(s) and every string is coerced.
export const NOTE_VERSION = 1;
export const LAYOUTS = ["card", "banner", "tile"];
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";
@@ -166,14 +166,6 @@ export function noteIsEmpty(note) {
);
}
// A note shows its tile face when it is in tile layout and has anything to
// show. The node is then just a button: the note itself opens in a dialog,
// so there is no card inside the node whose height needs fitting.
export function noteShowsTileFace(note) {
if (!note || typeof note !== "object") return false;
return note.layout === "tile" && !noteIsEmpty(note);
}
// "ComfyUI/models/loras" - the folder hint under each group of rows.
export function folderPath(dir) {
if (!dir) return "ComfyUI/models";
@@ -252,7 +244,7 @@ export function noteFit({ natural, nodeHeight, chrome }) {
// renderer builds DOM nodes from it, so no user text ever meets innerHTML.
// ---------------------------------------------------------------------------
const INLINE_PATTERN = /(?:\[(teal|green|amber|glow)\]([^\n]+?)\[\/\1\]|\*\*[^*\n]+?\*\*|`[^`\n]+?`|\[[^\]\n]+?\]\([^)\s]+?\)|(?<![\w*])\*[^*\n]+?\*(?![\w*]))/;
const INLINE_PATTERN = /(\*\*[^*\n]+?\*\*|`[^`\n]+?`|\[[^\]\n]+?\]\([^)\s]+?\)|(?<![\w*])\*[^*\n]+?\*(?![\w*]))/;
export function parseInline(text) {
const spans = [];
@@ -265,8 +257,7 @@ export function parseInline(text) {
}
if (match.index > 0) spans.push({ type: "text", text: rest.slice(0, match.index) });
const token = match[0];
if (match[1]) spans.push({ type: "tone", tone: match[1], text: match[2] });
else if (token.startsWith("**")) spans.push({ type: "strong", text: token.slice(2, -2) });
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("](");
@@ -279,15 +270,6 @@ export function parseInline(text) {
return spans;
}
// Split unescaped pipes. Optional outer pipes just frame the row.
function tableCells(line) {
let row = line.trim();
if (!row.includes("|")) return [];
if (row.startsWith("|")) row = row.slice(1);
if (row.endsWith("|") && !row.endsWith("\\|")) row = row.slice(0, -1);
return row.split(/(?<!\\)\|/).map(cell => cell.trim().replace(/\\\|/g, "|"));
}
export function parseMarkdown(text) {
const lines = str(text, TEXT_LIMIT).replace(/\r\n?/g, "\n").split("\n");
const blocks = [];
@@ -310,8 +292,7 @@ export function parseMarkdown(text) {
list = null;
};
for (let index = 0; index < lines.length; index++) {
const rawLine = lines[index];
for (const rawLine of lines) {
const line = rawLine.trimEnd();
if (code) {
if (/^```/.test(line)) {
@@ -331,25 +312,6 @@ export function parseMarkdown(text) {
flushList();
continue;
}
if (line.includes("|") && lines[index + 1]?.includes("|")) {
const divider = tableCells(lines[index + 1]);
const headers = tableCells(line);
if (headers.length && headers.length === divider.length && divider.every(cell => /^:?-{3,}:?$/.test(cell))) {
flushParagraph();
flushList();
const align = divider.map(cell => cell.endsWith(":") ? (cell.startsWith(":") ? "center" : "right") : "left");
const rows = [];
index++;
while (lines[index + 1]?.includes("|")) {
const cells = tableCells(lines[index + 1]);
if (cells.length !== headers.length) break;
rows.push(cells.map(parseInline));
index++;
}
blocks.push({ type: "table", headers: headers.map(parseInline), align, rows });
continue;
}
}
const heading = /^(#{1,3})\s+(.*)$/.exec(line);
if (heading) {
flushParagraph();
+7 -3
View File
@@ -809,9 +809,13 @@ export function listedRows(switchRows, groupRows, settings) {
// ---------- switching ----------
// Each half selects its named state, even when that state is already set.
// Keep the first argument for callers using the existing helper signature.
export function flipTo(_state, side) {
// What a click on a row's pill asks for. The pill is one switch: a click
// anywhere on it flips it, on the "off" half as much as on the "on" half.
// Only while the row reads mixed (neither half lit) does the half that was
// clicked decide: `side` is true for the on half.
export function flipTo(state, side) {
if (state === "on") return false;
if (state === "off") return true;
return Boolean(side);
}
+1 -1
View File
@@ -10,7 +10,7 @@
// pointer so it can be selected and copied, which is half the point of a
// Show Text node.
import { app } from "../../../scripts/app.js";
import { app } from "/scripts/app.js";
import { isForeignRun } from "../shared/prompt_scope.mjs";
import { BRAND, chainCallback, keepDomWidgetWidthAuto } from "../shared/index.mjs";
import { copyToClipboard } from "../shared/clipboard.mjs";
+1 -1
View File
@@ -1,4 +1,4 @@
import { app } from "../../../scripts/app.js";
import { app } from "/scripts/app.js";
import { BRAND, chainCallback } from "../shared/index.mjs";
import {
SNAP_SCREEN_PX,
+1 -1
View File
@@ -6,7 +6,7 @@
// The pictures and the run come from shared/step_preview.mjs; the server
// side asks for them (nodes/_step_preview_helpers.py).
import { app } from "../../../scripts/app.js";
import { app } from "/scripts/app.js";
import { BRAND, chainCallback, keepDomWidgetWidthAuto } from "../shared/index.mjs";
import { WIDGET_FRAME, fillNodeHeight } from "../shared/panel_layout.mjs";
import { ensureStepCss, mountStepOverlay, queuedRuns, stopAllRuns, stopStepRun, watchSteps } from "../shared/step_preview.mjs";
+2 -2
View File
@@ -11,8 +11,8 @@
// before falling back to the canvas path, which is what keeps the two
// gestures apart.
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { notifyAusbossChange, showToast } from "../shared/index.mjs";
import {
isVideoFileName,
+11 -14
View File
@@ -4,14 +4,12 @@
// 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 { PANEL_LABELS } from "../shared/panel_controls.mjs";
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { chainCallback, notifyAusbossChange } from "../shared/index.mjs";
import { formatWidgetVisibility } from "../shared/save_video_formats.mjs";
import { hideInputsInDef } from "../shared/widget_visibility.mjs";
import { castChips } from "../shared/h3_cast_chips.mjs";
import { clipSetsDuration } from "../shared/h3_duration.mjs";
import { momentStrip } from "../shared/moment_strip.mjs";
import { mountWidgetCard } from "../shared/widget_card.mjs";
import { SOCKETLESS_CHOICE, SOCKETLESS_TEXT, commitWidgetValue, markSocketless, resetUnknownValues } from "../shared/widget_card_math.mjs";
@@ -212,7 +210,7 @@ const CARDS = {
AUSBOSS_NODES_ImageFolder: {
minWidth: 320, first: true, hide: ["pictures"], socketless: ["pictures"],
rows: [
{ widget: "source", label: "From", kind: "segment", labels: PANEL_LABELS.sources },
{ widget: "source", label: "From", kind: "segment" },
{ widget: "folder", label: "Folder", placeholder: "the folder itself",
button: { text: "Browse", title: "Choose a folder inside ComfyUI's input or output folder.",
onClick: (node, state) => node.__ausbossImageFolder?.browse(state?.root?.querySelector(".ausboss-card-btn")) } },
@@ -335,26 +333,25 @@ const CARDS = {
minWidth: 420,
hide: ["job_1", "job_2", "job_3", "job_4", "video_job"],
socketless: ["job_1", "job_2", "job_3", "job_4", "video_job"],
resetUnknown: { sound: "", music: "", clip_sound: false },
resetUnknown: { sound: "", music: "" },
rows: [
{ section: "What happens" },
{
widget: "script", kind: "textarea", height: 118, grow: true,
placeholder: "Type what happens, one line for each thing. Picture 1 is @1. A blank line cuts to a new place.\n\nSwap: leave empty, or name who goes. Optional new look on its own line: @1 looks like a woman with blonde hair and a black hoodie.",
placeholder: "@1 walks through a crowded night market.\n\n@1 stands on a rooftop in the rain.\n\nOne line for each thing that happens. A blank line cuts to a new place. @1 is Picture 1.",
},
{ panel: momentStrip, height: 76 },
{ widget: "sound", label: "Sound", placeholder: "rain on a window" },
{ widget: "music", label: "Music", placeholder: "slow piano: music makes it louder" },
{ section: "Pictures and settings" },
{ panel: castChips, height: 30 },
{ widget: "seconds", label: "Length", suffix: "s", readOnlyText: (node, values) => clipSetsDuration(node, values) ? "From the clip" : "", readOnlyTitle: "This video job uses the incoming clip, so Length is not used." },
{ widget: "seconds", label: "Length", suffix: "s" },
{ widget: "camera", label: "Camera", kind: "select" },
{ widget: "look", label: "Look", kind: "select" },
{ group: "more", label: "More" },
{ widget: "song_start", label: "Song starts at", suffix: "s", decimals: 1, trimZeros: true, group: "more" },
{ widget: "cut_on_beat", label: "Moments on the beat", group: "more" },
{ widget: "language", label: "Language", group: "more" },
{ widget: "clip_sound", label: "Clip's own sound", group: "more" },
],
},
// Saved by a 1.x release, these two hold that empty value too and open
@@ -380,7 +377,7 @@ const CARDS = {
minWidth: 320, first: true, hide: ["upload"],
rows: [
{ widget: "video", label: "Source", kind: "select", preview: { kind: "video", url: viewUrl },
button: { text: PANEL_LABELS.load, title: "Upload a video into ComfyUI's input folder", onClick: (node) => node.widgets?.find((w) => w.name === "upload")?.callback?.() } },
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" },
@@ -392,7 +389,7 @@ const CARDS = {
minWidth: 280, first: true, hide: ["upload"],
rows: [
{ widget: "audio", label: "Source", kind: "select",
button: { text: PANEL_LABELS.load, title: "Upload a sound file into ComfyUI's input folder", onClick: (node) => node.widgets?.find((w) => w.name === "upload")?.callback?.() } },
button: { text: "Upload", title: "Upload a sound file into ComfyUI's input folder", onClick: (node) => node.widgets?.find((w) => w.name === "upload")?.callback?.() } },
{ widget: "max_seconds", label: "Max length", suffix: "s", decimals: 1, trimZeros: true,
title: "The longest the sound may be. Anything past it is cut off and shows in red. 0 means no limit." },
],
@@ -401,7 +398,7 @@ const CARDS = {
minWidth: 340, first: true, hide: ["upload"],
rows: [
{ widget: "image", label: "Source", kind: "select", preview: { kind: "image", url: viewUrl }, linkedBy: ["source_image"],
button: { text: PANEL_LABELS.load, title: "Upload an image into ComfyUI's input folder", onClick: (node) => node.widgets?.find((w) => w.name === "upload")?.callback?.() } },
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") },
@@ -416,7 +413,7 @@ const CARDS = {
],
},
AUSBOSS_NODES_SaveVideo: {
// step_preview is the Preview steps switch on the player (js/save_video).
// step_preview is the STEPS button on the player (js/save_video).
minWidth: 320, first: true, hide: ["step_preview"],
rows: [
{ widget: "filename_prefix", label: "Prefix", placeholder: "video" },
@@ -426,7 +423,7 @@ const CARDS = {
{ pair: ["fps", "crf"], label: "FPS · CRF", sep: "·", top: true, when: formatReads("crf") },
{ widget: "fps", label: "FPS", top: true, when: (values) => !formatReads("crf")(values) },
{ widget: "pingpong", label: "Ping-pong", onText: "forward, back", offText: "off" },
{ widget: "save_metadata", label: PANEL_LABELS.embed, onText: PANEL_LABELS.on, offText: PANEL_LABELS.off, when: formatReads("save_metadata"), title: "On embeds the workflow in the video file, so dropping the file back on the canvas restores the graph that made it." },
{ widget: "save_metadata", label: "Metadata", when: formatReads("save_metadata"), title: "On embeds the workflow in the video file, so dropping the file back on the canvas restores the graph that made it." },
],
},
};
+1 -1
View File
@@ -10,7 +10,7 @@
// The LoRA loader is deliberately absent: its single JSON widget already
// survives reordering.
import { app } from "../../../scripts/app.js";
import { app } from "/scripts/app.js";
import { chainCallback } from "../shared/index.mjs";
import {
captureWidgetDefaults,
+10 -199
View File
@@ -10,18 +10,14 @@
// 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 { setTileFace, TILE_FACE_CSS } from "../shared/tile_face.mjs";
import { NOTE_TYPES, serializeNoteIdentity } from "../shared/note_identity.mjs";
import { compactAction } from "../shared/panel_controls.mjs";
import { api } from "/scripts/api.js";
import { app } from "/scripts/app.js";
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
import { collectGraphNodes } from "../shared/appearance.mjs";
import { copyToClipboard } from "../shared/clipboard.mjs";
import { confirmDiscard } from "../shared/discard_prompt.mjs";
import { WIDGET_FRAME, fillNodeHeight, holdNodeMinHeight } from "../shared/panel_layout.mjs";
import { hideInputsInDef, hideWidget } from "../shared/widget_visibility.mjs";
import { suppressCoreImagePreview } from "../shared/core_preview.mjs";
import {
COMMON_FOLDERS,
INPUT_FOLDER,
@@ -35,7 +31,6 @@ import {
matchInstalled,
noteFit,
noteIsEmpty,
noteShowsTileFace,
normalizeNote,
packsFromGraph,
parseMarkdown,
@@ -46,6 +41,7 @@ import {
import { comboValues, commitWidgetValue } from "../shared/widget_card_math.mjs";
import { parseRows, serializeRows } from "../shared/lora_stack.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;
@@ -111,7 +107,6 @@ function ensureCss() {
const style = document.createElement("style");
style.id = CSS_ID;
style.textContent = `
${TILE_FACE_CSS}
.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;}
@@ -120,43 +115,6 @@ ${TILE_FACE_CSS}
.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-tile .ausboss-note-card{background:#080e10;border-color:#344446;border-left:3px solid var(--accent);}
.ausboss-note.is-tile .ausboss-note-scroll{display:flex;padding:0;}
.ausboss-note.is-tile .ausboss-note-head{display:flex;flex:1;min-width:0;padding:0;border:0;}
.ausboss-note-read{position:relative;display:flex;align-items:center;gap:14px;width:100%;padding:8px 60px 8px 17px;border:0;background:transparent;color:#e8ebe7;text-align:left;font:600 18px/1.25 "IBM Plex Sans",system-ui,sans-serif;overflow-wrap:anywhere;cursor:pointer;}
.ausboss-note-read svg{flex:none;width:25px;height:25px;fill:none;stroke:var(--accent);stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;}
.ausboss-note-read::after{content:"";position:absolute;right:18px;width:7px;height:7px;border:solid #a2b6b6;border-width:2px 2px 0 0;transform:rotate(45deg);}
.ausboss-note-read:hover{background:#10191b;}
.ausboss-note-read:focus-visible{outline:2px solid var(--accent);outline-offset:-3px;}
.ausboss-note.is-tile .ausboss-note-edit{top:50%;right:32px;transform:translateY(-50%);opacity:.65;pointer-events:auto;}
.ausboss-note.is-tile .ausboss-note-head:is(:hover,:focus-within) .ausboss-note-edit{opacity:1;pointer-events:auto;}
.ausboss-note-reader{box-sizing:border-box;width:min(640px,94vw);max-height:86vh;overflow:auto;margin:auto;padding:0 0 24px;border:1px solid #365154;border-radius:12px;background:#161f21;color:#e8ebe7;box-shadow:0 18px 64px #0008;font:15px/1.55 "IBM Plex Sans",system-ui,sans-serif;}
.ausboss-note-reader::backdrop{background:#06090ab8;}
.ausboss-note-reader-head{padding:20px 24px 16px;border-top:3px solid var(--accent);background:#080e10;}
.ausboss-note-reader-head button{float:right;margin:0 0 6px 10px;}
.ausboss-note-reader-head .ausboss-compact-action{height:32px;padding:0 12px;font-size:13px;}
.ausboss-note-reader-head h2{margin:0;font:650 26px/1.2 "IBM Plex Sans",system-ui,sans-serif;color:#ecf4f4;overflow-wrap:anywhere;}
.ausboss-note-reader-head p{margin:6px 0 0;color:#b4c5c5;overflow-wrap:anywhere;}
.ausboss-note-reader .ausboss-note-section{padding:14px 24px 0;}
.ausboss-note-reader .ausboss-note-body h2{font-size:19px;margin:18px 0 6px;}
.ausboss-note-reader .ausboss-note-body h1{font-size:23px;}
.ausboss-note-reader .ausboss-note-body li::marker{color:#6fc0aa;}
.ausboss-note-table-scroll{max-width:100%;overflow-x:auto;margin:10px 0 14px;border:1px solid #344649;border-radius:6px;}
.ausboss-note-table-scroll table{width:100%;border-collapse:collapse;font:inherit;}
.ausboss-note-table-scroll :is(th,td){padding:6px 10px;vertical-align:top;overflow-wrap:anywhere;}
.ausboss-note-table-scroll th{background:#102327;color:#ecf4f4;font:600 .88em/1.45 "IBM Plex Mono",ui-monospace,monospace;}
.ausboss-note-table-scroll td{border-top:1px solid #344649;}
.ausboss-note-table-scroll tbody tr:nth-child(even){background:#ffffff04;}
.ausboss-note-tone-teal{color:#00b4aa;}
.ausboss-note-tone-green{color:#6fc0aa;}
.ausboss-note-tone-amber{color:#f5b53d;}
.ausboss-note-tone-glow{color:#a8fff5;text-shadow:0 0 7px #00b4aa95,0 0 15px #00b4aa45;}
.ausboss-note-mdtools{display:flex;align-items:center;flex-wrap:wrap;gap:4px;}
.ausboss-note-mdtools button{height:24px;padding:0 9px;border:1px solid #344649;border-radius:4px;background:#101819;font:600 12px/1 "IBM Plex Sans",system-ui,sans-serif;cursor:pointer;}
.ausboss-note-mdtools button:hover{border-color:#6a928e;}
.ausboss-note-mdpreview{border:1px solid #344649;border-radius:6px;padding:7px 10px;}
.ausboss-note-mdpreview summary{cursor:pointer;color:#9fcec9;}
.ausboss-note-mdpreview[open] summary{margin-bottom:10px;}
.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;}
@@ -179,7 +137,6 @@ ${TILE_FACE_CSS}
.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-reader .ausboss-note-body pre,.ausboss-note-reader .ausboss-note-body pre code{white-space:pre-wrap;overflow-wrap:anywhere;}
.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;}
@@ -258,23 +215,6 @@ function el(tag, className, text) {
return node;
}
function tileIcon(note) {
// A how-to can include models too. Only use the download icon for a
// download title or a note whose only content is its model list.
const downloads = /^(?:download models?|model downloads?|downloads?|models?)$/i.test(note.title.trim())
|| (note.models.length > 0 && !note.body.trim() && !note.packs.length && !note.links.length);
const ns = "http://www.w3.org/2000/svg";
const icon = document.createElementNS(ns, "svg");
icon.setAttribute("viewBox", "0 0 24 24");
icon.setAttribute("aria-hidden", "true");
const path = document.createElementNS(ns, "path");
path.setAttribute("d", downloads
? "M12 3v12m-5-5 5 5 5-5M3 16v5h18v-5"
: "M2.5 4.5c3.5-1 6.5-.5 9.5 1.5 3-2 6-2.5 9.5-1.5v15c-3.5-1-6.5-.5-9.5 1.5-3-2-6-2.5-9.5-1.5zM12 6v15");
icon.append(path);
return icon;
}
function externalLink(url, className, text) {
const link = el("a", className, text);
link.href = url;
@@ -291,7 +231,6 @@ function spansInto(parent, spans) {
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 === "tone") parent.append(el("span", `ausboss-note-tone-${span.tone}`, span.text));
else if (span.type === "link") parent.append(externalLink(span.url, "", span.text));
else parent.append(document.createTextNode(span.text));
}
@@ -303,24 +242,6 @@ function markdownInto(parent, text) {
const heading = el(`h${block.level}`);
spansInto(heading, block.spans);
parent.append(heading);
} else if (block.type === "table") {
const wrap = el("div", "ausboss-note-table-scroll");
const grid = el("table");
const addRow = (group, cells, header) => {
const row = group.insertRow();
cells.forEach((spans, index) => {
const cell = el(header ? "th" : "td");
if (header) cell.scope = "col";
cell.style.textAlign = block.align[index];
spansInto(cell, spans);
row.append(cell);
});
};
addRow(grid.createTHead(), block.headers, true);
const rows = grid.createTBody();
for (const cells of block.rows) addRow(rows, cells, false);
wrap.append(grid);
parent.append(wrap);
} else if (block.type === "list") {
const list = el(block.ordered ? "ol" : "ul");
for (const item of block.items) {
@@ -503,9 +424,7 @@ function render(state) {
const { note } = state;
const root = state.root;
root.classList.toggle("is-banner", note.layout === "banner");
root.classList.toggle("is-tile", note.layout === "tile" && !noteIsEmpty(note));
setTileFace(state.node, root, root.classList.contains("is-tile"));
setBadgesHidden(state.node, note.layout !== "card" && !noteIsEmpty(note));
setBadgesHidden(state.node, note.layout === "banner" && !noteIsEmpty(note));
root.style.setProperty("--accent", note.accent || BRAND);
const scroll = state.scroll;
scroll.textContent = "";
@@ -529,52 +448,12 @@ function render(state) {
state.domWidget.options.getMinHeight = () => CARD_MIN_HEIGHT + WIDGET_FRAME;
return;
}
if (note.layout === "tile") {
const read = el("button", "ausboss-note-read");
read.append(tileIcon(note), el("span", "", note.title || "Workflow info"));
read.type = "button";
read.setAttribute("aria-haspopup", "dialog");
read.onclick = () => {
const dialog = el("dialog", "ausboss-note-reader");
dialog.setAttribute("aria-label", note.title || "Workflow info");
dialog.style.setProperty("--accent", note.accent || BRAND);
const close = compactAction("Close");
close.onclick = () => dialog.close();
const editNote = compactAction("Edit", "Edit this note");
editNote.onclick = () => {
dialog.close();
openEditor(state);
};
const header = el("header", "ausboss-note-reader-head");
header.append(close, editNote, el("h2", "", note.title || "Workflow info"));
if (note.subtitle) header.append(el("p", "", note.subtitle));
if (note.author) header.append(el("p", "", `by ${note.author}`));
dialog.append(header);
renderSections({ note, rows: [] }, dialog);
document.body.append(dialog);
state.reader = dialog;
dialog.addEventListener("close", () => {
dialog.remove();
state.reader = null;
if (!openDialog) read.focus();
}, {once:true});
dialog.showModal();
};
head.replaceChildren(read, edit);
state.domWidget.options.getMinHeight = () => 42 + 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;
renderSections(state, scroll);
}
function renderSections(state, scroll) {
const { note } = state;
if (note.body.trim()) {
const section = el("div", "ausboss-note-section");
const body = el("div", "ausboss-note-body");
@@ -676,7 +555,7 @@ async function checkModels(state, fresh) {
}
}
}));
if (state.domWidget && generation === state.checkGeneration) fitNote(state);
if (generation === state.checkGeneration) fitNote(state);
}
// ---------------------------------------------------------------------------
@@ -711,12 +590,6 @@ function naturalHeight(state, width) {
// empty space until someone drags it shorter.
function fitNote(state) {
if (!classicCanvas()) return;
// A tile face is a button, not a card: fitting it would measure the note
// behind the button and grow a new tile straight back to full height.
if (noteShowsTileFace(state.note)) {
state.fitHeight = undefined;
return;
}
const { node, root, domWidget } = state;
const width = node.size[0] - WIDGET_FRAME;
if (!(width > 0)) return;
@@ -895,7 +768,7 @@ async function openEditor(state) {
let layout = draft.layout;
const seg = el("div", "ausboss-note-seg");
const segButtons = LAYOUTS.map((option) => {
const button = el("button", option === layout ? "on" : "", ({ card: "Card", banner: "Banner", tile: "Info tile" })[option]);
const button = el("button", option === layout ? "on" : "", option === "card" ? "Card" : "Banner");
button.type = "button";
button.addEventListener("click", () => {
layout = option;
@@ -909,7 +782,7 @@ async function openEditor(state) {
field("Title", title),
field("Author", author),
field("Subtitle", subtitle),
field("Layout", seg, "Banner shows the title. Info tile opens the full note when clicked."),
field("Layout", seg, "Banner shows only the title, subtitle and author - a label for a column or stage."),
field("Accent color", accent, "Hex like #00b4aa; empty keeps the pack teal."),
);
form.append(grid);
@@ -917,48 +790,7 @@ async function openEditor(state) {
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,
"Use pipe tables and [teal]text[/teal], [green], [amber], or [glow] with a matching closing tag."));
const formatTools = el("div", "ausboss-note-mdtools");
formatTools.setAttribute("role", "group");
formatTools.setAttribute("aria-label", "Format selected text or insert a table");
for (const label of ["Teal", "Green", "Amber", "Glow", "Table"]) {
const tone = label.toLowerCase();
const isTable = tone === "table";
const button = el("button", isTable ? "" : `ausboss-note-tone-${tone}`, label);
button.type = "button";
button.title = isTable ? "Insert a Markdown table" : `Apply ${label.toLowerCase()} to the selection`;
button.addEventListener("click", () => {
const { selectionStart: start, selectionEnd: end, value } = bodyBox;
if (isTable) {
const gap = start && !value.slice(0, start).endsWith("\n\n") ? "\n\n" : "";
bodyBox.setRangeText(gap + "| Setting | Value |\n| --- | --- |\n| Name | Value |\n\n", start, start, "end");
} else {
const selection = value.slice(start, end) || "text";
// Each tag styles one line. A selection can span multiple paragraphs.
const styled = selection.split("\n").map(line => line ? `[${tone}]${line}[/${tone}]` : "").join("\n");
bodyBox.setRangeText(styled, start, end, "end");
if (start === end) bodyBox.setSelectionRange(start + tone.length + 2, start + tone.length + 2 + selection.length);
}
bodyBox.focus();
bodyBox.dispatchEvent(new Event("input", { bubbles: true }));
});
formatTools.append(button);
}
form.append(formatTools);
const preview = el("details", "ausboss-note-mdpreview");
const sample = el("div", "ausboss-note-body");
preview.append(el("summary", "", "Preview formatting"), sample);
const updatePreview = () => {
if (!preview.open) return;
sample.style.setProperty("--accent", accent.value || BRAND);
sample.replaceChildren();
markdownInto(sample, bodyBox.value);
};
preview.addEventListener("toggle", updatePreview);
bodyBox.addEventListener("input", updatePreview);
accent.addEventListener("input", updatePreview);
form.append(preview);
form.append(field("How-to (Markdown)", bodyBox));
const models = table("models", MODEL_COLUMNS.map((column) => column.label), draft.models, (row) =>
MODEL_COLUMNS.map(({ key, placeholder }) => (key === "dir" ? folderSelect(row.dir, folderOptions) : input(row[key], placeholder))));
@@ -1059,15 +891,10 @@ async function openEditor(state) {
return;
}
} else next = readForm();
const enteringTile = next.layout === "tile" && state.note.layout !== "tile";
state.note = next;
state.valueWidget.value = serializeNote(next);
closeEditor();
render(state);
if (enteringTile) {
state.fitHeight = undefined;
state.node.setSize?.([state.node.size[0], 80]);
}
fitNote(state);
state.node.setDirtyCanvas?.(true, true);
notifyAusbossChange();
@@ -1121,11 +948,6 @@ function buildPanel(node) {
const widget = node.widgets?.find((item) => item.name === "note");
if (!widget) return null;
hideWidget(widget);
// A note never has output images of its own. The frontend files previews
// by node id, so one left over from another node can still land here, and
// in the classic canvas its widget asks for the picture's full height:
// the node grows to hold it and a tile stops starting as a tile.
suppressCoreImagePreview(node);
const root = el("div", "ausboss-note");
const card = el("div", "ausboss-note-card");
@@ -1177,18 +999,12 @@ function buildPanel(node) {
app.registerExtension({
name: "AusBoss.WorkflowNote",
beforeRegisterNodeDef(nodeType, nodeData) {
if (!NOTE_TYPES.has(nodeData?.name)) return;
if (nodeData?.name !== NODE_CLASS) return;
hideInputsInDef(nodeData, ["note"]);
chainCallback(nodeType.prototype, "onNodeCreated", function () {
buildPanel(this);
// A new tile starts as a button; onConfigure restores saved dimensions.
if (nodeData.name === "AUSBOSS_NODES_WorkflowNoteTile") this.setSize?.([380, 80]);
});
chainCallback(nodeType.prototype, "onSerialize", function (saved) {
const value = this.widgets?.find(widget => widget.name === "note")?.value;
serializeNoteIdentity(saved, normalizeNote(value).layout === "tile");
});
chainCallback(nodeType.prototype, "onConfigure", function (saved) {
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.
// The fit runs before ComfyUI snapshots the opened workflow, so
@@ -1198,10 +1014,6 @@ app.registerExtension({
if (!state) return;
state.note = normalizeNote(state.valueWidget.value);
render(state);
if (state.note.layout === "tile" && Number.isFinite(saved?.size?.[1])) {
state.fitHeight = undefined;
this.setSize?.([this.size[0], saved.size[1]]);
}
fitNote(state);
});
});
@@ -1216,7 +1028,6 @@ app.registerExtension({
});
chainCallback(nodeType.prototype, "onRemoved", function () {
closeEditor();
this.__ausbossNote?.reader?.close();
this.__ausbossNote?.resizeObserver?.disconnect();
this.__ausbossNote = null;
});
+8 -5
View File
@@ -32,7 +32,7 @@
// while the canvas is busy, and always one after the last redraw. The
// decisions live in js/shared/workflow_switches.mjs under node:test.
import { app } from "../../../scripts/app.js";
import { app } from "/scripts/app.js";
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange, showToast } from "../shared/index.mjs";
import { keepKeyInField } from "../shared/canvas_passthrough.mjs";
import { holdNodeMinWidth, holdVueNodeMinWidth, pinVuePanelHeight } from "../shared/panel_layout.mjs";
@@ -349,7 +349,8 @@ function applyPlan(state, plan, values = []) {
notifyAusbossChange();
}
// Each half sets the state it names; clicking the selected half leaves it set.
// A click on a row's pill. The pill is one switch, so either half flips it;
// `side` (true for the on half) only decides while the row reads mixed.
function switchRow(state, key, side) {
const { settings, rows } = read(state);
const target = rows.find((row) => rowKey(row) === key);
@@ -834,7 +835,7 @@ function buildRow(state, key, made) {
const chip = el("span", "ausboss-ws-chip");
const title = el("span", "ausboss-ws-title");
const count = el("span", "ausboss-ws-count");
// Separate off and on targets, backed by the same saved switch.
// One switch drawn as two halves: a click on either half flips it.
const pill = el("div", "ausboss-card-seg ausboss-card-bool ausboss-ws-pill");
pill.setAttribute("role", "group");
const offButton = button("", "off");
@@ -939,8 +940,10 @@ function buildRow(state, key, made) {
offNodes.length > 0 && `${settings.off === "mute" ? "mute" : "bypass"} what runs only while it is off`,
settings.exclusive && "switch the other rows off",
]);
offButton.title = turnOff;
onButton.title = turnOn;
// Either half flips the switch, so both say what the next click does;
// a mixed row goes to the half that is clicked.
offButton.title = data.state === "off" ? `Click: ${turnOn}` : data.state === "on" ? `Click: ${turnOff}` : turnOff;
onButton.title = data.state === "off" ? `Click: ${turnOn}` : data.state === "on" ? `Click: ${turnOff}` : turnOn;
};
return { row, title, name, sync };
}
-28
View File
@@ -1583,33 +1583,5 @@
"name": "秒数"
}
}
},
"AUSBOSS_NODES_CalloutTile": {
"display_name": "标注卡片 🆎",
"description": "紧凑的标题或彩色链接按钮。双击编辑,拖动角落调整大小。旧版节点包会将它显示为缺失节点,请更新 ComfyUI-AusBoss。"
},
"AUSBOSS_NODES_WorkflowNoteTile": {
"display_name": "工作流说明卡片 🆎",
"description": "点击打开完整说明的小按钮。点击铅笔编辑,拖动角落调整大小。旧版节点包会将它显示为缺失节点,请更新 ComfyUI-AusBoss。"
},
"AUSBOSS_NODES_CameraMove": {
"display_name": "镜头移动 🆎",
"description": "移动视频的镜头。在节点画面上拖动来转动镜头,捏合或滚轮拉远推近,并在时间轴上设置关键帧。原镜头从未拍到的地方显示为粉色,交给 CrossView-Warp LoRA 补画。需要 MoGe Inference 的深度。",
"inputs": {
"moge_geometry": {
"name": "MoGe 几何"
},
"camera": {
"name": "镜头关键帧"
}
},
"outputs": {
"0": {
"name": "引导"
},
"1": {
"name": "空洞"
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More