Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b0baf351a | ||
|
|
50389b0cdb | ||
|
|
28dd526447 | ||
|
|
abf6cecd52 | ||
|
|
12e7d8c33f | ||
|
|
de95db228c | ||
|
|
93f64d07d7 | ||
|
|
b3a3d7a8ca | ||
|
|
c1d6136663 | ||
|
|
515b9ea4bd |
@@ -11,6 +11,7 @@ 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:
|
||||
@@ -28,6 +29,7 @@ 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:
|
||||
@@ -44,6 +46,7 @@ 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")
|
||||
@@ -84,3 +87,33 @@ 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
|
||||
|
||||
+28
-2
@@ -4,6 +4,32 @@ All notable changes to ComfyUI-AusBoss are documented here.
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 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
|
||||
@@ -45,8 +71,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, such as
|
||||
Pixaroma's Align, AusBoss leaves the dragging to it.
|
||||
a node does not snap yet. If another pack's snapping is on, 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
|
||||
|
||||
+10
-1
@@ -198,6 +198,11 @@ 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
|
||||
@@ -340,7 +345,11 @@ 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`).
|
||||
Contributors never change `version`.
|
||||
After a successful upload, the same workflow tags that commit and creates a
|
||||
GitHub Release with the full changelog section. The README badge follows 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`.
|
||||
|
||||
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
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
<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">
|
||||
<!-- 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">
|
||||
<!-- The publish workflow creates a GitHub Release for each version.
|
||||
This badge follows it; release_preflight.py checks the source. -->
|
||||
<a href="https://github.com/ausboss/ComfyUI-AusBoss/releases/latest"><img src="https://img.shields.io/github/v/release/ausboss/ComfyUI-AusBoss?label=release&color=00b4aa&style=flat-square" alt="Latest release"></a>
|
||||
<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>
|
||||
@@ -66,7 +65,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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -176,7 +175,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 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
Binary file not shown.
|
After 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, so Krea 2 knows where to paint. The plain crop goes along too."
|
||||
"text": "The painted area, grown a little, is filled with flat gray. Krea 2 never sees what you are replacing."
|
||||
},
|
||||
"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. Painting starts from the plain crop, inside the noise mask: the painted area, widened by 24 px."
|
||||
"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."
|
||||
},
|
||||
"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. Tone match is fully on, so the new area is adjusted to your picture's tone."
|
||||
"Blend in fades the model's picture into yours just inside its edge, with no tone shift. The new area stays as painted."
|
||||
],
|
||||
"widgets_values_named": {
|
||||
"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."
|
||||
"text": "Blend in fades the model's picture into yours just inside its edge, with no tone shift. The new area stays as painted."
|
||||
},
|
||||
"color": "#1f2c38",
|
||||
"bgcolor": "#2a3b4a"
|
||||
|
||||
@@ -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? 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\"}]}"
|
||||
"{\"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\"}]}"
|
||||
],
|
||||
"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? 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\"}]}"
|
||||
"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\"}]}"
|
||||
},
|
||||
"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- 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\"}]}"
|
||||
"{\"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\"}]}"
|
||||
],
|
||||
"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- 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\"}]}"
|
||||
"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\"}]}"
|
||||
},
|
||||
"color": "#14424d",
|
||||
"bgcolor": "#161f21"
|
||||
|
||||
@@ -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.constructor?.title_mode === (globalThis.LiteGraph?.NO_TITLE ?? 1);
|
||||
return (node.title_mode ?? node.constructor?.title_mode) === (globalThis.LiteGraph?.NO_TITLE ?? 1);
|
||||
}
|
||||
|
||||
function openHelpCard(node, event) {
|
||||
@@ -210,9 +210,8 @@ 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 (!showBadge(this.size?.[0] ?? 0, this.flags?.collapsed)) return;
|
||||
if (isTitleless(this) || !showBadge(this.size?.[0] ?? 0, this.flags?.collapsed)) return;
|
||||
const [x, y] = badgeCenter(this.size[0]);
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
@@ -228,7 +227,7 @@ function installHelpBadge(node) {
|
||||
ctx.restore();
|
||||
});
|
||||
chainHandler(node, "onMouseDown", function (event, pos) {
|
||||
if (!showBadge(this.size?.[0] ?? 0, this.flags?.collapsed)) return false;
|
||||
if (isTitleless(this) || !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
|
||||
|
||||
@@ -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;
|
||||
|
||||
+57
-6
@@ -6,7 +6,8 @@
|
||||
// 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 { app } from "../../../scripts/app.js";
|
||||
import { setTileFace, TILE_FACE_CSS } from "../shared/tile_face.mjs";
|
||||
import { BRAND, chainCallback, keepDomWidgetWidthAuto } from "../shared/index.mjs";
|
||||
import {
|
||||
ARROW_ANGLE,
|
||||
@@ -37,6 +38,10 @@ 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';
|
||||
|
||||
@@ -55,6 +60,7 @@ 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;}
|
||||
@@ -63,6 +69,14 @@ function ensureCss() {
|
||||
.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-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;}
|
||||
@@ -177,6 +191,18 @@ 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";
|
||||
@@ -222,6 +248,10 @@ 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));
|
||||
const blocks = parseBlocks(state.valueWidget.value);
|
||||
state.inner.replaceChildren();
|
||||
state.empty = blocks.length === 0;
|
||||
@@ -319,7 +349,9 @@ function fit(state) {
|
||||
inner.style.fontSize = `${px}px`;
|
||||
return inner.offsetHeight <= height && inner.scrollWidth <= width + 1;
|
||||
};
|
||||
inner.style.fontSize = `${fitFontSize(fits)}px`;
|
||||
const size = fitFontSize(fits);
|
||||
const social = card.dataset.tile === "youtube" || card.dataset.tile === "discord";
|
||||
inner.style.fontSize = `${social ? Math.min(size, 16) : size}px`;
|
||||
}
|
||||
|
||||
function markChanged() {
|
||||
@@ -499,16 +531,17 @@ 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: () => PANEL_MIN_HEIGHT + WIDGET_FRAME,
|
||||
getMinHeight: panelHeight,
|
||||
});
|
||||
keepDomWidgetWidthAuto(domWidget);
|
||||
domWidget.serialize = false;
|
||||
fillNodeHeight(domWidget, {
|
||||
minWidth: PANEL_MIN_WIDTH,
|
||||
minHeight: () => PANEL_MIN_HEIGHT + WIDGET_FRAME,
|
||||
minHeight: panelHeight,
|
||||
minNodeSize: [PANEL_MIN_WIDTH, PANEL_MIN_HEIGHT + 40],
|
||||
exactMinWidth: true,
|
||||
});
|
||||
@@ -516,6 +549,9 @@ 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));
|
||||
@@ -551,11 +587,15 @@ app.registerExtension({
|
||||
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
||||
buildPanel(this);
|
||||
});
|
||||
chainCallback(nodeType.prototype, "onConfigure", function () {
|
||||
chainCallback(nodeType.prototype, "onConfigure", function (saved) {
|
||||
// Saved values land after onNodeCreated; show them.
|
||||
queueMicrotask(() => {
|
||||
const state = buildPanel(this);
|
||||
if (state) renderText(state);
|
||||
if (!state) return;
|
||||
renderText(state);
|
||||
if (state.card.dataset.tile && Number.isFinite(saved?.size?.[1])) {
|
||||
this.setSize?.([this.size[0], saved.size[1]]);
|
||||
}
|
||||
});
|
||||
});
|
||||
chainCallback(nodeType.prototype, "onDblClick", function () {
|
||||
@@ -565,6 +605,17 @@ 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 () {
|
||||
|
||||
@@ -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
@@ -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
@@ -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";
|
||||
|
||||
@@ -7,6 +7,10 @@ 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, drag the tile to move it, or right-click **Edit callout text** to change the label and destination. Choose **Plain callout** to return to the standard style with the current text.
|
||||
|
||||
## Writing
|
||||
|
||||
- Double-click the note, or press **Edit**, to type. Click outside the box to
|
||||
|
||||
@@ -64,6 +64,7 @@ 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
|
||||
|
||||
@@ -89,13 +89,22 @@ 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. You can leave the box empty.
|
||||
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.
|
||||
- **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.
|
||||
|
||||
Swap and new voice ignore **Length** and keep the video's own length.
|
||||
|
||||
## What you can type
|
||||
|
||||
@@ -10,7 +10,7 @@ original back over the result.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Pick a picture under **Image source**, or click **Upload**. Dropping a
|
||||
1. Choose a file above the preview, or click **Load**. 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,22 +28,25 @@ uses what the node shows.
|
||||
|
||||
From top to bottom:
|
||||
|
||||
- **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
|
||||
- **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
|
||||
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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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, Divisible by, Resize and a lit
|
||||
(rotation, crop and padding); Fill, Feather, Align, Resize and a lit
|
||||
ratio stay.
|
||||
|
||||
### Ratios, Fit and the padlock
|
||||
@@ -51,8 +54,7 @@ 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.
|
||||
**Fit** only acts on a lit ratio, so it is dimmed and says "pick a ratio first"
|
||||
until you tap one.
|
||||
You can set **Fit to ratio** before picking a ratio. If a ratio is already lit, switching between Crop and Pad applies it immediately.
|
||||
|
||||
- **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
|
||||
@@ -85,11 +87,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.
|
||||
|
||||
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.
|
||||
**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 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
|
||||
@@ -112,9 +114,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.
|
||||
|
||||
### Divisible by
|
||||
### Align
|
||||
|
||||
**Divisible by** (`canvas_multiple`) makes the width and height divide evenly by
|
||||
**Align** (`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.
|
||||
|
||||
@@ -130,24 +132,23 @@ 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 **Divisible by** at work. An
|
||||
that changes nothing is left out, and "round to" is **Align** at work. An
|
||||
amber line warns when rounding each side to the Step stretches the picture by
|
||||
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
|
||||
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
|
||||
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. With Resize on its row is always on the node, with
|
||||
text or empty, so the controls under it stay where they are.
|
||||
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.
|
||||
|
||||
### 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 **Divisible by** stay.
|
||||
Fill, Feather and **Align** stay.
|
||||
|
||||
## Drawing a mask
|
||||
|
||||
@@ -167,8 +168,7 @@ 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 at the top right of the image
|
||||
source box and turn off **Show the mask on the picture**. The choice applies
|
||||
them too. To hide the teal, click the gear next to **Load** 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, Divisible by and a **Center** row.
|
||||
Fill, Feather, Align 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 Divisible by; the picture,
|
||||
clears rotation, crop, padding, fill, feather and Align; 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. **Divisible by** and the resize **Step** can slightly change
|
||||
resize settings. **Align** 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 **Divisible by** rounds it. With
|
||||
Divisible by on, **Center** counts the fill it adds, so the bands you see
|
||||
shape it keeps is the canvas before **Align** rounds it. With
|
||||
Align 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. **Divisible by** on the Crop + Rotate + Pad nodes and
|
||||
longer spans it. **Align** 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.
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ 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
|
||||
@@ -189,9 +188,7 @@ 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. 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
|
||||
nothing. 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.
|
||||
@@ -256,7 +253,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 and stacks beyond the row limit leave the sources active
|
||||
- linked LoRA settings leave the sources active
|
||||
- already-bypassed or muted loaders contribute no rows
|
||||
- a toast summarizes what was imported, bypassed, remapped, and skipped
|
||||
|
||||
|
||||
@@ -8,6 +8,14 @@ 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.
|
||||
|
||||
@@ -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`): **yes** stores the prompt and
|
||||
- **Embed workflow** (`save_metadata`): **On** stores the prompt and
|
||||
workflow in the file (PNG text chunks, EXIF in webp and jxl) so it drags
|
||||
back into ComfyUI; **no** writes a clean file for sharing or datasets.
|
||||
back into ComfyUI; **Off** 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
**STEPS** (`step_preview`, off by default) shows the clip's first frame in
|
||||
**Steps** above the player (`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 beside it. Stop a clip that is going wrong after the first steps
|
||||
button on the player. 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.
|
||||
- **Metadata** (`save_metadata`): On by default. Turn it off to share a file
|
||||
- **Embed workflow** (`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 **Metadata** row disappears for `gif` and `webp`,
|
||||
*ignored* above), and the **Embed workflow** 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
|
||||
|
||||
@@ -11,21 +11,22 @@ generated-area `mask`, a `stitcher` for Stitch Inpaint, the selected
|
||||
|
||||
## Source and frame
|
||||
|
||||
- **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
|
||||
- **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
|
||||
drop a video file onto the node. Picking another video keeps Fill, Feather,
|
||||
**Divisible by**, the Resize settings and the lit ratio button (the new frame is
|
||||
**Align**, 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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Playhead**: The rail under the node's preview is the timeline: press or drag it to
|
||||
- **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
|
||||
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.
|
||||
|
||||
@@ -33,19 +34,19 @@ The editor has the same rail full width, plus first/last, ±1, ±25, ±50, ±100
|
||||
|
||||
## Transform and outputs
|
||||
|
||||
Rotation, crop, padding, feathering, fill, **Divisible by**, the megapixel resize, gestures, `image`, and `mask` match the image node. The output batch contains one frame.
|
||||
Rotation, crop, padding, feathering, fill, **Align**, 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
|
||||
put the frame in the middle, side to side or top to bottom. Both work as on the
|
||||
center the crop and balance any padding, 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,
|
||||
**Divisible by** and the playhead stay. **Divisible by** makes the width and
|
||||
**Align** and the playhead stay. **Align** 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.
|
||||
|
||||
@@ -11,33 +11,20 @@ model that paints black regions.
|
||||
|
||||
## Source and window
|
||||
|
||||
- **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
|
||||
- **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
|
||||
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, **Divisible by**, Resize, Every nth, Frames for, Length - and
|
||||
settings - Fill, Feather, **Align**, 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.
|
||||
- **Timeline**: The rail on the node and in the editor is one timeline. Press or
|
||||
- **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
|
||||
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
|
||||
@@ -53,6 +40,21 @@ 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
|
||||
@@ -80,8 +82,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, 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
|
||||
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
|
||||
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
|
||||
@@ -92,31 +94,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
|
||||
dimmed until a ratio is lit. The orientation button turns the shape on its side
|
||||
ready to set before picking a ratio. 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, **Divisible by** and the timeline stay.
|
||||
fill, feather, **Align** and the timeline stay.
|
||||
**Reset transform** in the editor resets rotation, crop, padding, fill, feather and
|
||||
**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
|
||||
**Align**; it keeps the source, current frame, trim, Length, resize and stitch
|
||||
settings. **Align** 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.
|
||||
|
||||
Video Upload and file drop use a streaming route into ComfyUI's input folder,
|
||||
The **Load** button 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. **Divisible by** and
|
||||
Pixel rounding can differ by one pixel between opposite bands. **Align** and
|
||||
the resize **Step** can slightly change the final aspect ratio.
|
||||
|
||||
## Transform and outputs
|
||||
|
||||
Rotation, crop, padding, feathering, fill, **Divisible by**, and the editor's handles
|
||||
Rotation, crop, padding, feathering, fill, **Align**, 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
|
||||
@@ -161,7 +163,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. |
|
||||
| `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. |
|
||||
| `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`.
|
||||
|
||||
@@ -18,14 +18,37 @@ 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. Hover or focus the tile to reveal its edit pencil.
|
||||
- **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
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"id": "27945e9e-3e7b-49c5-bcb7-434d5de9e689",
|
||||
"revision": 0,
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "AUSBOSS_NODES_WorkflowNote",
|
||||
"pos": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"size": [
|
||||
300,
|
||||
88
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"title": "Download models",
|
||||
"properties": {
|
||||
"Node name for S&R": "AUSBOSS_NODES_WorkflowNote"
|
||||
},
|
||||
"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,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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ function mountTransformPanel(node, panel) {
|
||||
fillNodeHeight(widget, {
|
||||
minWidth: PANEL_MIN_WIDTH,
|
||||
minHeight: () => transformPanelFloor(node),
|
||||
minNodeSize: [PANEL_MIN_WIDTH, 470],
|
||||
minNodeSize: [PANEL_MIN_WIDTH, 396],
|
||||
exactMinWidth: true,
|
||||
});
|
||||
return widget;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
+16
-40
@@ -14,8 +14,9 @@
|
||||
// switch lays each sampling step over the stage, with a Stop button on the
|
||||
// bar (shared/step_preview.mjs).
|
||||
|
||||
import { api } from "/scripts/api.js";
|
||||
import { app } from "/scripts/app.js";
|
||||
import { panelSwitch } from "../shared/panel_controls.mjs";
|
||||
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";
|
||||
@@ -142,16 +143,10 @@ 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-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: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-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;}
|
||||
@@ -297,18 +292,9 @@ function previewEnabled(state) {
|
||||
}
|
||||
|
||||
function makePreviewToggle(state, signal) {
|
||||
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) => {
|
||||
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) => {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
const next = !previewEnabled(state);
|
||||
state.previewWidget.value = next;
|
||||
@@ -317,7 +303,7 @@ function makePreviewToggle(state, signal) {
|
||||
notifyAusbossChange();
|
||||
}, { signal });
|
||||
state.switchButton = button;
|
||||
return toggle;
|
||||
return button;
|
||||
}
|
||||
|
||||
function installPreviewSwitch(state, signal) {
|
||||
@@ -348,18 +334,10 @@ function installStepsSwitch(state, signal) {
|
||||
if (!widget) return;
|
||||
state.stepsWidget = widget;
|
||||
hideWidget(widget);
|
||||
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) => {
|
||||
const button = panelSwitch("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) => {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
const next = widget.value !== true;
|
||||
widget.value = next;
|
||||
@@ -368,9 +346,9 @@ function installStepsSwitch(state, signal) {
|
||||
notifyAusbossChange();
|
||||
}, { signal });
|
||||
state.stepsButton = button;
|
||||
const previewToggle = state.switchButton?.parentElement;
|
||||
if (previewToggle?.parentElement === state.bar) state.bar.insertBefore(toggle, previewToggle);
|
||||
else state.bar.append(toggle);
|
||||
const previewToggle = state.switchButton;
|
||||
if (previewToggle?.parentElement === state.bar) state.bar.insertBefore(button, previewToggle);
|
||||
else state.bar.append(button);
|
||||
|
||||
// Stop sits where the node's tools go; it keeps its place while hidden so
|
||||
// the bar never shifts when a run starts.
|
||||
@@ -408,7 +386,6 @@ 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");
|
||||
@@ -463,7 +440,6 @@ 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) {
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
+17
-6
@@ -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 } from "../shared/index.mjs";
|
||||
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 { WIDGET_FRAME, fillNodeHeight } from "../shared/panel_layout.mjs";
|
||||
import { formatTimecode, parseTimecode } from "../shared/timecode.mjs";
|
||||
@@ -102,6 +102,7 @@ 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;}
|
||||
@@ -379,7 +380,7 @@ function buildPanel(node) {
|
||||
canvas.className = "ausboss-audio-wave";
|
||||
const status = document.createElement("div");
|
||||
status.className = "ausboss-video-status";
|
||||
status.textContent = "Choose or upload a sound file";
|
||||
status.textContent = "Choose 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");
|
||||
@@ -469,7 +470,7 @@ function buildPanel(node) {
|
||||
if (!name || typeof name !== "string") {
|
||||
audio.removeAttribute("src");
|
||||
stage.classList.add("is-empty");
|
||||
status.textContent = "Choose or upload a sound file";
|
||||
status.textContent = "Choose a sound file";
|
||||
updateFace(state);
|
||||
return;
|
||||
}
|
||||
@@ -488,7 +489,17 @@ function buildPanel(node) {
|
||||
updateFace(state);
|
||||
} catch (error) {
|
||||
if (ticket !== state.loading) return;
|
||||
status.textContent = `This sound could not be shown: ${error?.message || error}`;
|
||||
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);
|
||||
}
|
||||
};
|
||||
state.refresh = refresh;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
+6
-28
@@ -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,7 +20,6 @@ import {
|
||||
BYPASS_MODE,
|
||||
DEFAULT_STEP,
|
||||
FINE_STEP,
|
||||
MAX_ROWS,
|
||||
MUTE_MODE,
|
||||
STRENGTH_MAX,
|
||||
STRENGTH_MIN,
|
||||
@@ -1078,10 +1077,6 @@ 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;
|
||||
@@ -1095,10 +1090,6 @@ 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
|
||||
@@ -1114,10 +1105,6 @@ 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;
|
||||
@@ -1769,7 +1756,6 @@ 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.
|
||||
@@ -1779,8 +1765,8 @@ function openRowMenu(state, index, event) {
|
||||
commitRows(state, rows, { structural: true });
|
||||
fitNode(state);
|
||||
};
|
||||
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("Insert empty LoRA above", () => insertEmpty(index));
|
||||
item("Insert empty LoRA below", () => insertEmpty(index + 1));
|
||||
item("Move up", () => commitGroups(state, moveRowInStack(state.rows, index, -1)),
|
||||
index === 0 && !row.group);
|
||||
item("Move down", () => commitGroups(state, moveRowInStack(state.rows, index, 1)),
|
||||
@@ -1791,7 +1777,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");
|
||||
@@ -1804,7 +1790,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 || state.rows.length >= MAX_ROWS);
|
||||
item("+ Add LoRAs…", () => openPicker(state, null, add), !add);
|
||||
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", () => {
|
||||
@@ -2489,11 +2475,6 @@ 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
|
||||
@@ -2670,7 +2651,6 @@ 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 });
|
||||
@@ -2708,7 +2688,6 @@ 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 });
|
||||
};
|
||||
@@ -2936,7 +2915,6 @@ 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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
+2
-2
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+179
-68
@@ -21,7 +21,8 @@
|
||||
// at the selected pixel budget and follow the explicit orientation toggle. Snap governs gestures only — typed
|
||||
// values are never re-snapped.
|
||||
|
||||
import { app } from "/scripts/app.js";
|
||||
import { CAPTION_TYPE, HINT_TYPE, compactAction } from "../shared/panel_controls.mjs";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import {
|
||||
chainCallback,
|
||||
keepDomWidgetWidthAuto,
|
||||
@@ -29,8 +30,10 @@ 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,
|
||||
@@ -51,12 +54,16 @@ import {
|
||||
offGrid,
|
||||
orientRatio,
|
||||
parseRail,
|
||||
parseWorkflowSizes,
|
||||
readWorkflowPolicy,
|
||||
savedWorkflowPolicy,
|
||||
workflowRatios,
|
||||
quickSizes,
|
||||
ratioLabel,
|
||||
reduceRatio,
|
||||
scrubDim,
|
||||
sizeForMegapixels,
|
||||
stageScale,
|
||||
fitStage,
|
||||
} from "../shared/resolution_math.mjs";
|
||||
|
||||
const NODE_CLASS = "AUSBOSS_NODES_Resolution";
|
||||
@@ -132,13 +139,19 @@ 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 { 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;
|
||||
.ausboss-res-sechead .label { ${CAPTION_TYPE} white-space: nowrap; }
|
||||
.ausboss-res-sechead .hint { ${HINT_TYPE}
|
||||
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;
|
||||
@@ -174,12 +187,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: 0; margin: auto;
|
||||
border: 1.5px solid #00b4aa; background: rgba(0,180,170,.05);
|
||||
.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);
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 2px; }
|
||||
.ausboss-res-rect.easing { transition: width ${REFIT_MS}ms ease, height ${REFIT_MS}ms ease; }
|
||||
.ausboss-res-ghost { position: absolute; inset: 0; margin: auto;
|
||||
.ausboss-res-ghost { position: absolute; inset: var(--ausboss-res-inset,0) 0 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;
|
||||
@@ -207,6 +220,8 @@ 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;
|
||||
@@ -214,7 +229,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;
|
||||
min-height: ${QUICK_H * 2 + 5}px; align-content: start; }
|
||||
height: ${QUICK_H * 2 + 5}px; overflow-y: auto; 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;
|
||||
@@ -256,6 +271,7 @@ 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);
|
||||
@@ -350,7 +366,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) {
|
||||
function paintRings(state, scale, shift) {
|
||||
const svg = state.rings;
|
||||
svg.replaceChildren();
|
||||
const show = state.settings?.show_mp_rings !== false;
|
||||
@@ -358,7 +374,7 @@ function paintRings(state, scale) {
|
||||
const box = stageBox(state);
|
||||
svg.setAttribute("viewBox", `0 0 ${box.w} ${box.h}`);
|
||||
const cx = box.w / 2;
|
||||
const cy = box.h / 2;
|
||||
const cy = box.h / 2 + shift;
|
||||
for (const mp of [0.5, 1, 2]) {
|
||||
const points = [];
|
||||
for (let i = 0; i <= 60; i += 1) {
|
||||
@@ -396,7 +412,7 @@ function paintRings(state, scale) {
|
||||
|
||||
// The quick list regenerates when the ratio or snap changes; repaint only
|
||||
// re-highlights it otherwise.
|
||||
function rebuildQuick(state, width, height, snap, custom) {
|
||||
function rebuildQuick(state, width, height, snap, custom, picks) {
|
||||
const wrap = state.quickWrap;
|
||||
wrap.replaceChildren();
|
||||
state.quickButtons = [];
|
||||
@@ -409,7 +425,8 @@ function rebuildQuick(state, width, height, snap, custom) {
|
||||
wrap.append(chip);
|
||||
return;
|
||||
}
|
||||
const list = quickSizes(width, height, snap);
|
||||
const list = picks ?? quickSizes(width, height, snap);
|
||||
if (state.strict && !list.length) return;
|
||||
if (!list.length) {
|
||||
wrap.append(el("span", "ausboss-res-noquick",
|
||||
"No on-grid sizes for this ratio at this snap — try a finer snap."));
|
||||
@@ -420,6 +437,7 @@ function rebuildQuick(state, width, height, snap, custom) {
|
||||
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);
|
||||
});
|
||||
@@ -430,26 +448,98 @@ function rebuildQuick(state, width, height, snap, custom) {
|
||||
}
|
||||
}
|
||||
|
||||
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.rect.style.width = `${Math.max(8, width * scale)}px`;
|
||||
state.rect.style.height = `${Math.max(8, height * scale)}px`;
|
||||
state.dimLabel.textContent = `${width} × ${height}`;
|
||||
state.subMp.textContent = `${megapixels(width, height).toFixed(2)} MP`;
|
||||
state.subRatio.textContent = ratioLabel(width, height);
|
||||
state.offGridDot.style.display = offGrid(width, height, snap) ? "" : "none";
|
||||
state.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" : "";
|
||||
|
||||
paintRings(state, scale);
|
||||
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);
|
||||
|
||||
// Section labels.
|
||||
state.orientLabel.textContent = width === height
|
||||
state.orientLabel.textContent = state.strict ? "Workflow ratios" : width === height
|
||||
? `square · chips ${portrait ? "portrait" : "landscape"}`
|
||||
: portrait ? "portrait" : "landscape";
|
||||
const flipFit = portrait ? { w: 11, h: 15 } : { w: 15, h: 11 };
|
||||
@@ -463,14 +553,17 @@ function repaint(state) {
|
||||
|
||||
// Ratio chips render in the selected orientation; filled = exact, dashed = near.
|
||||
state.chipButtons.forEach(({ button, ratio: chipRatio, glyph, label }) => {
|
||||
const oriented = orientRatio(chipRatio, portrait);
|
||||
const oriented = state.strict ? chipRatio : orientRatio(chipRatio, portrait);
|
||||
const fitted = fitRectInBox(oriented.x, oriented.y, 14, 14);
|
||||
glyph.style.width = `${fitted.w.toFixed(1)}px`;
|
||||
glyph.style.height = `${fitted.h.toFixed(1)}px`;
|
||||
label.textContent = oriented.label;
|
||||
button.title = `${oriented.label}: reshape to this ratio. The flip button on the left sets the orientation.`;
|
||||
button.title = state.strict ? `Show approved ${oriented.label} sizes` : `${oriented.label}: reshape to this ratio. The flip button on the left sets the orientation.`;
|
||||
const candidate = chipMatch(width, height, oriented);
|
||||
const match = candidate?.flipped ? null : candidate;
|
||||
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"));
|
||||
button.classList.toggle("exact", match?.state === "exact");
|
||||
button.classList.toggle("near", match?.state === "near");
|
||||
});
|
||||
@@ -479,49 +572,52 @@ function repaint(state) {
|
||||
const widthLinked = axisLinked(state, "width");
|
||||
const heightLinked = axisLinked(state, "height");
|
||||
const linked = widthLinked || heightLinked;
|
||||
state.flipButton.disabled = linked;
|
||||
state.swapButton.disabled = linked;
|
||||
state.budgetControl.root.style.pointerEvents = linked ? "none" : "";
|
||||
state.budgetControl.root.style.opacity = linked ? ".5" : "";
|
||||
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" : "";
|
||||
for (const { button } of state.chipButtons) button.disabled = linked;
|
||||
state.widthControl.set(width);
|
||||
state.heightControl.set(height);
|
||||
state.budgetControl?.set(megapixels(width, height));
|
||||
state.widthField.classList.toggle("linked", widthLinked);
|
||||
state.heightField.classList.toggle("linked", heightLinked);
|
||||
state.widthField.title = widthLinked ? "Width is driven by its input link." : "";
|
||||
state.heightField.title = heightLinked ? "Height is driven by its input link." : "";
|
||||
state.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.handles.e.classList.toggle("linked", widthLinked);
|
||||
state.handles.s.classList.toggle("linked", heightLinked);
|
||||
state.handles.se.classList.toggle("linked", widthLinked || heightLinked);
|
||||
state.handles.se.classList.toggle("linked", linked);
|
||||
|
||||
// Quick sizes follow the CURRENT ratio - the Pixaroma behavior, but the
|
||||
// list is generated on-ratio/on-grid instead of hand-curated. Frozen
|
||||
// Quick sizes follow the current ratio and snap grid. Frozen
|
||||
// while a gesture is live (a drag sweeps ratios; regenerating per move
|
||||
// read as glitching), and a shape off the rail shows one "custom" chip.
|
||||
if (!state.dragging) {
|
||||
const onRail = state.railRatios?.some(
|
||||
(railRatio) => chipMatch(width, height, railRatio)?.state === "exact",
|
||||
);
|
||||
const quickKey = onRail ? `${ratio.x}:${ratio.y}/${snap}` : `custom/${snap}`;
|
||||
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}`;
|
||||
if (state.quickKey !== quickKey) {
|
||||
state.quickKey = quickKey;
|
||||
rebuildQuick(state, width, height, snap, !onRail);
|
||||
state.quickHint.textContent = onRail
|
||||
? `for ${ratioLabel(width, height)} · /${snap}`
|
||||
: `custom · /${snap}`;
|
||||
rebuildQuick(state, width, height, snap, !picks && !onRail, picks);
|
||||
}
|
||||
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 = widthLinked || heightLinked;
|
||||
button.classList.toggle("exact", size.width === width && size.height === height);
|
||||
button.disabled = linked;
|
||||
const selected = size.width === width && size.height === height;
|
||||
button.classList.toggle("exact", selected);
|
||||
button.setAttribute("aria-pressed", String(selected));
|
||||
});
|
||||
}
|
||||
|
||||
function showGhost(state, width, height) {
|
||||
const box = stageBox(state);
|
||||
const { width: w, height: h } = getSize(state);
|
||||
const scale = stageScale(w, h, box.w, box.h);
|
||||
const { scale } = state.frame;
|
||||
state.ghost.style.width = `${width * scale}px`;
|
||||
state.ghost.style.height = `${height * scale}px`;
|
||||
state.ghost.style.display = "";
|
||||
@@ -532,7 +628,7 @@ function hideGhost(state) {
|
||||
}
|
||||
|
||||
function easeRefit(state) {
|
||||
state.frozenScale = null;
|
||||
state.frozenFrame = null;
|
||||
state.rect.classList.add("easing");
|
||||
repaint(state);
|
||||
clearTimeout(state.easeTimer);
|
||||
@@ -542,15 +638,14 @@ function easeRefit(state) {
|
||||
// ---------- gestures ----------
|
||||
|
||||
function beginHandleDrag(state, handle, event) {
|
||||
if (event.button !== 0 || state.disposed) return;
|
||||
if (state.strict || event.button !== 0 || state.disposed) return;
|
||||
if ((handle !== "s" && axisLinked(state, "width")) || (handle !== "e" && axisLinked(state, "height"))) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const start = getSize(state);
|
||||
const box = stageBox(state);
|
||||
// Frozen at pointerdown: the rect may overflow the stage mid-gesture and
|
||||
// re-fits with a short ease on release - auto-fit never squirms mid-drag.
|
||||
state.frozenScale = stageScale(start.width, start.height, box.w, box.h);
|
||||
state.frozenFrame = state.frame;
|
||||
state.dragging = true;
|
||||
const origin = { x: event.clientX, y: event.clientY };
|
||||
state.dragAbort?.abort();
|
||||
@@ -563,7 +658,7 @@ function beginHandleDrag(state, handle, event) {
|
||||
startHeight: start.height,
|
||||
dx: (moveEvent.clientX - origin.x) / zoom,
|
||||
dy: (moveEvent.clientY - origin.y) / zoom,
|
||||
scale: state.frozenScale,
|
||||
scale: state.frozenFrame.scale,
|
||||
snap: currentSnap(state),
|
||||
snapOn: !moveEvent.altKey,
|
||||
// The corner scales at the exact locked ratio; Shift frees both axes.
|
||||
@@ -644,7 +739,7 @@ function sectionHead(label, hint) {
|
||||
function buildPanel(state) {
|
||||
state.dragAbort?.abort();
|
||||
state.dragging = false;
|
||||
state.frozenScale = null;
|
||||
state.frozenFrame = null;
|
||||
const panel = state.panel;
|
||||
panel.textContent = "";
|
||||
state.quickKey = null; // force the quick list to regenerate
|
||||
@@ -668,19 +763,16 @@ function buildPanel(state) {
|
||||
state.flipButton = flip;
|
||||
state.flipGlyph = flipGlyph;
|
||||
state.chipButtons = [];
|
||||
state.railRatios = parseRail(state.settings?.rail);
|
||||
state.railRatios = state.strict ? workflowRatios(state.sizes) : 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);
|
||||
@@ -701,13 +793,15 @@ function buildPanel(state) {
|
||||
state.chipButtons.push({ button: chip, ratio, glyph, label });
|
||||
}
|
||||
chipBox.append(chipGrid);
|
||||
shapeRow.append(flipBox, chipBox);
|
||||
if (!state.strict) shapeRow.append(flipBox);
|
||||
shapeRow.append(chipBox);
|
||||
shapeSec.append(shapeRow);
|
||||
panel.append(shapeSec);
|
||||
|
||||
// (2) STAGE
|
||||
const stage = el("div", "ausboss-res-stage");
|
||||
stage.tabIndex = 0;
|
||||
stage.tabIndex = state.strict ? -1 : 0;
|
||||
if (state.strict) stage.title = "Choose from the workflow’s ratio and size buttons.";
|
||||
const dots = el("div", "ausboss-res-dots");
|
||||
const rings = svgEl("svg");
|
||||
rings.classList.add("ausboss-res-rings");
|
||||
@@ -734,14 +828,14 @@ function buildPanel(state) {
|
||||
? "Drag width - Alt unsnaps, Esc cancels"
|
||||
: "Drag height - Alt unsnaps, Esc cancels";
|
||||
grip.addEventListener("pointerdown", (event) => beginHandleDrag(state, handle, event));
|
||||
rect.append(grip);
|
||||
if (!state.strict) rect.append(grip);
|
||||
state.handles[handle] = grip;
|
||||
}
|
||||
stage.append(dots, rings, ghost, rect, readout);
|
||||
stage.addEventListener("keydown", (event) => {
|
||||
const keys = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, 1], ArrowDown: [0, -1] };
|
||||
const move = keys[event.key];
|
||||
if (!move) return;
|
||||
if (!move || state.strict) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const step = event.shiftKey ? 8 : currentSnap(state);
|
||||
@@ -803,10 +897,13 @@ function buildPanel(state) {
|
||||
gear.addEventListener("click", () => {
|
||||
openSettingsMenu({
|
||||
scope: SETTINGS_SCOPE,
|
||||
schema: SETTINGS_SCHEMA.map((entry) => {
|
||||
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) => {
|
||||
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: {
|
||||
@@ -869,7 +966,7 @@ function buildPanel(state) {
|
||||
state.resizeObserver?.disconnect();
|
||||
state.resizeObserver = new ResizeObserver(() => {
|
||||
holdVueNodeMinWidth(state.panel, MIN_WIDTH);
|
||||
if (!state.frozenScale) repaint(state);
|
||||
if (!state.frozenFrame) repaint(state);
|
||||
});
|
||||
state.resizeObserver.observe(stage);
|
||||
|
||||
@@ -907,6 +1004,8 @@ 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);
|
||||
|
||||
@@ -928,9 +1027,12 @@ function installResolutionNode(node) {
|
||||
heightWidget,
|
||||
latentWidget,
|
||||
batchWidget,
|
||||
policyWidget,
|
||||
sizes: [],
|
||||
strict: false,
|
||||
panel,
|
||||
settings: loadSettings(SETTINGS_SCOPE, SETTINGS_SCHEMA),
|
||||
frozenScale: null,
|
||||
frozenFrame: null,
|
||||
quickKey: null,
|
||||
};
|
||||
node.__ausbossResolution = state;
|
||||
@@ -953,6 +1055,7 @@ function installResolutionNode(node) {
|
||||
exactMinWidth: true,
|
||||
});
|
||||
|
||||
restorePolicy(state);
|
||||
buildPanel(state);
|
||||
for (const widget of [widthWidget, heightWidget, latentWidget, batchWidget]) {
|
||||
if (widget) chainCallback(widget, "callback", () => {
|
||||
@@ -980,11 +1083,18 @@ 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 () {
|
||||
// Convert-to-input honesty: link changes flip the read-only axes.
|
||||
requestAnimationFrame(() => { if (!state.disposed) { syncPrimitiveLinks(state); repaint(state); } });
|
||||
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);
|
||||
});
|
||||
});
|
||||
chainCallback(node, "onRemoved", () => {
|
||||
state.disposed = true;
|
||||
@@ -998,7 +1108,8 @@ app.registerExtension({
|
||||
name: "AusBoss.Resolution",
|
||||
beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData?.name !== NODE_CLASS) return;
|
||||
hideInputsInDef(nodeData, ["width", "height", "latent", "batch_size"]);
|
||||
markSocketless(nodeData, ["workflow_sizes"]);
|
||||
hideInputsInDef(nodeData, ["width", "height", "latent", "batch_size", "workflow_sizes"]);
|
||||
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
||||
installResolutionNode(this);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
// When the `filename` input is linked the name comes from upstream: the
|
||||
// field shows {{filename}} and the tags fold away, since an exact name is
|
||||
// never decorated.
|
||||
import { api } from "/scripts/api.js";
|
||||
import { app } from "/scripts/app.js";
|
||||
import { PANEL_LABELS } from "../shared/panel_controls.mjs";
|
||||
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";
|
||||
@@ -275,13 +276,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, "no"], [true, "yes"]]) {
|
||||
for (const [on, text] of [[false, PANEL_LABELS.off], [true, PANEL_LABELS.on]]) {
|
||||
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", "Embed workflow"), embedSeg);
|
||||
embedRow.append(el("span", "ausboss-save-embed-label", PANEL_LABELS.embed), embedSeg);
|
||||
root.append(embedRow);
|
||||
|
||||
root.addEventListener("pointerdown", (event) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { api } from "/scripts/api.js";
|
||||
import { app } from "/scripts/app.js";
|
||||
import { panelSwitch } from "../shared/panel_controls.mjs";
|
||||
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";
|
||||
@@ -21,7 +22,7 @@ import {
|
||||
|
||||
const NODE_NAME = "AUSBOSS_NODES_SaveVideo";
|
||||
const PREVIEW_WIDGET = "ausboss_save_video_viewer";
|
||||
const PREVIEW_CHROME = 12;
|
||||
const PREVIEW_CHROME = 38;
|
||||
// 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.
|
||||
@@ -47,7 +48,7 @@ function stepsOn(state) {
|
||||
|
||||
function syncSteps(state, run = stepRun()) {
|
||||
const on = stepsOn(state);
|
||||
state.stepsButton?.classList.toggle("active", on);
|
||||
state.stepsButton?.setAttribute("aria-checked", String(on));
|
||||
state.stopButton?.classList.toggle("live", on && run.status === "running");
|
||||
const overlay = state.stepOverlay;
|
||||
if (!on) {
|
||||
@@ -130,11 +131,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 = 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.");
|
||||
const stepsButton = panelSwitch("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, stepsButton, loopButton);
|
||||
tools.append(stopButton, loopButton);
|
||||
stage.append(video, image, status, tools);
|
||||
root.append(stage);
|
||||
root.append(stepsButton, stage);
|
||||
|
||||
const widget = node.addDOMWidget(PREVIEW_WIDGET, "ausboss_video", root, {
|
||||
serialize: false,
|
||||
@@ -147,7 +148,7 @@ function buildPreview(node) {
|
||||
widget.serialize = false;
|
||||
fillNodeHeight(widget, {
|
||||
minWidth: VIDEO_MIN_WIDTH,
|
||||
minHeight: 144,
|
||||
minHeight: 158,
|
||||
minNodeSize: [VIDEO_MIN_WIDTH, 270],
|
||||
exactMinWidth: true,
|
||||
});
|
||||
|
||||
+1
-1
@@ -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";
|
||||
|
||||
@@ -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 { api } from "/scripts/api.js";
|
||||
import { compactAction } from "./panel_controls.mjs";
|
||||
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,6 +82,7 @@ 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;}
|
||||
@@ -247,9 +248,32 @@ 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 || !er.refs) return;
|
||||
if (!er.active) return;
|
||||
drawBrushSwatch(er);
|
||||
if (!er.refs) return;
|
||||
sizeView(er);
|
||||
const { canvas } = er;
|
||||
const ctx = canvas.getContext("2d");
|
||||
@@ -377,7 +401,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."
|
||||
@@ -827,6 +851,11 @@ 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({
|
||||
@@ -846,7 +875,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(makeToolButton("UNDO", "Take back the last stroke, or a Clear."), () => {
|
||||
er.undoButton = click(compactAction("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.
|
||||
@@ -856,13 +885,13 @@ export function makeEraser({ node, root, stage, imageA, imageB, signal, onSaved,
|
||||
syncTools(er);
|
||||
schedule(er);
|
||||
});
|
||||
er.clearButton = click(makeToolButton("CLEAR", "Take all the paint off. Undo brings it back."), () => {
|
||||
er.clearButton = click(compactAction("Clear", "Take all the paint off. Undo brings it back."), () => {
|
||||
if (!er.strokes.length) return;
|
||||
change(er, []);
|
||||
rebuildMask(er);
|
||||
schedule(er);
|
||||
});
|
||||
er.alignButton = click(makeToolButton("ALIGN", ""), () => align(er));
|
||||
er.alignButton = click(compactAction("Align", ""), () => align(er));
|
||||
steps.append(er.alignButton, er.undoButton, er.clearButton);
|
||||
er.bar.append(er.kindSwitch.root, sizeField, softField, topField, steps);
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
+2
-2
@@ -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.20.5";
|
||||
export const AUSBOSS_JS_VERSION = "2.21.0";
|
||||
|
||||
export const BRAND = "#00b4aa"; // AusBoss teal — keep node accents consistent.
|
||||
export const BRAND_DARK = "#007f78";
|
||||
|
||||
@@ -12,7 +12,6 @@ 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;
|
||||
|
||||
@@ -40,7 +39,7 @@ export function newRow() {
|
||||
|
||||
export function normalizeRows(parsed) {
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return tidyGroups(parsed.slice(0, MAX_ROWS).map((entry) => {
|
||||
return tidyGroups(parsed.map((entry) => {
|
||||
const row = newRow();
|
||||
if (entry && typeof entry === "object") {
|
||||
row.id = typeof entry.id === "string" && entry.id ? entry.id : row.id;
|
||||
@@ -600,9 +599,8 @@ export function loraPickerEntries(names, query = "", collapsedFolders = new Set(
|
||||
return visit(tree);
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Append the folder in one step, preserving duplicates, disabled rows and
|
||||
// empty slots already in the stack. Deduplicate new files by full path.
|
||||
export function appendFolderRows(rows, names, defaultStrength = 1) {
|
||||
const seen = new Set(rows.map((row) => loraPath(row.name)));
|
||||
const pending = [];
|
||||
@@ -613,12 +611,10 @@ export function appendFolderRows(rows, names, defaultStrength = 1) {
|
||||
seen.add(key);
|
||||
pending.push(name);
|
||||
}
|
||||
const room = Math.max(0, MAX_ROWS - rows.length);
|
||||
const needed = pending.length;
|
||||
if (needed > room || !needed) return { rows, added: 0, skipped, needed, room };
|
||||
if (!pending.length) return { rows, added: 0, skipped };
|
||||
const strength = roundStrength(defaultStrength);
|
||||
const addedRows = pending.map((name) => ({ ...newRow(), name, strength, strength_clip: strength }));
|
||||
return { rows: [...rows, ...addedRows], added: needed, skipped, needed, room };
|
||||
return { rows: [...rows, ...addedRows], added: addedRows.length, skipped };
|
||||
}
|
||||
|
||||
// A suggested range is advisory: null/absent bounds never flag.
|
||||
@@ -1031,9 +1027,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 ?? [])]).slice(0, MAX_ROWS);
|
||||
: [...added, ...(existing ?? [])];
|
||||
return { rows, added: added.length, skipped };
|
||||
}
|
||||
|
||||
|
||||
@@ -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|output)\]$/, "");
|
||||
const bare = text.replace(/\s+\[input\]$/, "");
|
||||
return values.includes(text) || values.includes(bare) ? "" : text;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +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 { 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";
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -129,13 +129,17 @@ export function ratioSizeNear(ratio, width, height, step = 1) {
|
||||
// modifiers: snap (default true; false = 1px freedom),
|
||||
// lockRatio (true = keep the grab-time ratio exactly).
|
||||
|
||||
export function stageScale(width, height, boxW, boxH) {
|
||||
// Fit with fixed headroom for the handles, corner hints, and the label
|
||||
// (the design's 90px horizontal / 60px vertical margins), never below a
|
||||
// sliver on a tiny stage.
|
||||
const usableW = Math.max(40, boxW - 90);
|
||||
const usableH = Math.max(40, boxH - 60);
|
||||
return Math.max(0.002, Math.min(usableW / width, usableH / height));
|
||||
// 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 };
|
||||
}
|
||||
|
||||
const HANDLE_AXES = {
|
||||
@@ -406,3 +410,51 @@ 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 }) : "";
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
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) {
|
||||
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);
|
||||
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) {
|
||||
let press;
|
||||
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")) 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;
|
||||
}
|
||||
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 });
|
||||
}
|
||||
|
||||
// Nodes 2.0 draws its own DOM frame. Keep native move, selection and resize
|
||||
// targets on the node, and show only the widget inside that frame.
|
||||
export const TILE_FACE_CSS = `
|
||||
.ausboss-tile-face :is(.ausboss-callout-card,.ausboss-note-head){pointer-events:auto;touch-action:none;cursor:grab;}
|
||||
.lg-node:has(.ausboss-tile-face){filter: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,
|
||||
.lg-node:has(.ausboss-tile-face) .bg-component-node-background>div:last-child:not(.lg-node-widgets){display:none;}
|
||||
`;
|
||||
@@ -128,8 +128,9 @@ 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 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 };
|
||||
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 };
|
||||
}
|
||||
|
||||
// What a run keeps out of the window after thinning, the cap and the snap
|
||||
@@ -243,13 +244,28 @@ 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, tooLong: fixed.tooLong };
|
||||
return { ...base, first: fixed.first, last: fixed.last, windowLast: fixed.last, frames: fixed.frames, requested: frames, latestStart: fixed.latestStart, tooLong: fixed.tooLong };
|
||||
};
|
||||
const capped = (cap) => {
|
||||
const requested = cap > 0 ? snapFrameCount(cap, snap) : null;
|
||||
if (values.resampled) return { ...base, last: cap > 0 ? null : window.last, requested };
|
||||
// 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 };
|
||||
const kept = keptFrames(window, nth, cap, snap);
|
||||
return { ...base, last: kept.lastKept, frames: kept.frames, requested, truncated: requested != null && kept.frames < requested };
|
||||
return { ...base, last: kept.lastKept, frames: kept.frames, requested, latestStart, 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)));
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
// 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 { api } from "/scripts/api.js";
|
||||
import { ensurePanelControls } from "./panel_controls.mjs";
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import {
|
||||
brushAdjusts,
|
||||
brushCore,
|
||||
@@ -2938,8 +2939,9 @@ export function mountTouchStage(node, host) {
|
||||
};
|
||||
face.sizeField = field("Size", face.size, SIZE_HELP);
|
||||
face.softField = field("Soft", face.softBox, SOFT_HELP);
|
||||
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.", () => {
|
||||
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.", () => {
|
||||
dropPending(face);
|
||||
face.active = 0;
|
||||
face.fresh = 0;
|
||||
|
||||
+421
-235
File diff suppressed because it is too large
Load Diff
@@ -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(`${(Math.abs(chain.stretch) * 100).toFixed(1)}% ${chain.stretch > 0 ? "wider" : "taller"}: each side rounds to ${chain.steps ?? 1} px`);
|
||||
warnings.push(`Resize makes the picture ${(Math.abs(chain.stretch) * 100).toFixed(1)}% ${chain.stretch > 0 ? "wider" : "taller"}`);
|
||||
}
|
||||
// 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,6 +762,20 @@ 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.
|
||||
|
||||
@@ -55,6 +55,14 @@ 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}
|
||||
@@ -118,7 +126,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, onSeek, onCommit }) {
|
||||
export function mountTransformTrim({ get, set, has = () => true, driven = () => false, number = (name, fallback) => get(name, fallback), outputRate, metadata, trim = true, isPlaying, onTogglePlayback, onSeek, onCommit }) {
|
||||
installCss();
|
||||
const root = el("div", "ausboss-transform-trim");
|
||||
const rail = el("div", "ausboss-transform-trim-rail");
|
||||
@@ -132,6 +140,11 @@ 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"));
|
||||
@@ -225,6 +238,8 @@ 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;
|
||||
}
|
||||
@@ -294,6 +309,11 @@ 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"));
|
||||
@@ -343,7 +363,9 @@ 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");
|
||||
handle.setAttribute("aria-valuemax", String(Math.max(0, clip.count - 1)));
|
||||
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-valuenow", String(frame));
|
||||
handle.setAttribute("aria-valuetext", `frame ${frame}, ${formatTimecode(frameTime(frame, clip))}`);
|
||||
}
|
||||
@@ -536,7 +558,7 @@ export function mountTransformTrim({ get, set, has = () => true, driven = () =>
|
||||
label.append(controls.frame.root);
|
||||
timeRow.append(label, readout);
|
||||
}
|
||||
root.append(rail, timeRow);
|
||||
root.append(transport, 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.
|
||||
@@ -581,7 +603,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.frames ?? current.requested ?? 0) : (current.requested ?? get("max_frames", 1)));
|
||||
count.set(blocked ? (current.requested ?? current.frames ?? 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";
|
||||
|
||||
@@ -45,8 +45,7 @@ export function videoSourceState(mode, video, localPath) {
|
||||
};
|
||||
}
|
||||
|
||||
// Images use the same picker card, but their backend accepts input-folder
|
||||
// references only. Do not advertise a Local path mode it cannot execute.
|
||||
// Images store the folder annotation in their image widget. Only video uses local_path.
|
||||
export function mediaSourceState(kind, mode, selection, localPath) {
|
||||
if (kind !== "image") return videoSourceState(mode, selection, localPath);
|
||||
const image = String(selection ?? "");
|
||||
|
||||
@@ -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"];
|
||||
export const LAYOUTS = ["card", "banner", "tile"];
|
||||
// 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";
|
||||
@@ -244,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 = /(\*\*[^*\n]+?\*\*|`[^`\n]+?`|\[[^\]\n]+?\]\([^)\s]+?\)|(?<![\w*])\*[^*\n]+?\*(?![\w*]))/;
|
||||
const INLINE_PATTERN = /(?:\[(teal|green|amber|glow)\]([^\n]+?)\[\/\1\]|\*\*[^*\n]+?\*\*|`[^`\n]+?`|\[[^\]\n]+?\]\([^)\s]+?\)|(?<![\w*])\*[^*\n]+?\*(?![\w*]))/;
|
||||
|
||||
export function parseInline(text) {
|
||||
const spans = [];
|
||||
@@ -257,7 +257,8 @@ export function parseInline(text) {
|
||||
}
|
||||
if (match.index > 0) spans.push({ type: "text", text: rest.slice(0, match.index) });
|
||||
const token = match[0];
|
||||
if (token.startsWith("**")) spans.push({ type: "strong", text: token.slice(2, -2) });
|
||||
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) });
|
||||
else if (token.startsWith("`")) spans.push({ type: "code", text: token.slice(1, -1) });
|
||||
else if (token.startsWith("[")) {
|
||||
const close = token.indexOf("](");
|
||||
@@ -270,6 +271,15 @@ 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 = [];
|
||||
@@ -292,7 +302,8 @@ export function parseMarkdown(text) {
|
||||
list = null;
|
||||
};
|
||||
|
||||
for (const rawLine of lines) {
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
const rawLine = lines[index];
|
||||
const line = rawLine.trimEnd();
|
||||
if (code) {
|
||||
if (/^```/.test(line)) {
|
||||
@@ -312,6 +323,25 @@ 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();
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
// 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 { api } from "/scripts/api.js";
|
||||
import { app } from "/scripts/app.js";
|
||||
import { PANEL_LABELS } from "../shared/panel_controls.mjs";
|
||||
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";
|
||||
@@ -210,7 +211,7 @@ const CARDS = {
|
||||
AUSBOSS_NODES_ImageFolder: {
|
||||
minWidth: 320, first: true, hide: ["pictures"], socketless: ["pictures"],
|
||||
rows: [
|
||||
{ widget: "source", label: "From", kind: "segment" },
|
||||
{ widget: "source", label: "From", kind: "segment", labels: PANEL_LABELS.sources },
|
||||
{ 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")) } },
|
||||
@@ -333,7 +334,7 @@ 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: "" },
|
||||
resetUnknown: { sound: "", music: "", clip_sound: false },
|
||||
rows: [
|
||||
{ section: "What happens" },
|
||||
{
|
||||
@@ -352,6 +353,7 @@ const CARDS = {
|
||||
{ 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
|
||||
@@ -377,7 +379,7 @@ const CARDS = {
|
||||
minWidth: 320, first: true, hide: ["upload"],
|
||||
rows: [
|
||||
{ widget: "video", label: "Source", kind: "select", preview: { kind: "video", url: viewUrl },
|
||||
button: { text: "Upload", title: "Upload a video into ComfyUI's input folder", onClick: (node) => node.widgets?.find((w) => w.name === "upload")?.callback?.() } },
|
||||
button: { text: PANEL_LABELS.load, 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" },
|
||||
@@ -389,7 +391,7 @@ const CARDS = {
|
||||
minWidth: 280, first: true, hide: ["upload"],
|
||||
rows: [
|
||||
{ widget: "audio", label: "Source", kind: "select",
|
||||
button: { text: "Upload", title: "Upload a sound file into ComfyUI's input folder", onClick: (node) => node.widgets?.find((w) => w.name === "upload")?.callback?.() } },
|
||||
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?.() } },
|
||||
{ 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." },
|
||||
],
|
||||
@@ -398,7 +400,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: "Upload", title: "Upload an image into ComfyUI's input folder", onClick: (node) => node.widgets?.find((w) => w.name === "upload")?.callback?.() } },
|
||||
button: { text: PANEL_LABELS.load, 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") },
|
||||
@@ -423,7 +425,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: "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." },
|
||||
{ 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." },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
+170
-8
@@ -10,8 +10,9 @@
|
||||
// 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 { api } from "../../../scripts/api.js";
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { setTileFace, TILE_FACE_CSS } from "../shared/tile_face.mjs";
|
||||
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
|
||||
import { collectGraphNodes } from "../shared/appearance.mjs";
|
||||
import { copyToClipboard } from "../shared/clipboard.mjs";
|
||||
@@ -107,6 +108,7 @@ 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;}
|
||||
@@ -115,6 +117,42 @@ function ensureCss() {
|
||||
.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:0;pointer-events:none;}
|
||||
.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 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;}
|
||||
@@ -215,6 +253,23 @@ 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;
|
||||
@@ -231,6 +286,7 @@ 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));
|
||||
}
|
||||
@@ -242,6 +298,24 @@ 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) {
|
||||
@@ -424,7 +498,9 @@ function render(state) {
|
||||
const { note } = state;
|
||||
const root = state.root;
|
||||
root.classList.toggle("is-banner", note.layout === "banner");
|
||||
setBadgesHidden(state.node, note.layout === "banner" && !noteIsEmpty(note));
|
||||
root.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));
|
||||
root.style.setProperty("--accent", note.accent || BRAND);
|
||||
const scroll = state.scroll;
|
||||
scroll.textContent = "";
|
||||
@@ -448,12 +524,47 @@ 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 = el("button", "ausboss-note-btn", "Close");
|
||||
close.onclick = () => dialog.close();
|
||||
const header = el("header", "ausboss-note-reader-head");
|
||||
header.append(close, 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;
|
||||
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");
|
||||
@@ -555,7 +666,7 @@ async function checkModels(state, fresh) {
|
||||
}
|
||||
}
|
||||
}));
|
||||
if (generation === state.checkGeneration) fitNote(state);
|
||||
if (state.domWidget && generation === state.checkGeneration) fitNote(state);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -768,7 +879,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" : "", option === "card" ? "Card" : "Banner");
|
||||
const button = el("button", option === layout ? "on" : "", ({ card: "Card", banner: "Banner", tile: "Info tile" })[option]);
|
||||
button.type = "button";
|
||||
button.addEventListener("click", () => {
|
||||
layout = option;
|
||||
@@ -782,7 +893,7 @@ async function openEditor(state) {
|
||||
field("Title", title),
|
||||
field("Author", author),
|
||||
field("Subtitle", subtitle),
|
||||
field("Layout", seg, "Banner shows only the title, subtitle and author - a label for a column or stage."),
|
||||
field("Layout", seg, "Banner shows the title. Info tile opens the full note when clicked."),
|
||||
field("Accent color", accent, "Hex like #00b4aa; empty keeps the pack teal."),
|
||||
);
|
||||
form.append(grid);
|
||||
@@ -790,7 +901,48 @@ 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));
|
||||
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);
|
||||
|
||||
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))));
|
||||
@@ -891,10 +1043,15 @@ 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();
|
||||
@@ -1004,7 +1161,7 @@ app.registerExtension({
|
||||
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
||||
buildPanel(this);
|
||||
});
|
||||
chainCallback(nodeType.prototype, "onConfigure", function () {
|
||||
chainCallback(nodeType.prototype, "onConfigure", function (saved) {
|
||||
// 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
|
||||
@@ -1014,6 +1171,10 @@ 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);
|
||||
});
|
||||
});
|
||||
@@ -1028,6 +1189,7 @@ app.registerExtension({
|
||||
});
|
||||
chainCallback(nodeType.prototype, "onRemoved", function () {
|
||||
closeEditor();
|
||||
this.__ausbossNote?.reader?.close();
|
||||
this.__ausbossNote?.resizeObserver?.disconnect();
|
||||
this.__ausbossNote = null;
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -57,14 +57,15 @@ def list_input_audio() -> list[str]:
|
||||
"""Sound files (and videos, for their sound) in ComfyUI's input folder."""
|
||||
if folder_paths is None:
|
||||
return [""]
|
||||
root = Path(folder_paths.get_input_directory())
|
||||
from ._media_helpers import list_input_files
|
||||
|
||||
kinds = AUDIO_EXTENSIONS | SOUND_IN_VIDEO_EXTENSIONS
|
||||
results = [
|
||||
path.relative_to(root).as_posix()
|
||||
for path in root.rglob("*")
|
||||
if path.is_file() and not path.name.startswith(".") and path.suffix.lower() in kinds
|
||||
name
|
||||
for name in list_input_files(folder_paths)
|
||||
if Path(name).suffix.lower() in kinds
|
||||
]
|
||||
return sorted(results, key=str.casefold) or [""]
|
||||
return results or [""]
|
||||
|
||||
|
||||
def audio_window(start_seconds, end_seconds, max_seconds=0.0, duration=None) -> dict:
|
||||
|
||||
@@ -40,8 +40,8 @@ def list_pack_modules(pack_dir=PACK_DIR):
|
||||
|
||||
|
||||
def is_pack_module_path(request_path, pack_name):
|
||||
"""True for /extensions/<pack_name>/.../*.mjs."""
|
||||
return request_path.startswith(f"/extensions/{pack_name}/") and request_path.endswith(".mjs")
|
||||
"""True for /extensions/<pack_name>/.../*.mjs (including behind a path prefix)."""
|
||||
return f"/extensions/{pack_name}/" in request_path and request_path.endswith(".mjs")
|
||||
|
||||
|
||||
def compute_module_token(js_root):
|
||||
|
||||
+117
-12
@@ -88,16 +88,46 @@ MOVES_KEPT = (
|
||||
"keep the source performer's face, body, clothes, props, background and colours out of the target.\n"
|
||||
"<Video 1>: attribute_transfer - apply only its camera movement to the target scene."
|
||||
)
|
||||
SWAP_IS = (
|
||||
"<Video 1> supplies the place, the action, the timing, the camera movement and the look.\n"
|
||||
"{replaced} is {who} in <Video 1>, selected for replacement."
|
||||
)
|
||||
SWAP_KEPT = (
|
||||
SWAP_VIDEO = "<Video 1> supplies the place, the action, the timing, the camera movement and the look."
|
||||
SWAP_IS = "{replaced} is {who} in <Video 1>, selected for replacement."
|
||||
SWAP_VIDEO_KEPT = (
|
||||
"<Video 1>: partially_preserved - keep the place, the action, the timing, the camera movement and the look; "
|
||||
"change only the person.\n"
|
||||
"{replaced}: removed - replace {who} with {main} throughout."
|
||||
"change only the {people}."
|
||||
)
|
||||
SWAP_KEPT = "{replaced}: removed - replace {who} with {main} throughout."
|
||||
ONE_GOES = "the original person"
|
||||
# Where each person stands when two or more go and the script doesn't say, from left to right like the pictures.
|
||||
SPOTS = {2: ("left", "right"), 3: ("left", "centre", "right"), 4: ("far left", "left", "right", "far right")}
|
||||
SAYS_WHO = re.compile(
|
||||
r"^(?:is|are|replaces|takes\s+the\s+place\s+of|goes\s+in\s+for|swaps?\s+(?:in\s+)?for|in\s+place\s+of|for|as)\b\s*"
|
||||
r"|^[:=\-\u2013\u2014>]+\s*",
|
||||
re.I,
|
||||
)
|
||||
# "he replaces the person at the counter": the words after one of these are who goes, wherever they stand in the line.
|
||||
REPLACES = re.compile(
|
||||
r"\b(?:replaces|replacing|takes\s+the\s+place\s+of|taking\s+the\s+place\s+of|in\s+place\s+of|instead\s+of"
|
||||
r"|swaps?\s+(?:in\s+)?for|goes\s+in\s+for|stands?\s+in\s+for)\s+",
|
||||
re.I,
|
||||
)
|
||||
# "Elon is @1," names the new person, not the one who goes.
|
||||
# "the man on the left, Dario is" ends where the next person's name starts.
|
||||
NAMES_NEXT = re.compile(r",?\s*(?:[A-Z][\w'.-]*\s+)+(?:is|as|plays)\s*$")
|
||||
NAMES_THE_NEW = re.compile(r"^[^@.;]*?\b(?:is|as|plays)\s+@\d\b[\s,.;]*", re.I)
|
||||
SWAPPED = "attribute_transfer - apply the defined appearance to the original performance; the background of {picture} is not transferred."
|
||||
# "keeps the outfit", "in the clip's clothes": the new person wears the clothes of the one who goes, not the picture's.
|
||||
KEEP_CLOTHES = (
|
||||
r"\bkeep(?:s|ing)?\s+(?:the\s+|his\s+|her\s+|their\s+)?(?:original\s+|same\s+)?(?:outfit|clothes|clothing)\b"
|
||||
r"(?:\s+(?:from|of|in)\s+the\s+(?:clip|video|original))?"
|
||||
r"|\b(?:in|wearing|wears)\s+the\s+(?:clip'?s|video'?s|original)\s+(?:outfit|clothes|clothing)\b"
|
||||
r"|\b(?:outfit|clothes|clothing)\s+(?:from|of)\s+the\s+(?:clip|video)\b"
|
||||
)
|
||||
CLIP_CLOTHES = re.compile(KEEP_CLOTHES, re.I)
|
||||
CLIP_CLOTHES_CUT = re.compile(rf",?\s*(?:\band\s+)?(?:{KEEP_CLOTHES})", re.I)
|
||||
CLOTHED_SWAP = "{subject} is the person in {picture}, with the same face and hair, wearing the clothes of {who} in <Video 1>."
|
||||
CLOTHES_STAY = (
|
||||
"attribute_transfer - apply only the face and hair to the original performance; the clothes of {who} in <Video 1> "
|
||||
"stay as they are; the background of {picture} is not transferred."
|
||||
)
|
||||
KEEP_IS = (
|
||||
"<Video 1> is the video being continued, and the target video starts from its last moment.\n"
|
||||
"{main} is the main subject in <Video 1>, with the same look."
|
||||
@@ -115,6 +145,22 @@ REVOICE_KEPT = (
|
||||
)
|
||||
|
||||
|
||||
def who_goes_in(text):
|
||||
"""Who a typed swap line says goes, without the rest of the line: "Elon is @1, he replaces the man at the
|
||||
counter" is the man at the counter."""
|
||||
replaces = REPLACES.search(text)
|
||||
text = text[replaces.end() :] if replaces else NAMES_THE_NEW.sub("", text)
|
||||
text = re.split(r"[.;!?]", text.strip(" .;!?"), maxsplit=1)[0]
|
||||
text = NAMES_NEXT.sub("", text)
|
||||
text = SAYS_WHO.sub("", TOKEN.sub("", text).strip(" .,;")).strip(" .,;")
|
||||
return re.sub(r"\s+and$", "", text).strip(" .,;")
|
||||
|
||||
|
||||
def lower_first(text):
|
||||
first = text.split(" ", 1)[0]
|
||||
return text if text[:1] == "<" or (len(first) > 1 and first.isupper()) else text[:1].lower() + text[1:]
|
||||
|
||||
|
||||
def subject_label(number):
|
||||
return f"<Subject {number}>"
|
||||
|
||||
@@ -135,11 +181,16 @@ class Picture:
|
||||
subject: int = 0 # A picture that only marks the last frame has none.
|
||||
|
||||
|
||||
def swapped_in(jobs):
|
||||
"""How many people a swap puts in: one for each person or sheet picture."""
|
||||
return max(1, sum(job in (PERSON, SHEET) for job in jobs.values()))
|
||||
|
||||
|
||||
def number_pictures(jobs, video=""):
|
||||
"""Connected pictures in order, from {number on the node: job}. In a swap
|
||||
the person being replaced comes first, as <Subject 1>."""
|
||||
the people being replaced come first, as <Subject 1>, <Subject 2> and on."""
|
||||
pictures = []
|
||||
subjects = 1 if video == SWAP else 0
|
||||
subjects = swapped_in(jobs) if video == SWAP else 0
|
||||
for picture, slot in enumerate(sorted(jobs), 1):
|
||||
subjects += jobs[slot] != END
|
||||
pictures.append(Picture(slot, jobs[slot], picture, 0 if jobs[slot] == END else subjects))
|
||||
@@ -154,15 +205,62 @@ class Cast:
|
||||
self.video = video
|
||||
last = max((picture.subject for picture in pictures), default=0)
|
||||
self.moves = subject_label(last + 1) if video == MOVES else ""
|
||||
# In a swap each person or sheet picture takes one person's place in the video, in picture order. The script
|
||||
# can name who goes: "the child in the seat", or "@1 the man on the left. @2 the woman in red".
|
||||
self.swappers = [picture for picture in pictures if picture.job in (PERSON, SHEET)] if video == SWAP else []
|
||||
goes = len(self.swappers)
|
||||
self.whos = [f"the person on the {spot}" for spot in SPOTS[goes]] if goes > 1 else [ONE_GOES]
|
||||
self.replaced = subject_label(1) if video == SWAP else ""
|
||||
self.who = "the original person" # In a swap the script can name who goes: "the child in the seat".
|
||||
self.in_video = subject_label(last + 1) if video in (KEEP, REVOICE) else ""
|
||||
self.people = [picture for picture in pictures if picture.job in (PERSON, SHEET, START)]
|
||||
self.subjects = {picture.slot: subject_label(picture.subject) for picture in pictures if picture.subject}
|
||||
self.clip_clothes = False # A swap that keeps the clothes of whoever goes.
|
||||
self.clip_sound = False # A swap whose finished clip keeps the video's own sound.
|
||||
self.speaks_as_video = False # With that sound and no words typed: the new person says what the video says.
|
||||
self.typed_lines = False # The script typed words in quotes for someone to say.
|
||||
|
||||
def of(self, *jobs):
|
||||
return [picture for picture in self.pictures if picture.job in jobs]
|
||||
|
||||
@property
|
||||
def who(self):
|
||||
return self.whos[0]
|
||||
|
||||
@who.setter
|
||||
def who(self, words):
|
||||
self.whos[0] = words
|
||||
|
||||
@property
|
||||
def swaps(self):
|
||||
"""(who goes, the words for them, who comes in) for each person a swap replaces."""
|
||||
coming = [subject_label(picture.subject) for picture in self.swappers] or [self.main]
|
||||
return [(subject_label(number), who, new) for number, (who, new) in enumerate(zip(self.whos, coming), 1)]
|
||||
|
||||
def goes_for(self, picture):
|
||||
"""Who a swapping picture's person replaces, in the words of the script."""
|
||||
new = subject_label(picture.subject)
|
||||
return next((who for _, who, coming in self.swaps if coming == new), self.who)
|
||||
|
||||
def name_who_goes(self, lines):
|
||||
"""Who each new person replaces, from the lines typed in a swap. With one, the words are who goes. With more,
|
||||
each @ names one, or else the lines go in picture order; anything else keeps left to right."""
|
||||
self.clip_clothes = any(CLIP_CLOTHES.search(line) for line in lines)
|
||||
lines = [CLIP_CLOTHES_CUT.sub("", line).strip() for line in lines]
|
||||
lines = [line for line in lines if line.strip(" .")]
|
||||
text = ". ".join(line.rstrip(" .") for line in lines) # Each line ends where its who-goes words end.
|
||||
if len(self.whos) == 1:
|
||||
self.who = lower_first(who_goes_in(text) or ONE_GOES)
|
||||
return
|
||||
slots = [picture.slot for picture in self.swappers]
|
||||
pieces = re.split(r"@(\d)\b", text)
|
||||
named = {int(slot): words for slot, words in zip(pieces[1::2], pieces[2::2]) if int(slot) in slots}
|
||||
if not named:
|
||||
named = dict(zip(slots, lines)) if len(lines) == len(slots) else {}
|
||||
for index, slot in enumerate(slots):
|
||||
words = who_goes_in(named.get(slot, ""))
|
||||
if words:
|
||||
self.whos[index] = lower_first(words)
|
||||
|
||||
@property
|
||||
def main(self):
|
||||
"""Who a line is about when it names nobody."""
|
||||
@@ -222,7 +320,8 @@ class Cast:
|
||||
ends = self.ends
|
||||
lines = []
|
||||
if self.video == SWAP:
|
||||
lines.append(SWAP_IS.format(replaced=self.replaced, who=self.who))
|
||||
lines.append(SWAP_VIDEO)
|
||||
lines += [SWAP_IS.format(replaced=gone, who=who) for gone, who, _ in self.swaps]
|
||||
worn = set(wearers.values())
|
||||
for picture in self.pictures:
|
||||
if picture.job == END:
|
||||
@@ -233,6 +332,9 @@ class Cast:
|
||||
elif picture.job == PERSON and redraw:
|
||||
lines.append(REDRAWN[0].format(**words))
|
||||
continue
|
||||
elif self.clip_clothes and picture in self.swappers:
|
||||
lines.append(CLOTHED_SWAP.format(who=self.goes_for(picture), **words))
|
||||
continue
|
||||
elif picture.job == PERSON and words["subject"] in worn:
|
||||
lines.append(DRESSED[0].format(**words))
|
||||
continue
|
||||
@@ -255,6 +357,8 @@ class Cast:
|
||||
label = picture_label(picture.picture)
|
||||
wearing = [slot for slot, wearer in wearers.items() if wearer == subject]
|
||||
if self.video == SWAP and picture.job in (PERSON, SHEET):
|
||||
if self.clip_clothes:
|
||||
return CLOTHES_STAY.format(who=self.goes_for(picture), picture=label)
|
||||
return SWAPPED.format(picture=label)
|
||||
if picture.job == PERSON and redraw:
|
||||
words = REDRAWN[1]
|
||||
@@ -276,7 +380,8 @@ class Cast:
|
||||
|
||||
lines = []
|
||||
if self.video == SWAP:
|
||||
lines.append(SWAP_KEPT.format(replaced=self.replaced, who=self.who, main=self.main))
|
||||
lines.append(SWAP_VIDEO_KEPT.format(people="people" if len(self.swaps) > 1 else "person"))
|
||||
lines += [SWAP_KEPT.format(replaced=gone, who=who, main=new) for gone, who, new in self.swaps]
|
||||
for picture in self.pictures:
|
||||
if picture.job == END:
|
||||
continue
|
||||
|
||||
+316
-11
@@ -12,8 +12,21 @@ import re
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ._h3_cast_helpers import KEEP, NEEDS_NO_WORDS, REVOICE, SWAP, Cast, number_pictures
|
||||
from ._h3_script_helpers import ACTION_GOES_ON, Moment, add_moments, add_sound, lower_first, read_script, script_parts, speakers
|
||||
from ._h3_cast_helpers import KEEP, NEEDS_NO_WORDS, ONE_GOES, REVOICE, SWAP, Cast, number_pictures
|
||||
from ._h3_cast_helpers import TOKEN
|
||||
from ._h3_script_helpers import (
|
||||
ACTION_GOES_ON,
|
||||
PRONOUN,
|
||||
SPEECH_VERB,
|
||||
Moment,
|
||||
add_moments,
|
||||
add_sound,
|
||||
read_script,
|
||||
script_parts,
|
||||
speakers,
|
||||
voice_of,
|
||||
without_speech,
|
||||
)
|
||||
|
||||
SECTIONS = (
|
||||
"subject_definitions",
|
||||
@@ -81,16 +94,19 @@ def add_task(summary, task):
|
||||
return "[" + " + ".join(tasks) + "]" + summary[match.end() :]
|
||||
|
||||
|
||||
def spoken_line(speaker, words, language, voice_label, on_screen, number=1, lips=""):
|
||||
def spoken_line(speaker, words, language, voice_label, on_screen, number=1, lips="", delivery=""):
|
||||
"""The sentence that carries the words, with the speaker's mouth stated
|
||||
the way H3 needs it: moving and then closed on screen, closed in a
|
||||
voiceover. lips names a video whose lip movement the words land on."""
|
||||
line = f"<d>[{language}] {words}</d>"
|
||||
said = f"says {delivery}" if delivery else "says"
|
||||
if not speaker.startswith("<"): # A thing off screen, like a card reader.
|
||||
return f"{speaker} (S{number}) {said} from off screen, {line} Nobody on screen moves their lips to these words."
|
||||
if on_screen:
|
||||
how = "says,"
|
||||
how = f"{said}, each word landing exactly on the lip movement of {lips}," if lips else f"{said},"
|
||||
if voice_label:
|
||||
landing = f", each word landing exactly on the lip movement of {lips}," if lips else ""
|
||||
how = f"speaks using the voice timbre referenced from {voice_label}{landing} and says,"
|
||||
how = f"speaks using the voice timbre referenced from {voice_label}{landing} and {said},"
|
||||
return (
|
||||
f"{speaker} (S{number}) {how} {line} The jaw and lips of {speaker} move "
|
||||
f"clearly through every word. {speaker} closes the lips."
|
||||
@@ -100,6 +116,101 @@ def spoken_line(speaker, words, language, voice_label, on_screen, number=1, lips
|
||||
return f"{speaker} (S{number}) says in an off-screen voiceover, {line}{closed}{timbre}"
|
||||
|
||||
|
||||
# "Altman is @1" and "@1 is Altman" give a name to a person picture, so a later "Altman says" is @1.
|
||||
NAME_IS = re.compile(r"\b([A-Z][\w'.-]*(?:\s+[A-Z][\w'.-]*)*)\s+(?:is|as|plays)\s+@(\d)\b")
|
||||
IS_NAME = re.compile(r"@(\d)\s+(?:is|as|plays)\s+([A-Z][\w'.-]*(?:\s+[A-Z][\w'.-]*)*)\b")
|
||||
NOT_A_NAME = {"The", "A", "An", "He", "She", "They", "It", "This", "That", "Then", "And"}
|
||||
DELIVERY = re.compile(r"^(?:in|with|like)\b[^\"]*$", re.I)
|
||||
|
||||
|
||||
def names_in(moments):
|
||||
"""{name: @ number} for every person a swap's lines name."""
|
||||
names = {}
|
||||
for moment in moments:
|
||||
for name, slot in NAME_IS.findall(moment.text):
|
||||
if name not in NOT_A_NAME:
|
||||
names[name.lower()] = slot
|
||||
for slot, name in IS_NAME.findall(moment.text):
|
||||
if name not in NOT_A_NAME:
|
||||
names[name.lower()] = slot
|
||||
return names
|
||||
|
||||
|
||||
LINKING_WORDS = {"the", "a", "an", "in", "on", "at", "of", "with", "by", "to", "and", "from", "into", "who", "is"}
|
||||
|
||||
|
||||
def same_person(said_by, who):
|
||||
"""Whether "The man in the car" names the person a swap's "the man in the
|
||||
back of the police car" points at: every word that says who it is
|
||||
appears there too. "The man in the cap" is someone else."""
|
||||
def words(text):
|
||||
return {word for word in re.findall(r"[a-z']+", text.lower()) if word not in LINKING_WORDS}
|
||||
|
||||
named = words(said_by)
|
||||
return bool(named) and named <= words(who)
|
||||
|
||||
|
||||
def only_the_words(moment, names=None, last="", goes=()):
|
||||
"""A swap line's quoted words and the @ number that says them. The video
|
||||
supplies the action, so the rest of the line goes. Who speaks is the @ or
|
||||
the name in front of the words, else the person a he or she points back
|
||||
to; a thing that is no person, like a card reader, speaks from off screen,
|
||||
and so does a person the line puts off screen, like "a woman off camera"."""
|
||||
before = moment.text.partition(f'"{moment.speech}"')[0]
|
||||
who = TOKEN.search(before)
|
||||
slot = who.group(1) if who else ""
|
||||
if not slot:
|
||||
lowered = before.lower()
|
||||
slot = next((number for name, number in (names or {}).items() if re.search(rf"\b{re.escape(name)}\b", lowered)), "")
|
||||
if not slot and PRONOUN.search(before):
|
||||
slot = last
|
||||
words = Moment(f'says "{moment.speech}"', at=moment.at, speech=moment.speech, voiceover=moment.voiceover)
|
||||
verb = SPEECH_VERB.search(before)
|
||||
if verb and DELIVERY.match(before[verb.end() :].strip(" ,:")):
|
||||
words.delivery = before[verb.end() :].strip(" ,:")
|
||||
said_by = "" if slot else voice_of(before, True, things=True)
|
||||
if said_by and not moment.voiceover:
|
||||
slot = next((number for who, number in goes if same_person(said_by, who)), "")
|
||||
if slot:
|
||||
words.text = f"@{slot} {words.text}"
|
||||
return words, slot
|
||||
words.voice = voice_of(before, moment.voiceover, things=True)
|
||||
words.voiceover = words.voiceover or bool(words.voice)
|
||||
words.other = bool(said_by) and not words.voice
|
||||
return words, last
|
||||
|
||||
|
||||
# A time typed inside a swap line, not at its start: "Dario, at 3.2s, says" or "(00:03.2)".
|
||||
TIME_IN_LINE = re.compile(r"\b(?:(\d+):(\d\d(?:\.\d+)?)|(\d+(?:\.\d+)?)\s*(?:seconds?|secs?|s)\b)", re.I)
|
||||
|
||||
|
||||
def time_in(text):
|
||||
match = TIME_IN_LINE.search(text)
|
||||
if not match:
|
||||
return None
|
||||
if match.group(1) is not None:
|
||||
return int(match.group(1)) * 60 + float(match.group(2))
|
||||
return float(match.group(3))
|
||||
|
||||
|
||||
def swap_lines(moments, goes=()):
|
||||
"""What a swap keeps of its typed lines: only the words in quotes. goes
|
||||
pairs the words for each person who goes with the @ number coming in, so
|
||||
a line said by "the old man" is said by whoever replaces him."""
|
||||
names = names_in(moments)
|
||||
kept, last = [], ""
|
||||
for moment in moments:
|
||||
who = TOKEN.search(moment.text)
|
||||
if not moment.speech:
|
||||
last = who.group(1) if who else last
|
||||
continue
|
||||
words, last = only_the_words(moment, names, last, goes)
|
||||
if words.at is None:
|
||||
words.at = time_in(moment.text.partition(f'"{moment.speech}"')[0])
|
||||
kept.append(words)
|
||||
return kept
|
||||
|
||||
|
||||
def snap_cuts(detailed, beats, gap=0.4):
|
||||
"""Moves every timed moment to the nearest beat, keeping them in order and
|
||||
at least `gap` seconds apart."""
|
||||
@@ -159,13 +270,17 @@ def add_song_under(parts, beats):
|
||||
|
||||
|
||||
def write_prompt(script, seconds=15.0, camera="", look="", sound="", language="English", voice=False, song_beats=None,
|
||||
ask=None, jobs=None, video="", soundtrack=False):
|
||||
ask=None, jobs=None, video="", soundtrack=False, clip_sound=False, speech=None):
|
||||
"""The finished prompt.
|
||||
|
||||
A typed scene is read and laid out over seconds; H3's own six parts go
|
||||
through as they are. jobs says what each connected picture is for, as
|
||||
{number on the node: job}, and video what a connected video is for.
|
||||
soundtrack says a video's own sound is connected, for H3 to carry on from.
|
||||
clip_sound says a swap's finished clip keeps the video's own sound, so
|
||||
every voice in it is heard: lines from anyone but the people coming in are
|
||||
left out, and lines with no typed time go where speech finds them in the
|
||||
video's sound, as find_speech stretches.
|
||||
voice says whether one is connected. song_beats is None with no song, and
|
||||
otherwise the beats to land on, which may be an empty list. With a voice
|
||||
too, the song goes under the finished clip and H3 hears none. ask(question,
|
||||
@@ -184,8 +299,17 @@ def write_prompt(script, seconds=15.0, camera="", look="", sound="", language="E
|
||||
if not scene.moments and video not in NEEDS_NO_WORDS:
|
||||
raise ValueError(NO_SCRIPT)
|
||||
if video == SWAP and scene.moments: # Typed words in a swap say which person goes, for a clip with two.
|
||||
cast.who = lower_first(" ".join(moment.text for moment in scene.moments).rstrip(". "))
|
||||
scene.moments = []
|
||||
cast.name_who_goes([without_speech(moment) for moment in scene.moments])
|
||||
goes = [(who, str(picture.slot)) for who, picture in zip(cast.whos, cast.swappers) if who != ONE_GOES]
|
||||
scene.moments = swap_lines(scene.moments, goes)
|
||||
if video == SWAP and clip_sound:
|
||||
# On real clips, leaving others' words out kept the right mouth moving; naming them as off screen did not.
|
||||
cast.clip_sound = True
|
||||
# With no words typed, the clip's own speech is still heard, so the new person says it as the video does.
|
||||
cast.typed_lines = any(moment.speech for moment in scene.moments)
|
||||
cast.speaks_as_video = bool(speech) and not cast.typed_lines
|
||||
time_from_sound(scene.moments, speech)
|
||||
scene.moments = [moment for moment in scene.moments if not (moment.voice or moment.other)]
|
||||
if not scene.moments:
|
||||
scene.moments.append(Moment(ACTION_GOES_ON if video == KEEP else ""))
|
||||
if voice and not any(moment.speech for moment in scene.moments):
|
||||
@@ -201,9 +325,10 @@ def write_prompt(script, seconds=15.0, camera="", look="", sound="", language="E
|
||||
|
||||
if parts is None:
|
||||
|
||||
def say(words, on_screen=True, who="<Subject 1>", number=1):
|
||||
lips = "<Video 1>" if video == REVOICE else ""
|
||||
return spoken_line(who, clean_words(words), language, voice_label if number == 1 else None, on_screen, number, lips)
|
||||
def say(words, on_screen=True, who="<Subject 1>", number=1, delivery=""):
|
||||
lips = "<Video 1>" if video in (REVOICE, SWAP) else ""
|
||||
voiced = voice_label if number == 1 else None
|
||||
return spoken_line(who, clean_words(words), language, voiced, on_screen, number, lips, delivery)
|
||||
|
||||
keeps_song = song_beats is not None and not voice # The song is then all the sound there is.
|
||||
parts = script_parts(
|
||||
@@ -266,3 +391,183 @@ def find_beats(samples, rate, seconds=30.0):
|
||||
step = period / per_second
|
||||
count = int((len(samples) / rate - start) / step) + 1
|
||||
return [round(start + index * step, 3) for index in range(max(count, 0))]
|
||||
|
||||
|
||||
SPEECH_BAND = (250.0, 3500.0) # Where most of a voice's loudness sits, under hiss and over hum.
|
||||
SPEECH_GAP = 0.06 # A shorter quiet is the dip between two syllables. place_lines tells lines apart later.
|
||||
SPEECH_SHORTEST = 0.1 # A shorter sound is a click or a knock.
|
||||
VOICE_PITCH = (70.0, 390.0) # A speaking voice's lowest note, in Hz, and just under where pitch reading tops out.
|
||||
CLEAR_VOICE = 0.5 # How cleanly a frame repeats before its pitch counts.
|
||||
PITCH_GRID = tuple(float(x) for x in np.log2(np.geomspace(100, 340, 8))) # Voices to try, in octaves.
|
||||
SPEAKING_RATES = tuple(2.5 + 0.75 * step for step in range(8)) # Syllables a second, slow to fast.
|
||||
LONGEST_LINE = 12 # Stretches of speech one line can take.
|
||||
# Weights for place_lines, tuned on nine real clips against Whisper's times.
|
||||
GAP_WEIGHT, INNER_GAP_WEIGHT, NOISE_WEIGHT, PITCH_WEIGHT = 1.0, 1.0, 1.5, 6.0
|
||||
|
||||
|
||||
def find_speech(samples, rate):
|
||||
"""[(start, end, pitch, voiced), ...]: the stretches of a mono track loud
|
||||
enough in the voice band to be words, in seconds, with each stretch's
|
||||
middle pitch in octaves (log2 Hz), or None where it has too little clear
|
||||
voice, and how many seconds of it are clear voice.
|
||||
|
||||
The quiet level is the track's quietest tenth and the loud level its
|
||||
loudest twentieth, so music or a crowd under the words raises the bar
|
||||
with them. Returns [] for silence."""
|
||||
samples = np.asarray(samples, dtype=np.float32)
|
||||
hop = max(1, int(rate * 0.01))
|
||||
window = hop * 2
|
||||
frames = (len(samples) - window) // hop
|
||||
if frames < 8 or float(np.abs(samples).max(initial=0.0)) < 1e-4:
|
||||
return []
|
||||
taper = np.hanning(window)
|
||||
pitches = np.fft.rfftfreq(window, 1.0 / rate)
|
||||
band = (pitches >= SPEECH_BAND[0]) & (pitches <= SPEECH_BAND[1])
|
||||
level = np.array(
|
||||
[float((np.abs(np.fft.rfft(samples[index * hop : index * hop + window] * taper))[band] ** 2).sum()) for index in range(frames)]
|
||||
)
|
||||
level = 10 * np.log10(level + 1e-10)
|
||||
level = np.convolve(level, np.ones(5) / 5, "same")
|
||||
quiet, loud = np.percentile(level, 10), np.percentile(level, 95)
|
||||
if loud - quiet < 6:
|
||||
return []
|
||||
heard = level > max(quiet + 0.35 * (loud - quiet), quiet + 6)
|
||||
stretches, start = [], None
|
||||
for index, on in enumerate([*heard, False]):
|
||||
if on and start is None:
|
||||
start = index
|
||||
elif not on and start is not None:
|
||||
stretches.append([start * hop / rate, index * hop / rate])
|
||||
start = None
|
||||
joined = []
|
||||
for stretch in stretches:
|
||||
if joined and stretch[0] - joined[-1][1] < SPEECH_GAP:
|
||||
joined[-1][1] = stretch[1]
|
||||
else:
|
||||
joined.append(stretch)
|
||||
voice = voice_pitch(samples, rate, loudest=float(level.max()))
|
||||
return [(round(a, 3), round(b, 3), *middle_pitch(voice, a, b)) for a, b in joined if b - a >= SPEECH_SHORTEST]
|
||||
|
||||
|
||||
def voice_pitch(samples, rate, loudest):
|
||||
"""(times, octaves, clear) every 20 ms: the pitch where a frame repeats
|
||||
best, and whether it repeats cleanly and loudly enough to be a voice."""
|
||||
hop = max(1, int(rate * 0.02))
|
||||
window = hop * 2
|
||||
frames = max(0, (len(samples) - window) // hop)
|
||||
shortest, longest = int(rate / VOICE_PITCH[1]), int(rate / VOICE_PITCH[0])
|
||||
taper = np.hanning(window)
|
||||
pitches = np.fft.rfftfreq(window, 1.0 / rate)
|
||||
band = (pitches >= SPEECH_BAND[0]) & (pitches <= SPEECH_BAND[1])
|
||||
times, octaves, clear = [], [], []
|
||||
for index in range(frames):
|
||||
piece = samples[index * hop : index * hop + window]
|
||||
loud = 10 * np.log10(float((np.abs(np.fft.rfft(piece * taper))[band] ** 2).sum()) + 1e-10)
|
||||
piece = piece - piece.mean()
|
||||
repeats = np.fft.irfft(np.abs(np.fft.rfft(piece, 2 * window)) ** 2)[:window]
|
||||
lag = shortest + int(np.argmax(repeats[shortest:longest])) if repeats[0] > 1e-9 else 0
|
||||
times.append(index * hop / rate + window / 2 / rate)
|
||||
octaves.append(float(np.log2(rate / lag)) if lag else 0.0)
|
||||
clear.append(bool(lag) and repeats[lag] / repeats[0] >= CLEAR_VOICE and loud >= loudest - 30 and lag > shortest)
|
||||
return np.array(times), np.array(octaves), np.array(clear, dtype=bool)
|
||||
|
||||
|
||||
def middle_pitch(voice, start, end):
|
||||
"""(the middle pitch in octaves, or None, and the seconds of clear voice) between start and end."""
|
||||
times, octaves, clear = voice
|
||||
inside = clear & (times >= start) & (times < end)
|
||||
if inside.sum() < 3:
|
||||
return None, 0.0
|
||||
return round(float(np.median(octaves[inside])), 3), round(float(inside.sum() * (times[1] - times[0])), 3)
|
||||
|
||||
|
||||
def syllables(words):
|
||||
"""About how many syllables the words take to say."""
|
||||
count = 0
|
||||
for word in re.findall(r"[a-z']+", words.lower()):
|
||||
sounds = len(re.findall(r"[aeiouy]+", word))
|
||||
count += max(1, sounds - (1 if word.endswith("e") and sounds > 1 else 0))
|
||||
return max(1, count)
|
||||
|
||||
|
||||
def place_lines(stretches, lines):
|
||||
"""When each line starts, read from find_speech stretches. lines are
|
||||
(words, voice) in order, where voice tells one speaker from another.
|
||||
|
||||
Each line takes a run of stretches, and a stretch may be left out as
|
||||
noise. The best split gives each line about the time its syllables need,
|
||||
starts lines after long quiets, keeps quiets inside a line short and
|
||||
keeps one voice at one pitch. None when there are fewer stretches than
|
||||
lines."""
|
||||
count, found = len(lines), len(stretches)
|
||||
if count < 1 or found < count:
|
||||
return None
|
||||
sizes = [syllables(words) for words, _ in lines]
|
||||
voices = sorted({voice for _, voice in lines})
|
||||
best = (np.inf, None)
|
||||
for middles in _pitch_choices(len(voices)):
|
||||
middle = dict(zip(voices, middles))
|
||||
for speed in SPEAKING_RATES:
|
||||
cost, starts = _best_split(stretches, lines, [size / speed for size in sizes], middle)
|
||||
cost += 0.3 * np.log(speed / 4.5) ** 2
|
||||
if starts is not None and cost < best[0]:
|
||||
best = (cost, starts)
|
||||
return best[1]
|
||||
|
||||
|
||||
def _pitch_choices(voices):
|
||||
choices = [()]
|
||||
for _ in range(voices):
|
||||
choices = [(*choice, pitch) for choice in choices for pitch in PITCH_GRID]
|
||||
return choices
|
||||
|
||||
|
||||
def _best_split(stretches, lines, needs, middle):
|
||||
found, count = len(stretches), len(lines)
|
||||
best = [[(np.inf, None)] * (count + 1) for _ in range(found + 1)]
|
||||
best[0][0] = (0.0, [])
|
||||
for first in range(found):
|
||||
for done in range(count + 1):
|
||||
cost, starts = best[first][done]
|
||||
if cost == np.inf:
|
||||
continue
|
||||
start, end = stretches[first][:2]
|
||||
noise = cost + NOISE_WEIGHT * (end - start) + 0.2
|
||||
if noise < best[first + 1][done][0]:
|
||||
best[first + 1][done] = (noise, starts)
|
||||
if done == count:
|
||||
continue
|
||||
before = min(start - stretches[first - 1][1], 1.0) if first else 1.0
|
||||
spoken = inner = off_pitch = 0.0
|
||||
for last in range(first, min(found, first + LONGEST_LINE)):
|
||||
a, b, pitch, voiced = stretches[last]
|
||||
spoken += b - a
|
||||
if last > first:
|
||||
inner += max(0.0, a - stretches[last - 1][1] - 0.25)
|
||||
if pitch is not None:
|
||||
off_pitch += min((pitch - middle[lines[done][1]]) ** 2, 1.0) * voiced
|
||||
after = min(stretches[last + 1][0] - b, 1.0) if last + 1 < found else 1.0
|
||||
total = (
|
||||
cost
|
||||
+ np.log(spoken / needs[done]) ** 2
|
||||
+ INNER_GAP_WEIGHT * inner
|
||||
- GAP_WEIGHT * (before + after) / 2
|
||||
+ PITCH_WEIGHT * off_pitch
|
||||
)
|
||||
if total < best[last + 1][done + 1][0]:
|
||||
best[last + 1][done + 1] = (total, [*starts, start])
|
||||
return best[found][count]
|
||||
|
||||
|
||||
def time_from_sound(moments, stretches):
|
||||
"""Puts each line with no typed time where its words start in the clip's
|
||||
own sound. Every quoted line counts, whoever says it, since everyone is
|
||||
heard. Returns whether the sound gave the times."""
|
||||
lines = [(moment.speech, "other" if moment.voice or moment.other else "new") for moment in moments]
|
||||
starts = place_lines(stretches or [], lines)
|
||||
if starts is None:
|
||||
return False
|
||||
for moment, start in zip(moments, starts):
|
||||
if moment.at is None:
|
||||
moment.at = round(float(start), 3)
|
||||
return True
|
||||
|
||||
+78
-15
@@ -25,7 +25,10 @@ from ._h3_cast_helpers import (
|
||||
SOUND_GOES_ON_KEPT,
|
||||
THING,
|
||||
TOKEN,
|
||||
ONE_GOES,
|
||||
SPOTS,
|
||||
Cast,
|
||||
lower_first,
|
||||
number_pictures,
|
||||
picture_label,
|
||||
subject_label,
|
||||
@@ -50,14 +53,16 @@ TIMED = re.compile(
|
||||
r"(?:\s*-\s*\d+(?:\.\d+)?\s*(?:" + UNIT + r")?)?\]?\s*[:,\-]?\s*",
|
||||
re.I,
|
||||
)
|
||||
NUMBER = re.compile(r"^\s*(?:\d+[.)]|[-*•]|(?:first|second|third|fourth|fifth|sixth)(?:ly)?\b[:,]?|step\s+\d+[:.]?)\s*", re.I)
|
||||
NUMBER = re.compile(r"^\s*(?:\d+[.)](?!\d)|[-*•]|(?:first|second|third|fourth|fifth|sixth)(?:ly)?\b[:,]?|step\s+\d+[:.]?)\s*", re.I)
|
||||
LABEL = re.compile(r"^(camera|style|look|sound|sounds|audio|music)\s*:\s*(.*)$", re.I)
|
||||
SPEECH_CUE = re.compile(
|
||||
r"\b(?:says?|said|asks?|whispers?|shouts?|yells?|screams?|mutters?|sings?|replies|answers?|tells?\s+\w+|goes)\b[^\"]{0,24}$",
|
||||
r"\b(?:says?|said|asks?|whispers?|shouts?|yells?|screams?|mutters?|sings?|raps?|chants?|announces?|calls?\s+out|cries"
|
||||
r"|replies|answers?|tells?\s+\w+|goes)\b[^\"]{0,24}$",
|
||||
re.I,
|
||||
)
|
||||
SIGN_CUE = re.compile(
|
||||
r"\b(?:title|titled|called|named|sign|signs|text|caption|card|poster|reads?|saying|logo|banner|headline|words?)\b[^\"]{0,24}$",
|
||||
r"\b(?:title|titled|called|named|sign|signs|text|caption|card(?!\s+reader)|poster|reads?|saying|logo|banner|headline|words?)\b"
|
||||
r"[^\"]{0,24}$",
|
||||
re.I,
|
||||
)
|
||||
NO_MUSIC = re.compile(r"\b(?:no|without|skip)\s+(?:any\s+)?(?:music|soundtrack|score)\b|\bjust\s+(?:the\s+)?(?:ambien\w+|sound of|natural sound)\b", re.I)
|
||||
@@ -68,7 +73,21 @@ SIDE_NOTE = re.compile(
|
||||
r"|one\s+(?:long\s+)?(?:shot|take)|single\s+(?:shot|take)|continuous\s+(?:shot|take))\s*[.!]?\s*$",
|
||||
re.I,
|
||||
)
|
||||
VOICEOVER = re.compile(r"\b(?:voice-?\s?over|narrator|off-?\s?screen|a\s+voice)\b", re.I)
|
||||
VOICEOVER = re.compile(r"\b(?:voice-?\s?over|narrator|off-?\s?(?:screen|camera)|behind\s+the\s+camera|a\s+voice)\b|\((?:o\.?s|v\.?o)\.?\)", re.I)
|
||||
# Where a voice comes from, said beside who it is: "A woman off screen says", "Mom (O.S.):".
|
||||
FROM_OFF_SCREEN = re.compile(
|
||||
r"\b(?:(?:(?:in|as)\s+(?:an?\s+)?(?:off-?\s?screen\s+)?)?voice-?\s?over|off-?\s?(?:screen|camera)|behind\s+the\s+camera)\b|\((?:o\.?s|v\.?o)\.?\)", re.I
|
||||
)
|
||||
PRONOUN = re.compile(r"\b(?:he|she|they|him|her|them)\b", re.I)
|
||||
PERSON_WORDS = re.compile(
|
||||
r"\b(?:man|men|woman|women|person|people|guy|girl|boy|kid|child|lady|gentleman|cashier|customer|clerk|waiter"
|
||||
r"|waitress|driver|worker|singer|rapper|dancer|host|friend|everyone|someone|somebody)\b",
|
||||
re.I,
|
||||
)
|
||||
# "says in a flat robot voice": how it is said, kept out of who says it.
|
||||
SPEECH_VERB = re.compile(r"\b(?:says?|said|asks?|whispers?|shouts?|yells?|screams?|mutters?|sings?|raps?|chants?|announces?|calls?\s+out|cries|replies|answers?)\b", re.I)
|
||||
SAID_BY = re.compile(r"^(?:(?:the|a|an)\s+[\w\s-]+|someone|somebody)$", re.I)
|
||||
A_NAME = re.compile(r"^[A-Z][\w'.-]*(?:\s+[A-Z][\w'.-]*)?$")
|
||||
UNSEEN = re.compile(r"\b(?:feels?|thinks?|remembers?|reali[sz]es?|decides?|wants?|hopes?|wonders?|knows?|understands?|believes?|imagines?)\b", re.I)
|
||||
MOVING = re.compile(r"\b(?:pov|walks?|walking|runs?|running|forward|follows?|tracks?)\b", re.I)
|
||||
FRAME_WORDS = re.compile(r"^(?:(?:an?|the)\s+(?:[\w-]+\s+){0,2}?(?:close-?up|wide|medium|shot|view|angle|overhead|profile|insert|aerial)\b|black\b)", re.I)
|
||||
@@ -115,6 +134,11 @@ IN_THE_SHOT = "{main} is in the shot."
|
||||
TAKES_OVER = "{main} takes the place of {who} and does the same thing at the same pace."
|
||||
ONE_PERSON = "Show one person throughout."
|
||||
OTHERS_STAY = "Everyone else stays as they are in <Video 1>."
|
||||
# With the clip's own sound, every voice in it is heard, so the new person's lips say only their own lines.
|
||||
OWN_LINES_ONLY = "{main} keeps the lips closed whenever {main} is not saying a line of {main}'s own."
|
||||
SAYS_NOTHING = "{main} keeps the lips closed and says nothing the whole time."
|
||||
SPEAKS_AS_VIDEO = "{main} speaks as in <Video 1>, each word landing exactly on the lip movement of <Video 1>."
|
||||
CLIP_SOUND = "The original sound of <Video 1> continues throughout."
|
||||
FOLLOWS_CAMERA = "The camera follows the movement of <Video 1>."
|
||||
CARRIES_ON = "The shot carries straight on from the end of <Video 1>."
|
||||
ACTION_GOES_ON = "The action keeps going the way it was heading, and the camera keeps moving the same way."
|
||||
@@ -124,7 +148,6 @@ VIDEO_LOOKS = {
|
||||
KEEP: "The target video keeps the look, colours and camera of <Video 1>.",
|
||||
REVOICE: "The target video keeps the look, light and camera of <Video 1>.",
|
||||
}
|
||||
SPOTS = {2: ("left", "right"), 3: ("left", "centre", "right"), 4: ("far left", "left", "right", "far right")}
|
||||
NAMES_A_SPOT = re.compile(r"\b(?:left|right|behind|in\s+front|foreground|background)\b", re.I)
|
||||
OPENS_ON_THEM = re.compile(r"^(?:She|He)\b")
|
||||
FOLLOWS_A_COMMA = re.compile(r"^(?:A|An|The|She|He|They|It|Her|His|Their|Its|This|That|These|Those|There|Someone|Everyone|Nobody)\b")
|
||||
@@ -234,6 +257,9 @@ class Moment:
|
||||
camera: str = "" # A camera: line typed in this shot's block.
|
||||
sound: str = "" # A sound: line typed in this shot's block.
|
||||
line: int | None = None # The typed line this moment opens, counted from 0.
|
||||
voice: str = "" # Who says the words when it is nobody on screen: "The card reader".
|
||||
delivery: str = "" # How a swap line is said: "in a flat robot voice".
|
||||
other: bool = False # Said by someone who is in no picture, like the man at the counter.
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -306,6 +332,28 @@ def read_notes(text, script):
|
||||
script.one_take = True
|
||||
|
||||
|
||||
def voice_of(before, voiceover, things=False):
|
||||
"""Who says a line from off screen, named by the words in front of it:
|
||||
"A woman off screen says" is a woman and "a card reader off screen says"
|
||||
a card reader. With things, a thing needs no "off screen": "The card
|
||||
reader beeps and says" is the card reader. An @, a he or she, or no name
|
||||
at all is someone on screen or the main person's own voiceover, so that
|
||||
gives ""."""
|
||||
if TOKEN.search(before) or PRONOUN.search(before):
|
||||
return ""
|
||||
verb = SPEECH_VERB.search(before)
|
||||
action = FROM_OFF_SCREEN.sub("", before[: verb.start()] if verb else before)
|
||||
action = re.sub(r"[\s,]*\band\s*$", "", action.strip(" ,:")).split(",")[0].strip(" ,:")
|
||||
if len(action.split()) > 2 and action.endswith("s"): # "The card reader beeps": what it does goes.
|
||||
action = action.rsplit(" ", 1)[0]
|
||||
# "Narrator" and "a voice" name no one, so they stay the main person's voiceover.
|
||||
if voiceover and (A_NAME.match(action) or SAID_BY.match(action)) and not VOICEOVER.search(action):
|
||||
return action
|
||||
if things and SAID_BY.match(action) and not PERSON_WORDS.search(action):
|
||||
return action
|
||||
return ""
|
||||
|
||||
|
||||
def quoted_words(moment):
|
||||
"""Quoted words after a sign word stay on screen, as does a bare ALL CAPS
|
||||
quote. Any other quote is words someone says."""
|
||||
@@ -317,6 +365,7 @@ def quoted_words(moment):
|
||||
if not (SIGN_CUE.search(before) or (words.isupper() and not SPEECH_CUE.search(before))):
|
||||
moment.speech = words
|
||||
moment.voiceover = VOICEOVER.search(before) is not None
|
||||
moment.voice = voice_of(before, moment.voiceover)
|
||||
|
||||
|
||||
def read_moment(moment):
|
||||
@@ -539,9 +588,6 @@ def stamp(seconds):
|
||||
return f"{int(minutes):02d}:{rest:06.3f}"
|
||||
|
||||
|
||||
def lower_first(text):
|
||||
first = text.split(" ", 1)[0]
|
||||
return text if text[:1] == "<" or (len(first) > 1 and first.isupper()) else text[:1].lower() + text[1:]
|
||||
|
||||
|
||||
def after_comma(text):
|
||||
@@ -617,7 +663,9 @@ def still_true(moments):
|
||||
|
||||
|
||||
def speaker_of(moment, cast):
|
||||
"""Who says a moment's words: the first person it names, or the main person."""
|
||||
"""Who says a moment's words: an off-screen voice, the first person it names, or the main person."""
|
||||
if moment.voice:
|
||||
return moment.voice
|
||||
people = {picture.slot for picture in cast.people}
|
||||
for slot in TOKEN.findall(moment.text):
|
||||
if int(slot) in people:
|
||||
@@ -632,7 +680,8 @@ def speakers(moments, cast):
|
||||
who = speaker_of(moment, cast)
|
||||
if moment.speech and who not in found:
|
||||
found.append(who)
|
||||
return found
|
||||
# A voice from off screen, like a card reader, comes after the people, who keep the low numbers.
|
||||
return [who for who in found if who.startswith("<")] + [who for who in found if not who.startswith("<")]
|
||||
|
||||
|
||||
def with_labels(words, cast):
|
||||
@@ -669,7 +718,13 @@ def before_the_action(number, text, cast, wearers):
|
||||
words = OPENS_ON if cast.ends else OPENING
|
||||
notes.append(words.format(subject=subject_label(opens.subject), picture=picture_label(opens.picture)))
|
||||
if cast.video == SWAP:
|
||||
notes += [TAKES_OVER.format(main=cast.main, who=cast.who), ONE_PERSON if cast.who == "the original person" else OTHERS_STAY]
|
||||
notes += [TAKES_OVER.format(main=new, who=who) for _, who, new in cast.swaps]
|
||||
notes.append(ONE_PERSON if cast.whos == [ONE_GOES] else OTHERS_STAY)
|
||||
for _, _, new in cast.swaps if cast.clip_sound else []:
|
||||
if cast.speaks_as_video:
|
||||
notes.append(SPEAKS_AS_VIDEO.format(main=new))
|
||||
elif cast.typed_lines:
|
||||
notes.append((OWN_LINES_ONLY if f"{new} (S" in text else SAYS_NOTHING).format(main=new))
|
||||
if number == 1 and cast.video == KEEP:
|
||||
notes += [CARRIES_ON]
|
||||
if number == 1 and cast.video == REVOICE:
|
||||
@@ -678,7 +733,7 @@ def before_the_action(number, text, cast, wearers):
|
||||
if places and not any(place in text for place in places):
|
||||
notes.append(IN_THE_PLACE.format(place=places[0]))
|
||||
people = people_in(text, cast)
|
||||
if len(people) > 1 and not NAMES_A_SPOT.search(text):
|
||||
if len(people) > 1 and not NAMES_A_SPOT.search(text) and cast.video != SWAP: # A swap's people stand where the video has them.
|
||||
spots = [f"{person} is on the {spot}" for person, spot in zip(people, SPOTS[len(people)])]
|
||||
notes.append(sentence(", ".join(spots[:-1]) + f" and {spots[-1]} of the frame"))
|
||||
for slot, wearer in wearers.items():
|
||||
@@ -718,7 +773,7 @@ def shot_lines(script, seconds, say, cast, wearers, move="", hold="", sounds=Tru
|
||||
say, so the moments of one take are sentences inside one shot."""
|
||||
starts = timeline(script, seconds)
|
||||
talking = speakers(script.moments, cast)
|
||||
quiet = " ".join(QUIET.format(speaker=speaker) for speaker in talking)
|
||||
quiet = " ".join(QUIET.format(speaker=speaker) for speaker in talking if speaker.startswith("<"))
|
||||
kept = still_true(script.moments)
|
||||
lines = []
|
||||
for number, shot in enumerate(shots_of(script), 1):
|
||||
@@ -731,6 +786,8 @@ def shot_lines(script, seconds, say, cast, wearers, move="", hold="", sounds=Tru
|
||||
|
||||
def speak(words, on_screen=True, moment=moment):
|
||||
who = speaker_of(moment, cast)
|
||||
if moment.delivery:
|
||||
return say(words, on_screen, who, talking.index(who) + 1, delivery=moment.delivery)
|
||||
return say(words, on_screen, who, talking.index(who) + 1)
|
||||
|
||||
words = " ".join(
|
||||
@@ -746,6 +803,8 @@ def shot_lines(script, seconds, say, cast, wearers, move="", hold="", sounds=Tru
|
||||
text = " ".join(said)
|
||||
first = shot[0]
|
||||
head, said[0] = (cut_to(said[0], text, cast) if number > 1 else ("", said[0]))
|
||||
if not head and starts[first] > 0: # A swap's first words can start late in the video.
|
||||
said[0] = f"At {stamp(starts[first])}, {after_comma(said[0])}"
|
||||
for place, index in enumerate(shot[1:], 1):
|
||||
lead = f"{STILL_MOVING} " if moving else ""
|
||||
said[place] = f"At {stamp(starts[index])}, {lead}{after_comma(said[place])}"
|
||||
@@ -786,7 +845,8 @@ def task_of(cast):
|
||||
def summary_of(script, cast, new_places, soundtrack):
|
||||
task = task_of(cast)
|
||||
if cast.video == SWAP:
|
||||
return f"[{task}] Replace {cast.replaced} with {cast.main} while preserving the original action, place, camera and look of <Video 1>."
|
||||
swaps = " and ".join(f"{gone} with {new}" for gone, _, new in cast.swaps)
|
||||
return f"[{task}] Replace {swaps} while preserving the original action, place, camera and look of <Video 1>."
|
||||
if cast.video == REVOICE:
|
||||
return (
|
||||
f"[{task}] The target video is an edited version of <Video 1>. The picture, the timing and the lip movement "
|
||||
@@ -849,7 +909,10 @@ def script_parts(script, seconds, camera="", look="", sound="", say=None, cast=N
|
||||
ambience = OLD_SOUND
|
||||
if cast.video == REVOICE and ambience == PLACE_SOUND:
|
||||
ambience = ROOM_TONE
|
||||
if ambience != "N/A" and any(moment.speech for moment in script.moments):
|
||||
if cast.clip_sound and ambience == PLACE_SOUND: # The clip's own sound is what plays, unless the script says the sound.
|
||||
ambience = CLIP_SOUND
|
||||
# With the clip's own sound, words the prompt leaves out are still heard.
|
||||
if ambience != "N/A" and any(moment.speech for moment in script.moments) and not cast.clip_sound:
|
||||
ambience = f"{ambience} {ONE_LINE}"
|
||||
return {
|
||||
"subject_definitions": subjects,
|
||||
|
||||
+107
-20
@@ -51,6 +51,17 @@ _SERVER_FOLDERS = {
|
||||
# The Outputs list shows this many, newest first.
|
||||
OUTPUT_LIST_CAP = 2000
|
||||
|
||||
IMAGE_EXTENSIONS = {
|
||||
".avif",
|
||||
".bmp",
|
||||
".gif",
|
||||
".jpeg",
|
||||
".jpg",
|
||||
".png",
|
||||
".tif",
|
||||
".tiff",
|
||||
".webp",
|
||||
}
|
||||
VIDEO_EXTENSIONS = {
|
||||
".avi",
|
||||
".m2ts",
|
||||
@@ -64,34 +75,101 @@ VIDEO_EXTENSIONS = {
|
||||
".webm",
|
||||
}
|
||||
|
||||
_INPUT_FILES_LOCK = threading.Lock()
|
||||
_INPUT_FILES_CACHE: tuple[str, int, float, list[str]] | None = None
|
||||
_INPUT_FILES_TTL = 5.0
|
||||
|
||||
|
||||
def clear_input_files_cache() -> None:
|
||||
global _INPUT_FILES_CACHE
|
||||
with _INPUT_FILES_LOCK:
|
||||
_INPUT_FILES_CACHE = None
|
||||
|
||||
|
||||
def _scan_input_files(root: str) -> list[str]:
|
||||
files: list[str] = []
|
||||
resolved_root = None
|
||||
stack: list[tuple[str, str]] = [(root, "")]
|
||||
while stack:
|
||||
folder, prefix = stack.pop()
|
||||
try:
|
||||
with os.scandir(folder) as entries:
|
||||
for entry in entries:
|
||||
name = entry.name
|
||||
if name.startswith("."):
|
||||
continue
|
||||
try:
|
||||
if entry.is_dir(follow_symlinks=False):
|
||||
stack.append((entry.path, f"{prefix}{name}/"))
|
||||
elif entry.is_file(follow_symlinks=False):
|
||||
files.append(f"{prefix}{name}")
|
||||
elif entry.is_symlink():
|
||||
if resolved_root is None:
|
||||
resolved_root = Path(root).resolve()
|
||||
target = Path(entry.path).resolve(strict=True)
|
||||
if target.is_relative_to(resolved_root) and target.is_file():
|
||||
files.append(f"{prefix}{name}")
|
||||
except (OSError, RuntimeError): # A broken or looping link.
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
files.sort(key=str.casefold)
|
||||
return files
|
||||
|
||||
|
||||
def list_input_files(paths=None) -> list[str]:
|
||||
"""Every non-hidden regular file in ComfyUI's input folder, as a forward-slash
|
||||
path below it, sorted case-insensitively. Shared across the input loaders so
|
||||
/object_info walks the input tree once with os.scandir instead of per-file
|
||||
stat calls on every node."""
|
||||
global _INPUT_FILES_CACHE
|
||||
if paths is None:
|
||||
if folder_paths is None:
|
||||
return []
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
else:
|
||||
input_dir = paths.get_input_directory()
|
||||
root = os.path.normpath(str(input_dir))
|
||||
try:
|
||||
root_mtime = os.stat(root).st_mtime_ns
|
||||
except OSError:
|
||||
return []
|
||||
now = time.monotonic()
|
||||
with _INPUT_FILES_LOCK:
|
||||
cached = _INPUT_FILES_CACHE
|
||||
if (
|
||||
cached is not None
|
||||
and cached[0] == root
|
||||
and cached[1] == root_mtime
|
||||
and (now - cached[2]) < _INPUT_FILES_TTL
|
||||
):
|
||||
return list(cached[3])
|
||||
files = _scan_input_files(root)
|
||||
with _INPUT_FILES_LOCK:
|
||||
_INPUT_FILES_CACHE = (root, root_mtime, time.monotonic(), files)
|
||||
return list(files)
|
||||
|
||||
|
||||
def list_input_images() -> list[str]:
|
||||
if folder_paths is None:
|
||||
return [""]
|
||||
root = Path(folder_paths.get_input_directory())
|
||||
results: list[str] = []
|
||||
for path in root.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
if folder_paths.filter_files_content_types([path.name], ["image"]):
|
||||
results.append(path.relative_to(root).as_posix())
|
||||
except Exception:
|
||||
if path.suffix.lower() in {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".tif", ".tiff"}:
|
||||
results.append(path.relative_to(root).as_posix())
|
||||
return sorted(results, key=str.casefold) or [""]
|
||||
files = list_input_files()
|
||||
try:
|
||||
results = list(folder_paths.filter_files_content_types(files, ["image"]))
|
||||
except Exception:
|
||||
results = [name for name in files if Path(name).suffix.lower() in IMAGE_EXTENSIONS]
|
||||
return results or [""]
|
||||
|
||||
|
||||
def list_input_videos() -> list[str]:
|
||||
if folder_paths is None:
|
||||
return [""]
|
||||
root = Path(folder_paths.get_input_directory())
|
||||
results = [
|
||||
path.relative_to(root).as_posix()
|
||||
for path in root.rglob("*")
|
||||
if path.is_file() and path.suffix.lower() in VIDEO_EXTENSIONS
|
||||
name
|
||||
for name in list_input_files()
|
||||
if Path(name).suffix.lower() in VIDEO_EXTENSIONS
|
||||
]
|
||||
return sorted(results, key=str.casefold) or [""]
|
||||
return results or [""]
|
||||
|
||||
|
||||
def resolve_input_path(selection: str) -> Path:
|
||||
@@ -176,8 +254,8 @@ def _locate_comfy_file(text: str, rule: str) -> tuple[str, str, Path]:
|
||||
return folder, inside, path
|
||||
|
||||
|
||||
def list_output_videos() -> list[str]:
|
||||
"""The videos in ComfyUI's output folder, newest first, each named the
|
||||
def list_output_media(extensions: set[str]) -> list[str]:
|
||||
"""The media in ComfyUI's output folder, newest first, each named the
|
||||
way ComfyUI names a file there: "sub/clip.mp4 [output]". Hidden files and
|
||||
folders and links are left out."""
|
||||
root = _server_folders().get("output")
|
||||
@@ -187,7 +265,7 @@ def list_output_videos() -> list[str]:
|
||||
for folder, folders, files in os.walk(root): # does not follow links to folders
|
||||
folders[:] = [name for name in folders if not name.startswith(".")]
|
||||
for name in files:
|
||||
if name.startswith(".") or Path(name).suffix.lower() not in VIDEO_EXTENSIONS:
|
||||
if name.startswith(".") or Path(name).suffix.lower() not in extensions:
|
||||
continue
|
||||
path = Path(folder, name)
|
||||
try:
|
||||
@@ -200,6 +278,10 @@ def list_output_videos() -> list[str]:
|
||||
return [f"{name} [output]" for _mtime, name in found[:OUTPUT_LIST_CAP]]
|
||||
|
||||
|
||||
def list_output_videos() -> list[str]:
|
||||
return list_output_media(VIDEO_EXTENSIONS)
|
||||
|
||||
|
||||
def load_image_frames(path: Path) -> list[Image.Image]:
|
||||
frames: list[Image.Image] = []
|
||||
with Image.open(path) as source:
|
||||
@@ -772,6 +854,11 @@ def register_video_routes() -> None:
|
||||
videos = await asyncio.get_running_loop().run_in_executor(None, list_output_videos)
|
||||
return web.json_response({"videos": videos})
|
||||
|
||||
@prompt_server.routes.get("/ausboss/transform/image/outputs")
|
||||
async def ausboss_image_outputs(request):
|
||||
images = await asyncio.get_running_loop().run_in_executor(None, list_output_media, IMAGE_EXTENSIONS)
|
||||
return web.json_response({"images": images})
|
||||
|
||||
def request_path(request) -> Path:
|
||||
# resolve_video_path refuses a whole path from its text, before the
|
||||
# disk is touched, so a denied request can not be used to probe which
|
||||
|
||||
@@ -4,8 +4,8 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from ._h3_cast_helpers import JOBS, KEEP, KEEPS_ITS_LENGTH, PERSON, PLACE, START, THING, VIDEO_JOBS
|
||||
from ._h3_prompt_helpers import find_beats, split_sections, write_prompt
|
||||
from ._h3_cast_helpers import JOBS, KEEP, KEEPS_ITS_LENGTH, PERSON, PLACE, START, SWAP, THING, VIDEO_JOBS
|
||||
from ._h3_prompt_helpers import find_beats, find_speech, split_sections, write_prompt
|
||||
from ._h3_script_helpers import CAMERAS, LOOKS, script_report
|
||||
from ._route_errors import safe_route_error
|
||||
|
||||
@@ -273,6 +273,16 @@ class AusBossH3PromptWriter:
|
||||
"tooltip": "The music, like a slow lo-fi beat. A clip with music comes out louder. Empty means no music.",
|
||||
},
|
||||
),
|
||||
"clip_sound": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": (
|
||||
"Turn on when a swap keeps the video's own sound. Each line is timed from that sound, "
|
||||
"and words from anyone else are left out so only the new person's mouth moves."
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -312,7 +322,7 @@ class AusBossH3PromptWriter:
|
||||
def write(self, script, seconds, clip=None, picture_1=None, picture_2=None, picture_3=None, picture_4=None, video=None,
|
||||
video_sound=None, voice=None, song=None, camera=AS_TYPED, look=FROM_PICTURE, song_start=0.0, cut_on_beat=True,
|
||||
language="English", job_1=PERSON, job_2=PERSON, job_3=PERSON, job_4=THING, video_job=VIDEO_JOBS[0],
|
||||
sound="", music=""):
|
||||
sound="", music="", clip_sound=False):
|
||||
script = with_sound_and_music(script, sound, music)
|
||||
pictures = {1: picture_1, 2: picture_2, 3: picture_3, 4: picture_4}
|
||||
given = {slot: picture for slot, picture in pictures.items() if picture is not None}
|
||||
@@ -324,6 +334,8 @@ class AusBossH3PromptWriter:
|
||||
frames = video_frames(video.shape[0])
|
||||
seconds = frames / FRAME_RATE
|
||||
length = frames / FRAME_RATE
|
||||
clip_sound = bool(clip_sound) and video_job == SWAP
|
||||
speech = find_speech(mono_samples(video_sound), video_sound["sample_rate"]) if clip_sound and video_sound is not None else None
|
||||
if video_job == KEEP:
|
||||
video, video_sound = last_of_clip(video, video_sound)
|
||||
else:
|
||||
@@ -349,6 +361,8 @@ class AusBossH3PromptWriter:
|
||||
jobs=jobs,
|
||||
video=video_job,
|
||||
soundtrack=video_sound is not None,
|
||||
clip_sound=clip_sound,
|
||||
speech=speech,
|
||||
)
|
||||
ordered = [*given.values(), None, None, None, None][: len(PICTURES)]
|
||||
return (text, *ordered, video, video_sound, voice, song, song_under, frames, *clip_shape(video, ordered[0]))
|
||||
|
||||
@@ -16,6 +16,8 @@ sets; the constants are architecture facts, not anyone's model card.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
try: # Offline tests import this module without ComfyUI.
|
||||
import torch
|
||||
import comfy.model_management as model_management
|
||||
@@ -45,6 +47,27 @@ def clamp_dimension(value, fallback: int = 1024) -> int:
|
||||
return max(DIM_MIN, min(DIM_MAX, number))
|
||||
|
||||
|
||||
def check_workflow_size(width, height, workflow_sizes):
|
||||
if workflow_sizes == "":
|
||||
return
|
||||
try:
|
||||
if not isinstance(workflow_sizes, str) or len(workflow_sizes) > 2048:
|
||||
raise ValueError
|
||||
policy = json.loads(workflow_sizes)
|
||||
sizes = policy["sizes"]
|
||||
if type(policy["strict"]) is not bool or not isinstance(sizes, list) or not 1 <= len(sizes) <= 24:
|
||||
raise ValueError
|
||||
for pair in sizes:
|
||||
if (not isinstance(pair, list) or len(pair) != 2
|
||||
or any(type(n) is not int or not DIM_MIN <= n <= DIM_MAX for n in pair)):
|
||||
raise ValueError
|
||||
except (ValueError, KeyError, TypeError, RecursionError) as exc:
|
||||
raise ValueError("Invalid workflow resolutions. Edit the list in Latent Size settings.") from exc
|
||||
if policy["strict"] and [width, height] not in sizes:
|
||||
choices = ", ".join(f"{w}×{h}" for w, h in sizes)
|
||||
raise ValueError(f"Latent Size: {width}×{height} is outside the workflow's allowed sizes: {choices}.")
|
||||
|
||||
|
||||
def latent_downscale(family: str) -> int:
|
||||
"""How many pixels one latent cell covers per side in this family."""
|
||||
return LATENT_FAMILIES.get(family, LATENT_FAMILIES[DEFAULT_FAMILY])[1]
|
||||
@@ -144,6 +167,10 @@ class AusBossResolution:
|
||||
"tooltip": "How many latents to make. Set from the panel's gear.",
|
||||
},
|
||||
),
|
||||
"workflow_sizes": ("STRING", {
|
||||
"default": "", "dynamicPrompts": False,
|
||||
"tooltip": "Workflow resolutions set in the gear menu. Strict mode checks wired dimensions too.",
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -157,7 +184,8 @@ class AusBossResolution:
|
||||
)
|
||||
FUNCTION = "resolve"
|
||||
|
||||
def resolve(self, width, height, latent=DEFAULT_FAMILY, batch_size=1):
|
||||
def resolve(self, width, height, latent=DEFAULT_FAMILY, batch_size=1, workflow_sizes=""):
|
||||
check_workflow_size(width, height, workflow_sizes)
|
||||
w = clamp_dimension(width)
|
||||
h = clamp_dimension(height)
|
||||
return (w, h, empty_latent(w, h, latent, batch_size))
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ name = "ausboss-nodes"
|
||||
# Manager/registry search matches against this text, so it names the actual
|
||||
# nodes instead of describing the collection abstractly.
|
||||
description = "ComfyUI-AusBoss (AusBoss nodes): image and video nodes with compact controls: Image and Video Crop + Rotate + Pad (frame or clip), Load Image + Pad, Image Folder (run a workflow on a folder of pictures), Image Resize, Align Image, Image Size, Color Match, Image Compare A/B, Realign to Source (experimental), Save Image (PNG, lossless WebP/JXL, captions), Load Video, Save Video, Select Frame, Frame Interpolate, Mask Refine, Mask by Name and Mask by Touch (type a name or click a thing, get its mask), LaMa Inpaint, Crop For Inpaint, Stitch Inpaint, Tiled Upscale and Tiled Upscale Stitch, LoRA Loader, Krea 2 Encode and Outpaint Model Patch, Latent Size, Layout Boxes (draw where things and words go), Seed, Select Every Nth, Split Batch, Merge Batches, Text, Integer, Float, Math Expression, Show Text, Free Memory, Workflow Note, Callout (a note with arrows), Workflow Switches, Run Timer, and Step Preview (stop bad runs). Example workflows for Qwen Image 2.1, Krea 2, Klein 9B and LTX 2.3."
|
||||
version = "2.20.5"
|
||||
version = "2.21.0"
|
||||
# comfy-cli serializes this to JSON verbatim, so `file` publishes the literal
|
||||
# string {"file": "LICENSE"} to the registry listing. `text` puts a readable
|
||||
# name there instead. Both forms are supported; the LICENSE file is unchanged.
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A version's entries in CHANGELOG.md, as the short notes the Registry shows.
|
||||
"""A version's CHANGELOG entries for the Registry or a GitHub Release.
|
||||
|
||||
python scripts/changelog_notes.py # the pyproject version
|
||||
python scripts/changelog_notes.py --version 2.9.0
|
||||
python scripts/changelog_notes.py --github-env # for the publish workflow
|
||||
python scripts/changelog_notes.py --full # full Markdown for GitHub
|
||||
|
||||
Every entry of the version becomes one line: its bold opening, and for a new
|
||||
node or example the sentence that says what it does. So a release reads
|
||||
@@ -109,11 +110,16 @@ def pyproject_version() -> str:
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
|
||||
parser.add_argument("--version", help="the version to read; the pyproject version when left out")
|
||||
parser.add_argument("--github-env", action="store_true", help="write COMFY_NODE_CHANGELOG to $GITHUB_ENV")
|
||||
output = parser.add_mutually_exclusive_group()
|
||||
output.add_argument("--github-env", action="store_true", help="write COMFY_NODE_CHANGELOG to $GITHUB_ENV")
|
||||
output.add_argument("--full", action="store_true", help="print the full Markdown section for GitHub Releases")
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
version = args.version or pyproject_version()
|
||||
notes = notes_for(version, (ROOT / "CHANGELOG.md").read_text(encoding="utf-8"))
|
||||
text = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
|
||||
notes = notes_for(version, text)
|
||||
if args.full:
|
||||
notes = section(text, version).strip()
|
||||
except (OSError, ValueError) as exc:
|
||||
print(f"ERROR {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
@@ -9,10 +9,9 @@ Checks:
|
||||
BOM breaks installs silently.
|
||||
2. AUSBOSS_JS_VERSION in js/shared/index.mjs equals the pyproject
|
||||
version, so the stale-browser warning never fires on a fresh install.
|
||||
3. The README release badge is the DYNAMIC shields.io kind - it reads
|
||||
the version out of pyproject.toml on main at view time, so it can
|
||||
never go stale (a hardcoded badge sat at 1.0.0 through two releases).
|
||||
The check guards against someone swapping a static badge back in.
|
||||
3. The README release badge reads GitHub Releases and links to the latest
|
||||
release. The publish workflow creates it after the Registry upload.
|
||||
The check guards against a static version or unsupported badge route.
|
||||
4. The Registry archive holds runtime files only. With git it is listed
|
||||
the way comfy-cli packs it - tracked files minus those .comfyignore
|
||||
matches - and every shipped path must sit under the runtime allowlist
|
||||
@@ -101,17 +100,16 @@ if pyproject_version:
|
||||
|
||||
# --- 3. the README release badge stays dynamic -------------------------------
|
||||
DYNAMIC_BADGE = (
|
||||
"img.shields.io/badge/dynamic/toml"
|
||||
"?url=https%3A%2F%2Fraw.githubusercontent.com%2Fausboss%2FComfyUI-AusBoss"
|
||||
"%2Fmain%2Fpyproject.toml&query=%24.project.version"
|
||||
"img.shields.io/github/v/release/ausboss/ComfyUI-AusBoss?"
|
||||
)
|
||||
try:
|
||||
readme = (ROOT / "README.md").read_text(encoding="utf-8")
|
||||
if DYNAMIC_BADGE not in readme:
|
||||
errors.append(
|
||||
"README.md release badge is not the dynamic pyproject-reading "
|
||||
"shields.io badge; a hardcoded version WILL go stale"
|
||||
"README.md release badge must use the shields.io GitHub release source"
|
||||
)
|
||||
if 'href="https://github.com/ausboss/ComfyUI-AusBoss/releases/latest"' not in readme:
|
||||
errors.append("README.md must link to the latest GitHub Release")
|
||||
if re.search(r"badge/release-\d+\.\d+\.\d+-", readme):
|
||||
errors.append(
|
||||
"README.md carries a hardcoded release-X.Y.Z badge; use the "
|
||||
|
||||
@@ -12,8 +12,8 @@ import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const ENTRY = join(dirname(fileURLToPath(import.meta.url)), "..", "js", "cache_guard", "index.js");
|
||||
const API_IMPORT = 'import { api } from "/scripts/api.js";';
|
||||
const APP_IMPORT = 'import { app } from "/scripts/app.js";';
|
||||
const API_IMPORT = 'import { api } from "../../../scripts/api.js";';
|
||||
const APP_IMPORT = 'import { app } from "../../../scripts/app.js";';
|
||||
|
||||
const PAGE = `
|
||||
export const page = { installed: null, down: false, toasts: [], listeners: {}, extension: null };
|
||||
|
||||
Vendored
+4
-2
@@ -180,7 +180,8 @@
|
||||
"job_4",
|
||||
"video_job",
|
||||
"sound",
|
||||
"music"
|
||||
"music",
|
||||
"clip_sound"
|
||||
],
|
||||
"outputs": [
|
||||
[
|
||||
@@ -786,7 +787,8 @@
|
||||
],
|
||||
"optional": [
|
||||
"latent",
|
||||
"batch_size"
|
||||
"batch_size",
|
||||
"workflow_sizes"
|
||||
],
|
||||
"outputs": [
|
||||
[
|
||||
|
||||
+17
-16
@@ -5,10 +5,10 @@ import {
|
||||
BYPASS_MODE,
|
||||
DEFAULT_STEP,
|
||||
FINE_STEP,
|
||||
MAX_ROWS,
|
||||
SCRUB_DEAD_ZONE,
|
||||
SCRUB_PIXELS_PER_STEP,
|
||||
appendFolderRows,
|
||||
applyTemplate,
|
||||
clampHighlight,
|
||||
clampStrength,
|
||||
commonFolderPrefix,
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
setStrength,
|
||||
strengthOutOfRange,
|
||||
summarizeRows,
|
||||
templateFromRows,
|
||||
toggleAllRows,
|
||||
toggleAllState,
|
||||
toggleTrigger,
|
||||
@@ -506,29 +507,29 @@ test("folder add preserves Windows filenames and uses the ordinary default stren
|
||||
assert.equal(appendFolderRows([], [names[0]], 100).rows[0].strength, 10);
|
||||
});
|
||||
|
||||
test("folder add is all-or-nothing at the row limit, counting empty rows and skipping duplicates first", () => {
|
||||
const rows = normalizeRows(Array.from({ length: MAX_ROWS - 1 }, (_, i) => ({ name: i ? "" : "styles/a" })));
|
||||
const blocked = appendFolderRows(rows, ["styles/a", "styles/b", "styles/c"]);
|
||||
assert.equal(blocked.rows, rows);
|
||||
assert.deepEqual({ added: blocked.added, skipped: blocked.skipped, needed: blocked.needed, room: blocked.room },
|
||||
{ added: 0, skipped: 1, needed: 2, room: 1 });
|
||||
const fits = appendFolderRows(rows, ["styles/a", "styles/b"]);
|
||||
assert.equal(fits.added, 1);
|
||||
assert.equal(fits.rows.length, MAX_ROWS);
|
||||
const full = appendFolderRows(fits.rows, ["styles/c"]);
|
||||
assert.equal(full.rows, fits.rows);
|
||||
assert.equal(full.added, 0);
|
||||
assert.equal(full.room, 0);
|
||||
test("large stacks keep every row across folder adds, chain imports, workflows and templates", () => {
|
||||
const names = Array.from({ length: 600 }, (_, i) => `styles/${i}.safetensors`);
|
||||
const folder = appendFolderRows([], names);
|
||||
assert.equal(folder.added, names.length);
|
||||
const initial = normalizeRows([{ name: "original", strength: 0.25, enabled: false }]);
|
||||
const imported = mergeImportedRows(initial, folder.rows, { position: "before" });
|
||||
const expected = [...names, "original"];
|
||||
assert.deepEqual(imported.rows.map(row => row.name), expected);
|
||||
const saved = parseRows(serializeRows(imported.rows));
|
||||
assert.deepEqual(saved, imported.rows);
|
||||
const template = applyTemplate(templateFromRows("large stack", saved));
|
||||
assert.deepEqual(template.map(row => row.name), expected);
|
||||
assert.equal(template.at(-1).enabled, false);
|
||||
assert.equal(template.at(-1).strength, 0.25);
|
||||
});
|
||||
|
||||
test("an empty or already-added folder is a no-op with no new row identities", () => {
|
||||
const rows = normalizeRows([{ name: "styles/a" }]);
|
||||
assert.deepEqual(appendFolderRows(rows, []), { rows, added: 0, skipped: 0, needed: 0, room: 63 });
|
||||
assert.deepEqual(appendFolderRows(rows, []), { rows, added: 0, skipped: 0 });
|
||||
const result = appendFolderRows(rows, ["styles/a"]);
|
||||
assert.equal(result.rows, rows);
|
||||
assert.equal(result.added, 0);
|
||||
assert.equal(result.skipped, 1);
|
||||
assert.equal(result.needed, 0);
|
||||
});
|
||||
|
||||
test("strength range flags only real finite bounds", () => {
|
||||
|
||||
@@ -36,6 +36,7 @@ test("unlistedMedia names a value the list lacks, with or without its folder tai
|
||||
assert.equal(unlistedMedia(values, "clipspace-painted-masked-1.png [input]"), "clipspace-painted-masked-1.png [input]");
|
||||
assert.equal(unlistedMedia(values, "new.png"), "new.png");
|
||||
assert.equal(unlistedMedia(values, "photo.png"), "");
|
||||
assert.equal(unlistedMedia(values, "photo.png [output]"), "photo.png [output]");
|
||||
assert.equal(unlistedMedia(values, "photo.png [input]"), "");
|
||||
assert.equal(unlistedMedia(["photo.png [input]"], "photo.png [input]"), "");
|
||||
assert.equal(unlistedMedia(values, ""), "");
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
outwardDelta,
|
||||
parsePins,
|
||||
parseRail,
|
||||
parseWorkflowSizes, readWorkflowPolicy, workflowRatios,
|
||||
defaultSizeForRatio,
|
||||
pinLabel,
|
||||
quickSizes,
|
||||
@@ -32,7 +33,7 @@ import {
|
||||
serializePins,
|
||||
sizeForMegapixels,
|
||||
snapDim,
|
||||
stageScale,
|
||||
fitStage,
|
||||
stepMegapixels,
|
||||
strideWarning,
|
||||
togglePin,
|
||||
@@ -126,10 +127,16 @@ test("a ratio-locked corner drag cannot drift the ratio", () => {
|
||||
assert.ok(size.width > 1344, "grew outward");
|
||||
});
|
||||
|
||||
test("stageScale leaves label and handle headroom", () => {
|
||||
assert.ok(stageScale(1024, 1024, 200, 200) < 200 / 1024);
|
||||
assert.equal(stageScale(1024, 1024, 400, 400), (400 - 90) / 1024);
|
||||
assert.ok(stageScale(1024, 1024, 50, 50) > 0, "tiny stages stay positive");
|
||||
test("portrait uses the side gutters; a wide selection and its handles clear the header", () => {
|
||||
const portrait = fitStage(576, 1024, 420, 140);
|
||||
assert.equal(1024 * portrait.scale, 120);
|
||||
assert.equal(portrait.shift, 0);
|
||||
for (const [w, h] of [[1024, 576], [1024, 1024], [8192, 64]]) {
|
||||
const {scale, shift} = fitStage(w, h, 320, 180);
|
||||
const top = (180 - h * scale) / 2 + shift;
|
||||
assert.ok(top >= 26 && top + h * scale <= 170);
|
||||
assert.ok(w * scale <= 300);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- megapixel retarget ----------
|
||||
@@ -330,3 +337,14 @@ test("extreme ratio and snap combinations always return bounded dimensions", ()
|
||||
assert.deepEqual(orientRatio({x:3,y:4}, true), {x:3,y:4,label:"3:4"});
|
||||
assert.deepEqual(orientRatio({x:3,y:4}, false), {x:4,y:3,label:"4:3"});
|
||||
});
|
||||
|
||||
test("workflow chips group by exact oriented ratio and keep the author's order", () => {
|
||||
const sizes = parseWorkflowSizes("576×1024\n480 x 864\n720 X 1280\n1024 × 576\n576×1024");
|
||||
const groups = workflowRatios(sizes);
|
||||
assert.deepEqual(groups.map(r => r.label), ["9:16", "5:9", "16:9"]);
|
||||
assert.deepEqual(groups[0].sizes, [{width:576,height:1024}, {width:720,height:1280}]);
|
||||
assert.throws(() => parseWorkflowSizes("576×1024\n1024×32"));
|
||||
assert.throws(() => readWorkflowPolicy('{"strict":"false","sizes":[]}'));
|
||||
assert.throws(() => readWorkflowPolicy('{"strict":true,"sizes":[[576,1024],"1024x576"]}'));
|
||||
assert.deepEqual(readWorkflowPolicy(""), {strict:false, sizes:[]});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { savedWorkflowPolicy } from "../js/shared/resolution_math.mjs";
|
||||
import { resetUnknownValues } from "../js/shared/widget_card_math.mjs";
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
@@ -158,6 +159,7 @@ const LAST_GUARDED_INPUT = {
|
||||
// (js/widget_cards/index.js), or RESET_UNKNOWN beside NODE_CLASS in its own
|
||||
// js/<name>/index.js for a node without a card.
|
||||
const APPENDED_SINCE = {
|
||||
AUSBOSS_NODES_Resolution: { workflow_sizes: "workflow-policy" },
|
||||
// Added in 2.4.0; 2.0.0 - 2.3.0 saves hold the card's value there.
|
||||
AUSBOSS_NODES_CropForInpaint: { keep_inside: "toggle" },
|
||||
AUSBOSS_NODES_StitchInpaint: { seam: "combo", paste_onto: "combo" },
|
||||
@@ -240,6 +242,14 @@ test("an added widget input opens an older save with its fallback, not the old s
|
||||
for (const [nodeId, inputs] of Object.entries(APPENDED_SINCE)) {
|
||||
for (const [name, type] of Object.entries(inputs)) {
|
||||
if (type === "socket") continue;
|
||||
if (type === "workflow-policy") {
|
||||
// An empty string is a valid, unrestricted text policy.
|
||||
for (const old of OLD_SLOT_VALUES) assert.equal(savedWorkflowPolicy(old), "");
|
||||
const saved = '{"strict":true,"sizes":[[576,1024]]}';
|
||||
assert.equal(savedWorkflowPolicy(saved), saved);
|
||||
assert.equal(savedWorkflowPolicy("", [[576, 1024]]), saved);
|
||||
continue;
|
||||
}
|
||||
const fallbacks = nodeFallbacks(nodeId);
|
||||
assert.ok(
|
||||
name in fallbacks,
|
||||
|
||||
+25
-23
@@ -157,24 +157,6 @@ const rule = (selector) => {
|
||||
return editor.slice(start + selector.length + 1, editor.indexOf("}", start));
|
||||
};
|
||||
|
||||
test("the note's line is as tall empty as it is with a note", () => {
|
||||
const lineHeight = Number(/font:11px\/(\d+)px/.exec(rule(".ausboss-transform-readout"))?.[1]);
|
||||
assert.ok(lineHeight > 0, "the readout no longer sets its line height in px");
|
||||
const note = rule(".ausboss-transform-readout i");
|
||||
assert.match(note, /display:block/);
|
||||
assert.equal(Number(/min-height:(\d+)px/.exec(note)?.[1]), lineHeight, "an empty note line must keep one line of height");
|
||||
// An inline button on the text's baseline made the line a pixel taller
|
||||
// than a line without one, so the rows under it moved when it came.
|
||||
const button = rule(".ausboss-transform-readout button");
|
||||
assert.match(button, /vertical-align:top/);
|
||||
assert.ok(Number(/height:(\d+)px/.exec(button)?.[1]) <= lineHeight, "Even out must fit inside the line");
|
||||
});
|
||||
|
||||
test("the line is there with Resize on and gone with it off", () => {
|
||||
assert.match(body("syncReadout"), /state\.noteLine\.hidden = !resize;/);
|
||||
assert.match(editor, /\.ausboss-transform-readout i\[hidden\]\{display:none\}/);
|
||||
});
|
||||
|
||||
test("the readout asks before it shows a note, and never writes one itself", () => {
|
||||
const sync = body("syncReadout");
|
||||
assert.match(sync, /const now = changed && state\.settle\.touch\(\);/);
|
||||
@@ -186,11 +168,6 @@ test("the readout asks before it shows a note, and never writes one itself", ()
|
||||
assert.match(body("settleReadout"), /showNote\(state, state\.wantedNote/);
|
||||
});
|
||||
|
||||
test("mid-drag the size line only grows", () => {
|
||||
assert.match(body("syncReadout"), /sizeLine\.style\.minHeight = now \? "" : `\$\{Math\.max\(before, sizeLine\.offsetHeight\)\}px`;/);
|
||||
assert.match(body("settleReadout"), /state\.sizeLine\.style\.minHeight = "";/);
|
||||
});
|
||||
|
||||
test("the editor's size box and status show the settled note too", () => {
|
||||
assert.match(body("drawOutputSize"), /const warnings = shownNote\(state\)\.warnings;/);
|
||||
assert.doesNotMatch(body("drawOutputSize"), /\{ tokens, warnings \}/);
|
||||
@@ -216,3 +193,28 @@ test("one watch on the page tells every node when the pointer comes up", () => {
|
||||
assert.match(watch, /for \(const live of liveStates\) live\.settle\.release\(\);/);
|
||||
assert.match(editor, /state\.settle = createSettle\(\{ delay: NOTE_SETTLE_MS, held: \(\) => pointerHeld, onSettle: \(\) => settleReadout\(state\) \}\);/);
|
||||
});
|
||||
|
||||
test("the video timeline sits right under the preview and size line, ahead of the collapsible transform group", () => {
|
||||
const install = body("installTransformNode");
|
||||
const atPreview = install.indexOf("panel.append(preview, readout);");
|
||||
const atTrim = install.indexOf('if (kind === "video") panel.append(buildTrim(state));');
|
||||
const atGroup = install.indexOf("panel.append(buildTransformGroup(state, [modeRow.row, chipRow.row, ...canvasRows]));");
|
||||
const atButtons = install.indexOf("panel.append(row);");
|
||||
assert.ok(atPreview > 0 && atTrim > atPreview, "the video timeline must sit right under the preview and size line");
|
||||
assert.ok(atGroup > atTrim, "the collapsible transform group must follow the timeline");
|
||||
assert.ok(atButtons > atGroup, "the action buttons must sit at the bottom of the panel");
|
||||
});
|
||||
|
||||
test("the collapsible transform group stores its state on node.properties and preserves stage height", () => {
|
||||
const group = body("buildTransformGroup");
|
||||
assert.match(group, /const stageHeight = state\.previewCanvas\?\.clientHeight \?\? 0;/);
|
||||
assert.match(group, /node\.properties\[TRANSFORM_GROUP_OPEN_PROPERTY\] = !transformGroupOpen\(node\);/);
|
||||
assert.match(group, /const before = measureChrome\(state\) \?\? 0;/);
|
||||
assert.match(group, /const after = measureChrome\(state\) \?\? before;/);
|
||||
assert.match(group, /state\.stageGrowth = \{ height: stageHeight, target: nextHeight \};/);
|
||||
assert.match(body("installTransformNode"), /state\.panelResizeObserver\.observe\(preview\);\s*state\.panelResizeObserver\.observe\(panel\);/);
|
||||
assert.match(body("panelBackingStale"), /canvas\.width !== Math\.round\(canvas\.clientWidth \* dpr\)/);
|
||||
assert.match(body("editorSnapshot"), /key !== TRANSFORM_GROUP_OPEN_PROPERTY/);
|
||||
assert.match(body("transformPanelFloor"), /transformGroupOpen\(node\) \? PANEL_CHROME_OPEN_ESTIMATE\[key\] : PANEL_CHROME_CLOSED_ESTIMATE\[key\]/);
|
||||
assert.match(rule(".ausboss-transform-group-body[hidden]"), /display:none/);
|
||||
});
|
||||
|
||||
@@ -107,6 +107,30 @@ class CommandTests(unittest.TestCase):
|
||||
self.assertEqual((code, err), (0, ""))
|
||||
self.assertTrue(out.startswith("- "))
|
||||
|
||||
def test_full_notes_keep_markdown_and_details_for_only_the_requested_version(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
root = Path(folder)
|
||||
(root / "CHANGELOG.md").write_text(CHANGELOG, encoding="utf-8")
|
||||
with patch.object(changelog_notes, "ROOT", root):
|
||||
code, out, err = self.run_main("--version", "2.7.0", "--full")
|
||||
self.assertEqual((code, err), (0, ""))
|
||||
self.assertEqual(out, section(CHANGELOG, "2.7.0").strip() + "\n")
|
||||
self.assertIn("**Save Video:", out)
|
||||
self.assertIn("A second sentence.", out)
|
||||
self.assertNotIn("Mask by Touch", out)
|
||||
self.assertNotIn("plain entry", out)
|
||||
|
||||
def test_full_notes_reject_missing_or_empty_sections(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
root = Path(folder)
|
||||
(root / "CHANGELOG.md").write_text(CHANGELOG, encoding="utf-8")
|
||||
with patch.object(changelog_notes, "ROOT", root):
|
||||
for version in ("9.9.9", "2.6.4"):
|
||||
with self.subTest(version=version):
|
||||
code, out, err = self.run_main("--version", version, "--full")
|
||||
self.assertEqual((code, out), (1, ""))
|
||||
self.assertIn("ERROR", err)
|
||||
|
||||
def test_the_workflow_gets_the_notes_under_the_name_comfy_cli_reads(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
target = Path(folder) / "env"
|
||||
|
||||
@@ -43,6 +43,7 @@ class PackModuleListTests(unittest.TestCase):
|
||||
|
||||
def test_path_check_matches_only_this_packs_mjs(self):
|
||||
self.assertTrue(is_pack_module_path("/extensions/ausboss-nodes/shared/index.mjs", "ausboss-nodes"))
|
||||
self.assertTrue(is_pack_module_path("/comfyui/extensions/ausboss-nodes/shared/index.mjs", "ausboss-nodes"))
|
||||
self.assertFalse(is_pack_module_path("/extensions/ausboss-nodes/seed/index.js", "ausboss-nodes"))
|
||||
self.assertFalse(is_pack_module_path("/extensions/other-pack/shared/index.mjs", "ausboss-nodes"))
|
||||
self.assertFalse(is_pack_module_path("/extensions/ausboss-nodes-extra/a.mjs", "ausboss-nodes"))
|
||||
@@ -104,7 +105,7 @@ class ModuleVersionTests(unittest.TestCase):
|
||||
|
||||
def test_everything_else_is_left_alone(self):
|
||||
source = (
|
||||
'import { app } from "/scripts/app.js";\n'
|
||||
'import { app } from "../../../scripts/app.js";\n'
|
||||
'import { x } from "./plain.js";\n'
|
||||
'import { y } from "./old.mjs?v=1";\n'
|
||||
'import { z } from "https://example.com/z.mjs";\n'
|
||||
@@ -144,6 +145,25 @@ class ModuleVersionTests(unittest.TestCase):
|
||||
loose.append(path.relative_to(ROOT).as_posix())
|
||||
self.assertEqual(loose, [])
|
||||
|
||||
def test_real_pack_scripts_use_relative_imports_so_subpath_prefixes_work(self):
|
||||
# A leading "/" in an import resolves to the origin root, which 404s
|
||||
# when ComfyUI is served behind a subpath prefix such as /comfyui/.
|
||||
from urllib.parse import urljoin
|
||||
|
||||
specifier = re.compile(r"""(?:\bfrom\s*|\bimport\s*\(?\s*)["']([^"'\n]+)["']""")
|
||||
broken = []
|
||||
for path in sorted((ROOT / "js").rglob("*")):
|
||||
if path.suffix not in (".js", ".mjs") or not path.is_file():
|
||||
continue
|
||||
rel = path.relative_to(ROOT / "js").as_posix()
|
||||
base = f"http://host/comfyui/extensions/ausboss-nodes/{rel}"
|
||||
for match in specifier.finditer(path.read_text(encoding="utf-8")):
|
||||
target = match.group(1)
|
||||
resolved = urljoin(base, target)
|
||||
if target.startswith("/") or not resolved.startswith("http://host/comfyui/"):
|
||||
broken.append(f"{rel}: {target} -> {resolved}")
|
||||
self.assertEqual(broken, [])
|
||||
|
||||
|
||||
class VersionedServingTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
|
||||
@@ -19,7 +19,9 @@ from nodes._h3_prompt_helpers import (
|
||||
add_task,
|
||||
clean_words,
|
||||
find_beats,
|
||||
find_speech,
|
||||
join_sections,
|
||||
place_lines,
|
||||
snap_cuts,
|
||||
split_sections,
|
||||
write_prompt,
|
||||
@@ -676,6 +678,138 @@ class VideoJobTests(unittest.TestCase):
|
||||
self.assertIn("Everyone else stays", parts["detailed_description"])
|
||||
self.assertNotIn("Show one person", parts["detailed_description"])
|
||||
|
||||
def test_a_swap_with_two_people_puts_each_in_one_place(self):
|
||||
parts = parts_of("", jobs={1: "person", 2: "person"}, video="swap the person")
|
||||
self.assertIn("Replace <Subject 1> with <Subject 3> and <Subject 2> with <Subject 4>", parts["summary"])
|
||||
self.assertIn("<Subject 1> is the person on the left in <Video 1>", parts["subject_definitions"])
|
||||
self.assertIn("<Subject 2> is the person on the right in <Video 1>", parts["subject_definitions"])
|
||||
self.assertIn("<Subject 4> is the person in <Picture 2>", parts["subject_definitions"])
|
||||
self.assertIn("replace the person on the right with <Subject 4>", parts["retention_analysis"])
|
||||
self.assertIn("change only the people", parts["retention_analysis"])
|
||||
self.assertIn("<Subject 4> takes the place of the person on the right", parts["detailed_description"])
|
||||
self.assertNotIn("Show one person", parts["detailed_description"])
|
||||
|
||||
def test_a_swap_with_two_people_reads_who_goes_from_the_script(self):
|
||||
for script in ("@2 is the woman in red. @1 replaces the man in the hat", "The man in the hat.\nThe woman in red."):
|
||||
parts = parts_of(script, jobs={1: "person", 2: "character sheet"}, video="swap the person")
|
||||
self.assertIn("replace the man in the hat with <Subject 3>", parts["retention_analysis"])
|
||||
self.assertIn("replace the woman in red with <Subject 4>", parts["retention_analysis"])
|
||||
self.assertNotIn("@", join_sections(parts))
|
||||
|
||||
def test_one_swap_takes_the_at_out_of_who_goes(self):
|
||||
parts = parts_of("@1 replaces the child in the seat", jobs={1: "person"}, video="swap the person")
|
||||
self.assertIn("replace the child in the seat with <Subject 2>", parts["retention_analysis"])
|
||||
|
||||
def test_a_swap_reads_who_goes_out_of_a_longer_line(self):
|
||||
parts = parts_of("Elon is @1, he replaces the person at the counter", jobs={1: "person"}, video="swap the person")
|
||||
self.assertIn("<Subject 1> is the person at the counter in <Video 1>", parts["subject_definitions"])
|
||||
self.assertNotIn("Elon", join_sections(parts))
|
||||
parts = parts_of("Elon is @1", jobs={1: "person"}, video="swap the person")
|
||||
self.assertIn("<Subject 1> is the original person in <Video 1>", parts["subject_definitions"])
|
||||
script = "Elon is @1, he replaces the man on the left. Sam is @2, she takes the place of the woman in red"
|
||||
parts = parts_of(script, jobs={1: "person", 2: "person"}, video="swap the person")
|
||||
self.assertIn("replace the man on the left with <Subject 3>", parts["retention_analysis"])
|
||||
self.assertIn("replace the woman in red with <Subject 4>", parts["retention_analysis"])
|
||||
|
||||
def test_a_swap_carries_the_words_in_quotes_onto_the_lips(self):
|
||||
script = 'Elon is @1, he replaces the person at the counter.\n@1 says "Welcome in, what can I get you?"'
|
||||
parts = parts_of(script, jobs={1: "person"}, video="swap the person")
|
||||
self.assertIn("<Subject 1> is the person at the counter in <Video 1>", parts["subject_definitions"])
|
||||
line = parts["detailed_description"]
|
||||
self.assertIn("<d>[English] Welcome in, what can I get you?</d>", line)
|
||||
self.assertIn("<Subject 2> (S1) says, each word landing exactly on the lip movement of <Video 1>", line)
|
||||
self.assertNotIn("Elon", join_sections(parts))
|
||||
|
||||
def test_a_swap_gives_each_named_person_their_own_words(self):
|
||||
script = (
|
||||
"Altman is @1, the man on the left. Dario is @2, the man on the right.\n"
|
||||
'Dario says "We should talk."\nAltman says "Again?"\nHe says "Fine."'
|
||||
)
|
||||
parts = parts_of(script, jobs={1: "person", 2: "person"}, video="swap the person")
|
||||
self.assertIn("replace the man on the left with <Subject 3>", parts["retention_analysis"])
|
||||
self.assertIn("replace the man on the right with <Subject 4>", parts["retention_analysis"])
|
||||
line = parts["detailed_description"]
|
||||
self.assertIn("<Subject 4> (S1) says, each word landing exactly on the lip movement of <Video 1>, <d>[English] We should talk.</d>", line)
|
||||
self.assertIn("<Subject 3> (S2) says, each word landing exactly on the lip movement of <Video 1>, <d>[English] Again?</d>", line)
|
||||
self.assertIn("<Subject 3> (S2) says, each word landing exactly on the lip movement of <Video 1>, <d>[English] Fine.</d>", line)
|
||||
parts = parts_of("Altman is @1, the man on the left, Dario is @2, the man on the right", jobs={1: "person", 2: "person"}, video="swap the person")
|
||||
self.assertIn("replace the man on the left with <Subject 3>", parts["retention_analysis"])
|
||||
|
||||
def test_a_thing_in_a_swap_speaks_from_off_screen(self):
|
||||
script = '@1 replaces the man at the till.\nThe card reader beeps and says "Card declined."\n@1 says "Again?"'
|
||||
line = parts_of(script, jobs={1: "person"}, video="swap the person")["detailed_description"]
|
||||
self.assertIn("card reader (S2) says from off screen, <d>[English] Card declined.</d> Nobody on screen moves", line)
|
||||
self.assertIn("<Subject 2> (S1) says, each word landing exactly on the lip movement of <Video 1>, <d>[English] Again?</d>", line)
|
||||
|
||||
def test_a_person_off_screen_in_a_swap_gets_their_own_voice(self):
|
||||
for said_by, voice in (('A woman off screen says', "A woman"), ('The cashier, off camera, says', "The cashier"),
|
||||
('Someone behind the camera yells', "Someone"), ('Mom (O.S.) says', "Mom")):
|
||||
script = f'@1 replaces the man at the till.\n{said_by} "Card declined."\n@1 says "Again?"'
|
||||
line = parts_of(script, jobs={1: "person"}, video="swap the person")["detailed_description"]
|
||||
self.assertIn(f"{voice} (S2) says from off screen, <d>[English] Card declined.</d> Nobody on screen moves", line)
|
||||
self.assertIn("<Subject 2> (S1) says, each word landing exactly on the lip movement of <Video 1>, <d>[English] Again?</d>", line)
|
||||
self.assertNotIn("<Subject 2> (S1) says in an off-screen voiceover", line)
|
||||
|
||||
def test_a_person_off_screen_is_not_the_person_on_screen(self):
|
||||
line = parts_of('@1 stands at the till.\nA woman off screen says "Card declined."\n@1 says "Again?"', jobs={1: "person"})["detailed_description"]
|
||||
self.assertIn("a woman (S2) says from off screen, <d>[English] Card declined.</d>", line)
|
||||
self.assertIn("<Subject 1> (S1) says, <d>[English] Again?</d>", line)
|
||||
self.assertNotIn("woman stays silent", line)
|
||||
|
||||
def test_a_thing_marked_off_screen_speaks_for_itself_in_every_mode(self):
|
||||
for video in ("", "copy its moves"):
|
||||
script = '@1 stands at the till.\nA card reader off screen says "Card declined."\n@1 says "Again?"'
|
||||
line = parts_of(script, jobs={1: "person"}, video=video)["detailed_description"]
|
||||
self.assertIn("a card reader (S2) says from off screen, <d>[English] Card declined.</d> Nobody on screen moves", line)
|
||||
self.assertNotIn("<Subject 1> (S1) says in an off-screen voiceover", line)
|
||||
|
||||
def test_an_unnamed_voiceover_stays_the_main_persons(self):
|
||||
for script in ('@1 stands at the till.\nVoiceover: "I was broke."', '@1 stands at the till.\n@1, off screen, says "I was broke."',
|
||||
'@1 stands at the till.\nShe says off camera "I was broke."'):
|
||||
line = parts_of(script, jobs={1: "person"})["detailed_description"]
|
||||
self.assertIn("<Subject 1> (S1) says in an off-screen voiceover, <d>[English] I was broke.</d>", line)
|
||||
|
||||
def test_a_swap_can_keep_the_clips_clothes(self):
|
||||
for script in ('@1 replaces the girl in the hoodie and keeps her outfit.', "@1 replaces the girl in the hoodie, in the clip's clothes.",
|
||||
'@1 replaces the girl in the hoodie.\nKeep the clothes from the video.'):
|
||||
parts = parts_of(script, jobs={1: "person"}, video="swap the person")
|
||||
self.assertIn("<Subject 1> is the girl in the hoodie in <Video 1>, selected for replacement.", parts["subject_definitions"])
|
||||
self.assertIn("<Subject 2> is the person in <Picture 1>, with the same face and hair, wearing the clothes of the girl in the hoodie in <Video 1>.",
|
||||
parts["subject_definitions"])
|
||||
self.assertIn("the clothes of the girl in the hoodie in <Video 1> stay as they are", parts["retention_analysis"])
|
||||
self.assertNotIn("same face, hair and clothes", parts["subject_definitions"])
|
||||
|
||||
def test_a_swap_wears_the_pictures_clothes_unless_told(self):
|
||||
for script in ('@1 replaces the girl in the hoodie.', '@1 replaces the girl in the hoodie and keeps her own clothes.'):
|
||||
parts = parts_of(script, jobs={1: "person"}, video="swap the person")
|
||||
self.assertIn("<Subject 2> is the person in <Picture 1>, with the same face, hair and clothes.", parts["subject_definitions"])
|
||||
|
||||
def test_how_a_swap_line_is_said_stays_out_of_who_says_it(self):
|
||||
script = '@1 replaces the man at the till.\nThe card reader says in a flat robot voice "Card declined."'
|
||||
line = parts_of(script, jobs={1: "person"}, video="swap the person")["detailed_description"]
|
||||
self.assertIn("card reader (S1) says in a flat robot voice from off screen, <d>[English] Card declined.</d>", line)
|
||||
self.assertNotIn("reader says in", line)
|
||||
|
||||
def test_a_swap_line_keeps_the_decimals_of_its_time(self):
|
||||
for script in ('@1 replaces the man at the till.\n3.2s: @1 says "Again?"', '@1 replaces the man at the till.\nAt 3.2 seconds @1 says "Again?"',
|
||||
'@1 replaces the man at the till.\nThe card reader, at 3.2s, says "Card declined."'):
|
||||
line = parts_of(script, jobs={1: "person"}, video="swap the person", seconds=8)["detailed_description"]
|
||||
self.assertIn("At 00:03.200,", line)
|
||||
self.assertEqual(read_script("1.5s: she waves").moments[-1].at, 1.5)
|
||||
|
||||
def test_a_swap_line_that_raps_keeps_the_verb_out_of_who_goes(self):
|
||||
parts = parts_of('@1 replaces the man on the left.\n@2 replaces the man on the right and raps "Yo, yo."', jobs={1: "person", 2: "person"}, video="swap the person")
|
||||
self.assertIn("replace the man on the right with <Subject 4>", parts["retention_analysis"])
|
||||
self.assertNotIn("and raps", join_sections(parts))
|
||||
self.assertIn("replace the man on the left with <Subject 3>", parts["retention_analysis"])
|
||||
self.assertIn("<Subject 4> (S1) says, each word landing", parts["detailed_description"])
|
||||
|
||||
def test_a_swap_with_a_voice_takes_its_words(self):
|
||||
parts = parts_of('@1 replaces the man in the hat. He says "Hi there."', jobs={1: "person"}, video="swap the person", voice=True)
|
||||
self.assertIn("replace the man in the hat with <Subject 2>", parts["retention_analysis"])
|
||||
self.assertIn("voice timbre referenced from <Audio 1>, each word landing exactly on the lip movement of <Video 1>", parts["detailed_description"])
|
||||
self.assertIn("<d>[English] Hi there.</d>", parts["detailed_description"])
|
||||
|
||||
def test_keeping_going_needs_no_words_and_no_picture(self):
|
||||
parts = parts_of("", jobs={}, video="keep going")
|
||||
self.assertTrue(parts["summary"].startswith("[video continuation]"))
|
||||
@@ -1090,7 +1224,170 @@ class SoundAndMusicBoxTests(unittest.TestCase):
|
||||
|
||||
def test_the_boxes_are_appended_after_every_older_input(self):
|
||||
names = list(AusBossH3PromptWriter.INPUT_TYPES()["optional"])
|
||||
self.assertEqual(names[-2:], ["sound", "music"])
|
||||
self.assertEqual(names[-3:], ["sound", "music", "clip_sound"])
|
||||
|
||||
|
||||
def spoken(stretches, seconds, rate=16000, noise=0.002, seed=3):
|
||||
"""A mono track: a voice-band buzz over each (start, end), soft hiss elsewhere."""
|
||||
noise_maker = np.random.default_rng(seed)
|
||||
times = np.arange(int(seconds * rate)) / rate
|
||||
track = noise * noise_maker.standard_normal(len(times))
|
||||
for start, end in stretches:
|
||||
inside = (times >= start) & (times < end)
|
||||
syllables = 0.5 + 0.5 * np.sin(2 * np.pi * 4 * times[inside]) ** 2 # Words rise and fall.
|
||||
track[inside] += 0.2 * syllables * (np.sin(2 * np.pi * 220 * times[inside]) + 0.6 * np.sin(2 * np.pi * 880 * times[inside]))
|
||||
return track.astype(np.float32)
|
||||
|
||||
|
||||
# The "state property" clip: who said what and when, read from its words.
|
||||
STATE = (
|
||||
'@1 replaces the older man in the white pickup truck.\n'
|
||||
'A man off screen says "Who told you you could fish here?"\n'
|
||||
'The older man says "I thought it was state property."\n'
|
||||
'A man off screen says "State?"\n'
|
||||
'He says "Yeah."\n'
|
||||
'A man off screen says "This?"\n'
|
||||
'He says "Yeah."'
|
||||
)
|
||||
STATE_TIMES = [(0.08, 1.94), (2.28, 3.44), (4.66, 4.9), (5.32, 5.52), (5.86, 6.22), (6.32, 6.66)]
|
||||
# What find_speech heard in that clip's real sound: (start, end, pitch in Hz, seconds of clear voice). The man
|
||||
# filming sits near 270 Hz and the man in the truck near 150 Hz. Whisper put the lines at 0.08, 2.28, 4.66,
|
||||
# 5.32, 5.86 and 6.32 s.
|
||||
STATE_HEARD = [
|
||||
(0.0, 1.13, 204, 0.5), (1.5, 1.66, 179, 0.1), (1.74, 1.91, 355, 0.1), (2.21, 2.41, 166, 0.14), (2.53, 2.92, 154, 0.3),
|
||||
(3.01, 3.46, 160, 0.3), (4.59, 4.91, 269, 0.24), (5.27, 5.53, 125, 0.16), (5.83, 6.2, 296, 0.28), (6.45, 6.67, 121, 0.12),
|
||||
]
|
||||
|
||||
|
||||
def heard(stretches):
|
||||
return [(start, end, None if hz is None else float(np.log2(hz)), voiced) for start, end, hz, voiced in stretches]
|
||||
|
||||
|
||||
def plain(times, hz=150.0):
|
||||
"""Stretches at times, all in one voice."""
|
||||
return heard([(start, end, hz, (end - start) * 0.6) for start, end in times])
|
||||
|
||||
|
||||
SWAP_JOBS = {"jobs": {1: "person"}, "video": "swap the person", "seconds": 6.8}
|
||||
|
||||
|
||||
class ClipSoundTests(unittest.TestCase):
|
||||
def test_speech_is_found_where_the_words_are(self):
|
||||
found = find_speech(spoken(STATE_TIMES, 7.0), 16000)
|
||||
self.assertEqual(len(found), len(STATE_TIMES))
|
||||
for (start, end), (heard_from, heard_to, pitch, voiced) in zip(STATE_TIMES, found):
|
||||
self.assertAlmostEqual(heard_from, start, delta=0.06)
|
||||
self.assertAlmostEqual(heard_to, end, delta=0.08)
|
||||
self.assertAlmostEqual(2 ** pitch, 220, delta=8)
|
||||
self.assertGreater(voiced, 0)
|
||||
|
||||
def test_silence_has_no_speech(self):
|
||||
self.assertEqual(find_speech(np.zeros(16000 * 3, np.float32), 16000), [])
|
||||
self.assertEqual(find_speech(spoken([], 3.0), 16000), [])
|
||||
|
||||
def test_a_breath_inside_a_line_stays_in_the_line(self):
|
||||
found = find_speech(spoken([(0.5, 1.2), (1.35, 2.0), (3.0, 4.0)], 5.0), 16000)
|
||||
starts = place_lines(found, [("Let me tell you about the night", "new"), ("It was cold", "new")])
|
||||
self.assertAlmostEqual(starts[0], 0.5, delta=0.06)
|
||||
self.assertAlmostEqual(starts[1], 3.0, delta=0.06)
|
||||
|
||||
def test_lines_start_after_the_longest_quiet(self):
|
||||
stretches = plain([(0.0, 0.7), (1.0, 1.6), (2.3, 3.0), (3.2, 4.0)])
|
||||
self.assertEqual(place_lines(stretches, [("Five in the morning and do ten", "new"), ("So this is not a joke", "new")]), [0.0, 2.3])
|
||||
self.assertIsNone(place_lines(stretches, [("a", "new")] * 5))
|
||||
self.assertIsNone(place_lines([], [("a", "new")]))
|
||||
|
||||
def test_two_voices_taking_turns_are_told_apart_by_pitch(self):
|
||||
lines = [("Who told you you could fish here?", "other"), ("I thought it was state property.", "new"), ("State?", "other"),
|
||||
("Yeah.", "new"), ("This?", "other"), ("Yeah.", "new")]
|
||||
starts = place_lines(heard(STATE_HEARD), lines)
|
||||
for start, (truth, _) in zip(starts, STATE_TIMES):
|
||||
self.assertAlmostEqual(start, truth, delta=0.15)
|
||||
|
||||
def test_only_the_new_persons_lines_stay_timed_from_the_sound(self):
|
||||
parts = parts_of(STATE, clip_sound=True, speech=heard(STATE_HEARD), **SWAP_JOBS)
|
||||
line = parts["detailed_description"]
|
||||
for gone in ("Who told you", "State?", "This?", "Nobody on screen moves", "off screen"):
|
||||
self.assertNotIn(gone, line)
|
||||
self.assertIn("<Subject 2> keeps the lips closed whenever <Subject 2> is not saying a line of <Subject 2>'s own.", line)
|
||||
self.assertIn("At 00:02.210, <Subject 2> (S1) says, each word landing exactly on the lip movement of <Video 1>, "
|
||||
"<d>[English] I thought it was state property.</d>", line)
|
||||
self.assertIn("At 00:05.270, <Subject 2> (S1) says", line)
|
||||
self.assertIn("At 00:06.450, <Subject 2> (S1) says", line)
|
||||
self.assertNotIn("No other words are spoken", parts["overall_soundscape"])
|
||||
|
||||
def test_without_the_clips_sound_every_line_is_written(self):
|
||||
line = parts_of(STATE, **SWAP_JOBS)["detailed_description"]
|
||||
self.assertIn("Who told you you could fish here?", line)
|
||||
self.assertIn("Nobody on screen moves their lips to these words.", line)
|
||||
self.assertNotIn("keeps the lips closed whenever", line)
|
||||
|
||||
def test_someone_who_stays_is_left_out_and_the_new_person_says_nothing(self):
|
||||
script = (
|
||||
'@1 replaces the man in the black jacket at the counter.\n'
|
||||
'The man in the cap at the left edge says "Why would we take EBT here? We don\'t sell groceries."\n'
|
||||
'The man in the cap says "Maybe that\'s something y\'all need to look into."'
|
||||
)
|
||||
parts = parts_of(script, clip_sound=True, speech=plain([(0.0, 2.58), (3.76, 4.74)]), **SWAP_JOBS)
|
||||
line = parts["detailed_description"]
|
||||
self.assertNotIn("<d>", line)
|
||||
self.assertNotIn("EBT", line)
|
||||
self.assertIn("<Subject 2> keeps the lips closed and says nothing the whole time.", line)
|
||||
|
||||
def test_a_short_name_for_who_goes_keeps_their_line(self):
|
||||
for said_by in ("The man in the car says", "The man says", "The man in the police car says"):
|
||||
script = f'@1 replaces the man in the back of the police car.\n{said_by} "I did nothing wrong."'
|
||||
line = parts_of(script, clip_sound=True, speech=plain([(0.5, 2.0)]), **SWAP_JOBS)["detailed_description"]
|
||||
self.assertIn("<Subject 2> (S1) says, each word landing exactly on the lip movement of <Video 1>, <d>[English] I did nothing wrong.</d>", line)
|
||||
line = parts_of('@1 replaces the man in the back of the police car.\nThe man in the cap says "Move."', clip_sound=True,
|
||||
speech=plain([(0.5, 2.0)]), **SWAP_JOBS)["detailed_description"]
|
||||
self.assertNotIn("Move.", line)
|
||||
|
||||
def test_a_swap_with_no_words_typed_speaks_as_the_video_does(self):
|
||||
for script in ("@1 replaces the man in the hat.", ""):
|
||||
parts = parts_of(script, clip_sound=True, speech=plain([(0.4, 1.9), (2.3, 4.0)]), **SWAP_JOBS)
|
||||
line = parts["detailed_description"]
|
||||
self.assertIn("<Subject 2> speaks as in <Video 1>, each word landing exactly on the lip movement of <Video 1>.", line)
|
||||
self.assertNotIn("says nothing", line)
|
||||
self.assertNotIn("keeps the lips closed", line)
|
||||
self.assertEqual(parts["overall_soundscape"], "The original sound of <Video 1> continues throughout.")
|
||||
|
||||
def test_no_words_typed_and_no_speech_heard_says_nothing_of_the_lips(self):
|
||||
line = parts_of("@1 replaces the man in the hat.", clip_sound=True, speech=[], **SWAP_JOBS)["detailed_description"]
|
||||
self.assertNotIn("lips", line)
|
||||
self.assertNotIn("speaks as in", line)
|
||||
|
||||
def test_a_typed_sound_wins_over_the_clips_sound(self):
|
||||
parts = parts_of("@1 replaces the man in the hat.\nsound: rain on the roof", clip_sound=True, speech=plain([(0.4, 1.9)]), **SWAP_JOBS)
|
||||
self.assertIn("rain on the roof", parts["overall_soundscape"].lower())
|
||||
|
||||
def test_a_typed_time_wins_over_the_sound(self):
|
||||
script = '@1 replaces the man at the till.\n3.5s: @1 says "Again?"'
|
||||
line = parts_of(script, clip_sound=True, speech=plain([(1.0, 2.0)]), **SWAP_JOBS)["detailed_description"]
|
||||
self.assertIn("At 00:03.500, <Subject 2> (S1) says", line)
|
||||
|
||||
def test_too_little_speech_keeps_the_writers_own_timing(self):
|
||||
line = parts_of(STATE, clip_sound=True, speech=plain([(0.1, 6.5)]), **SWAP_JOBS)["detailed_description"]
|
||||
self.assertIn("<d>[English] I thought it was state property.</d>", line)
|
||||
self.assertNotIn("Who told you", line)
|
||||
|
||||
def test_the_node_times_a_swap_from_the_videos_sound(self):
|
||||
rate = 16000
|
||||
sound = {"waveform": torch.from_numpy(spoken(STATE_TIMES, 7.0, rate)).reshape(1, 1, -1), "sample_rate": rate}
|
||||
frames = torch.zeros(163, 8, 8, 3)
|
||||
node = AusBossH3PromptWriter()
|
||||
on = node.write(STATE, 6, picture_1=PICTURE, video=frames, video_sound=sound, video_job="swap the person", clip_sound=True)
|
||||
self.assertIn("At 00:02.2", on[0])
|
||||
self.assertNotIn("Who told you", on[0])
|
||||
self.assertIsNone(on[6]) # A swap still sends H3 none of the video's sound itself.
|
||||
off = node.write(STATE, 6, picture_1=PICTURE, video=frames, video_sound=sound, video_job="swap the person")
|
||||
self.assertIn("Who told you", off[0])
|
||||
hat = node.write("@1 replaces the man in the hat.", 6, picture_1=PICTURE, video=frames, video_sound=sound, video_job="swap the person",
|
||||
clip_sound=True)[0]
|
||||
self.assertIn("<Subject 2> speaks as in <Video 1>", hat)
|
||||
self.assertNotIn("says nothing", hat)
|
||||
moves = node.write('@1 dances.', 6, picture_1=PICTURE, video=frames, video_sound=sound, video_job="copy its moves", clip_sound=True)
|
||||
self.assertEqual(moves[0], node.write('@1 dances.', 6, picture_1=PICTURE, video=frames, video_sound=sound, video_job="copy its moves")[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -40,18 +40,22 @@ HEAVY = ("av", "PIL", "numpy", "torch")
|
||||
REAL_MEDIA_STACK = all(importlib.util.find_spec(name) for name in HEAVY)
|
||||
|
||||
# Only the import line needs these; the guards never call into them.
|
||||
if importlib.util.find_spec("av") is None:
|
||||
sys.modules["av"] = types.ModuleType("av")
|
||||
for _mod_name in ("av", "numpy", "torch"):
|
||||
if importlib.util.find_spec(_mod_name) is None:
|
||||
sys.modules[_mod_name] = types.ModuleType(_mod_name)
|
||||
if importlib.util.find_spec("PIL") is None:
|
||||
pil = types.ModuleType("PIL")
|
||||
for part in ("Image", "ImageOps", "ImageSequence"):
|
||||
setattr(pil, part, types.ModuleType(f"PIL.{part}"))
|
||||
sys.modules["PIL"] = pil
|
||||
|
||||
from nodes import _media_helpers # noqa: E402
|
||||
from nodes import _audio_trim_helpers, _media_helpers # noqa: E402
|
||||
from nodes._audio_trim_helpers import list_input_audio # noqa: E402
|
||||
from nodes._media_helpers import ( # noqa: E402
|
||||
INPUT_FILE_RULE,
|
||||
SERVER_FILE_RULE,
|
||||
list_input_images,
|
||||
list_input_videos,
|
||||
list_output_videos,
|
||||
resolve_input_path,
|
||||
resolve_server_file,
|
||||
@@ -337,6 +341,65 @@ class OutputListTests(TempFolders):
|
||||
self.assertEqual(list_output_videos(), [])
|
||||
|
||||
|
||||
class InputListTests(TempFolders):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
getattr(_media_helpers, "clear_input_files_cache", lambda: None)()
|
||||
patcher = patch.object(_audio_trim_helpers, "folder_paths", self.folders)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def test_input_lists_find_nested_files_and_skip_hidden_or_external_entries(self):
|
||||
self.touch(self.input / "sub" / "take.mp4")
|
||||
self.touch(self.input / "voice.wav")
|
||||
hidden = self.input / ".trash"
|
||||
hidden.mkdir()
|
||||
self.touch(hidden / "old.png")
|
||||
self.touch(self.input / ".hidden.mp3")
|
||||
(self.input / "linked.png").symlink_to(self.outside)
|
||||
(self.input / "linked_dir").symlink_to(self.output, target_is_directory=True)
|
||||
self.assertEqual(list_input_images(), ["clipspace/painted.png", "pic.png", "sub/pic.png"])
|
||||
self.assertEqual(list_input_videos(), ["sub/take.mp4"])
|
||||
self.assertEqual(list_input_audio(), ["sub/take.mp4", "voice.wav"])
|
||||
|
||||
def test_file_links_inside_input_remain_selectable(self):
|
||||
target = self.touch(self.input / "sub" / "take.mp4")
|
||||
(self.input / "alias.mp4").symlink_to(Path("sub") / target.name)
|
||||
(self.input / "broken.mp4").symlink_to("missing.mp4")
|
||||
(self.input / "loop.mp4").symlink_to("loop.mp4")
|
||||
(self.input / "linked_dir").symlink_to(self.input / "sub", target_is_directory=True)
|
||||
self.assertEqual(list_input_videos(), ["alias.mp4", "sub/take.mp4"])
|
||||
self.assertEqual(list_input_audio(), ["alias.mp4", "sub/take.mp4"])
|
||||
self.assertEqual(resolve_input_path("alias.mp4"), target)
|
||||
|
||||
def test_input_lists_share_one_scandir_walk_without_per_file_stat(self):
|
||||
self.touch(self.input / "sub" / "take.mp4")
|
||||
self.touch(self.input / "voice.wav")
|
||||
scanned: list[str] = []
|
||||
real_scandir = os.scandir
|
||||
|
||||
def counting_scandir(path):
|
||||
scanned.append(str(path))
|
||||
return real_scandir(path)
|
||||
|
||||
with (
|
||||
patch.object(os, "scandir", side_effect=counting_scandir),
|
||||
patch.object(Path, "is_file", side_effect=AssertionError("per-file Path.is_file called")),
|
||||
):
|
||||
for _ in range(2):
|
||||
self.assertEqual(list_input_images(), ["clipspace/painted.png", "pic.png", "sub/pic.png"])
|
||||
self.assertEqual(list_input_videos(), ["sub/take.mp4"])
|
||||
self.assertEqual(list_input_audio(), ["sub/take.mp4", "voice.wav"])
|
||||
# input/, input/sub/, input/clipspace/: walked once total across all 6 calls.
|
||||
self.assertEqual(len(scanned), 3, scanned)
|
||||
|
||||
def test_new_top_level_upload_refreshes_the_cached_list_immediately(self):
|
||||
self.assertEqual(list_input_images(), ["clipspace/painted.png", "pic.png", "sub/pic.png"])
|
||||
self.touch(self.input / "fresh.png")
|
||||
os.utime(self.input, (2_000_000, 2_000_000))
|
||||
self.assertIn("fresh.png", list_input_images())
|
||||
|
||||
|
||||
class RouteErrorTests(unittest.TestCase):
|
||||
def test_a_message_that_names_a_place_on_the_server_is_replaced(self):
|
||||
for text in (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user