Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8508a9dc3f | ||
|
|
adfaedd233 | ||
|
|
6a56a9d519 | ||
|
|
bf95ebef42 |
@@ -8,7 +8,7 @@ node is prefixed **SF** so they're easy to find in the node search.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [**Stillfront/WaveSpeed**](#stillfrontwavespeed) | 52 nodes — image, video and 3D generation through [WaveSpeed AI](https://wavespeed.ai): VEO 3.1, Sora 2, Seedance 2.5, GPT Image 2.5, Nano Banana 2.1/Pro, Seedream 5.0, Qwen, Flux Kontext, WAN 2.5, InfiniteTalk, Tripo P2 and H3.1, Rodin 2.5, Meshy 7.1, upscalers |
|
||||
| [**Stillfront/WaveSpeed**](#stillfrontwavespeed) | 54 nodes — image, video and 3D generation through [WaveSpeed AI](https://wavespeed.ai): VEO 3.1, Sora 2, Seedance 2.5, GPT Image 2.5, Nano Banana 2.1/Pro, Seedream 5.0, Qwen, Flux Kontext, WAN 2.5, InfiniteTalk, Tripo P2 and H3.1, Rodin 2.5, Meshy 7.1, upscalers |
|
||||
| [**Stillfront/VertexAI**](#stillfrontvertexai) | 7 nodes — Imagen 3/4, VEO 3.1 and Nano Banana Pro via Google Cloud Vertex AI |
|
||||
| [**Stillfront/Utils**](#stillfrontutils) | 7 nodes — Google Sheets integration, prompt batching, resolution presets |
|
||||
| [**Stillfront/LLM**](#stillfrontllm) | 6 nodes — Claude, Gemini and GPT in your graph, plus the asset library tooling and a system-prompt coach below |
|
||||
@@ -335,6 +335,8 @@ named without it for new work.
|
||||
| **SF WaveSpeed GPT Image 2.5** | OpenAI's GPT Image models through WaveSpeed, built to match ComfyUI's *OpenAI GPT Image 2.5* partner node — see below. |
|
||||
| **SF WaveSpeed Nano Banana** | Google's Nano Banana 2.1, Nano Banana 2 and Nano Banana Pro through WaveSpeed, generate and edit in one node, built to match ComfyUI's *Nano Banana Pro (Google Gemini Image)* partner node — see below. |
|
||||
| **SF WaveSpeed Seedream 5.0** | ByteDance Seedream 5.0 Pro or Flash, generate and edit in one node. Up to 10 references, up to 2K. Flash is a flat $0.027 at any resolution. |
|
||||
| **SF WaveSpeed Outpaint Resizer** | Turns one image into several resolutions without redrawing it: crop where that is enough, otherwise FLUX Fill, Bria GenFill or Bria Expand paints only the new strips. Optional torn-edge mask on every output — see below. |
|
||||
| **SF Banner Resizer** | Reframes one finished image into several resolutions — crop where that is enough, otherwise GPT Image 2.5 recomposes it, as in the Art Pipeline Hub — see below. |
|
||||
| **SF WaveSpeed Nano Banana Text to Image (Legacy)** | Nano Banana text-to-image generation. |
|
||||
| **SF WaveSpeed Nano Banana Edit (Legacy)** | Edit images using the Nano Banana model. |
|
||||
| **SF WaveSpeed Nano Banana Pro Text to Image (Legacy)** | Nano Banana Pro text-to-image. |
|
||||
@@ -395,6 +397,47 @@ output** (the partner's second output, a written response from the model), **`re
|
||||
the **system prompt**, and the **`files`** input. As on the partner node, `seed` isn't used by the
|
||||
model — change it to force a re-run.
|
||||
|
||||
#### Using SF WaveSpeed Outpaint Resizer
|
||||
|
||||
Turns one finished image into up to 8 sizes, edited in the **Output sizes** list on the node (width,
|
||||
height and a mode per row). Each row adds an output labelled with its size.
|
||||
|
||||
- **auto** (default) crops when that cuts away no more than **auto_crop_limit** (15%) of the picture,
|
||||
and outpaints otherwise. **crop** always scales and centre-crops; **outpaint** always paints new
|
||||
area. A size with the image's own shape is just resized. Crops and resizes are free.
|
||||
- **Outpainting** never redraws the artwork: the chosen model — **flux-fill** (cleanest joins, best for
|
||||
strips above/below; **clean_corners** repaints FLUX's occasional fake signature), **bria-genfill**
|
||||
(best for strips left/right) or **bria-expand** (strong on squares) — paints only the new strips,
|
||||
the original pixels are pasted back with a soft seam, and each size is scaled from that. Sizes that
|
||||
share a shape share one paid generation.
|
||||
- **prompt** is the whole prompt every model receives, sent as written; emptying it restores the
|
||||
default. bria-expand also has a visible **negative_prompt**.
|
||||
- **edge_mask** (optional): connect a mask of a shaped edge, such as a torn-paper right edge, and pick
|
||||
**mask_side**. The whole shape is scaled to the full length of that side of every output and cut out,
|
||||
so the outputs carry transparency and Save Image writes transparent PNGs. The masked area of the input
|
||||
is filled in first, so no old edge pixels leak into the results. **invert_mask** flips it.
|
||||
|
||||
The **plan** output lists what happened to each size, the paid calls and the estimated cost; the badge
|
||||
shows the worst case, since auto rows may turn out free.
|
||||
|
||||
#### Using SF Banner Resizer
|
||||
|
||||
Turns one finished image into as many sizes as you list, one per line in **resolutions**
|
||||
(`1920x1080`, `800x450`, …, up to 8). Each line adds an output labelled with its size. It works the
|
||||
way the Art Pipeline Hub's marketing pipeline makes its final sizes:
|
||||
|
||||
- **Sizes within crop_threshold** (default 8%) of the image's shape are cover-resized and centre-
|
||||
cropped. No AI, no cost.
|
||||
- **Every other size is recomposed by GPT Image 2.5**: the image goes to GPT with **reflow_prompt**
|
||||
(the Hub's own, editable) at the nearest shape GPT supports, and GPT redraws it in that shape —
|
||||
extending the existing background and repositioning what is there rather than inventing new
|
||||
elements. The result is then cover-resized and centre-cropped to the exact pixels. Sizes that
|
||||
share a shape share one generation.
|
||||
|
||||
Because GPT redraws the whole picture there are no seams, but fine detail can shift slightly from
|
||||
the original. The **plan** output lists what was done for every size. The badge shows the worst
|
||||
case: one generation per distinct shape.
|
||||
|
||||
#### Using SF WaveSpeed GPT Image 2.5
|
||||
|
||||
Same interface as ComfyUI's partner node: pick a model and the controls below it change to what
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
# ABOUTME: Reframes one image into several resolutions the way the Art Pipeline Hub's marketing pipeline does:
|
||||
# ABOUTME: crop when the shape barely changes, otherwise GPT Image 2.5 (WaveSpeed) reflows it, then cover-resize and crop
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
from comfy_api.latest import IO
|
||||
|
||||
from .wavespeed_api.client import WaveSpeedClient
|
||||
from .wavespeed_api.utils import tensor2images
|
||||
from .wavespeed_api.v3 import fetch_images, submit_and_collect, upload_images
|
||||
|
||||
MAX_OUTPUTS = 8
|
||||
GPT_MODELS = {
|
||||
"gpt-image-2.5-sunburst": "openai/gpt-image-2.5-sunburst",
|
||||
"gpt-image-2.5-flare": "openai/gpt-image-2.5-flare",
|
||||
}
|
||||
# Aspect ratios GPT Image 2.5 can generate on WaveSpeed.
|
||||
GPT_RATIOS = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9",
|
||||
"1:2", "2:1", "1:3", "3:1", "9:21", "21:9"]
|
||||
|
||||
# The Art Pipeline Hub's REFLOW_PROMPT (marketing-creative, ported from GGS2), worded for any image.
|
||||
# {width} and {height} are filled in per generation.
|
||||
DEFAULT_REFLOW_PROMPT = """Adapt this exact image to fit a {width}x{height} canvas. This is a professional resize task, not a redesign: preserve every element exactly as shown -- the character(s), all text, all UI panels and values, the logo -- do not crop out, remove, invent, or change any of them. Rearrange and reflow the composition and layout, the way a professional graphic designer would when adapting a creative to a new aspect ratio, repositioning elements so everything stays fully visible and well-composed in the new shape. Do not invent new characters, do not change any numbers, labels, or text, do not alter the art style.
|
||||
|
||||
Two hard requirements this needs to get right:
|
||||
1. Never stretch, squash, or distort any element's proportions to fit the new shape -- every character, panel, icon, and piece of text must keep its own natural width-to-height ratio. Resize elements uniformly (same scale on both axes) or reposition them; never scale an element differently on one axis than the other.
|
||||
2. Use the entire canvas meaningfully -- do not just pad extra space with empty sky, floor, or blank background. If the new shape is much taller or wider than the original, fill the added space primarily by repositioning and resizing the EXISTING elements (the same background, the same UI panels) to occupy more of the canvas, or by extending the same existing background texture/silhouette a bit further in the same style and level of detail. Do not invent new distinct scene elements (extra buildings, new set pieces, new landmarks) that aren't already present, even partially, in the source -- this image is one of several ratio variants of the same campaign, and every variant needs to look like the same scene, not a fresh reinterpretation of it.
|
||||
|
||||
Do not add a border, caption, watermark, or logo of your own -- any logo is composited separately, afterward."""
|
||||
|
||||
|
||||
def parse_resolutions(text):
|
||||
sizes = []
|
||||
for token in re.split(r"[,\n;]+", text):
|
||||
token = token.strip()
|
||||
if not token:
|
||||
continue
|
||||
match = re.fullmatch(r"(\d+)\s*[xX×*]\s*(\d+)", token)
|
||||
if not match:
|
||||
raise ValueError(f"Can't read resolution '{token}'. Write each as WIDTHxHEIGHT, e.g. 1920x1080.")
|
||||
w, h = int(match.group(1)), int(match.group(2))
|
||||
if not (16 <= w <= 8192 and 16 <= h <= 8192):
|
||||
raise ValueError(f"Resolution {w}x{h} is outside 16-8192 pixels.")
|
||||
sizes.append((w, h))
|
||||
if not sizes:
|
||||
raise ValueError("Add at least one resolution, e.g. 1920x1080.")
|
||||
if len(sizes) > MAX_OUTPUTS:
|
||||
raise ValueError(f"At most {MAX_OUTPUTS} resolutions per node; got {len(sizes)}.")
|
||||
return sizes
|
||||
|
||||
|
||||
def needs_reflow(w, h, W, H, threshold):
|
||||
"""True when a plain crop would take more than `threshold` of the image (the Hub's 8% rule)."""
|
||||
a, A = w / h, W / H
|
||||
return 1 - min(a, A) / max(a, A) > threshold + 1e-9
|
||||
|
||||
|
||||
def nearest_ratio(W, H):
|
||||
"""The supported ratio closest to WxH, compared in log space so 16:9 and 9:16 are
|
||||
equally far from square."""
|
||||
wanted = math.log(W / H)
|
||||
return min(GPT_RATIOS, key=lambda r: abs(math.log(int(r.split(":")[0]) / int(r.split(":")[1])) - wanted))
|
||||
|
||||
|
||||
def generation_tier(W, H, setting):
|
||||
if setting != "auto":
|
||||
return setting
|
||||
side = max(W, H)
|
||||
return "1k" if side <= 1100 else "2k" if side <= 2200 else "4k"
|
||||
|
||||
|
||||
def fill_crop(image, W, H):
|
||||
"""Scale to cover WxH, then centre-crop to exactly that: fill the frame, never letterbox or squash."""
|
||||
return ImageOps.fit(image, (W, H), method=Image.LANCZOS, centering=(0.5, 0.5))
|
||||
|
||||
|
||||
def to_tensor(image):
|
||||
return torch.from_numpy(np.asarray(image.convert("RGB"), dtype=np.float32) / 255.0)[None,]
|
||||
|
||||
|
||||
class SFBannerResizer(IO.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="SFBannerResizer",
|
||||
display_name="SF Banner Resizer",
|
||||
category="Stillfront/WaveSpeed",
|
||||
description=(
|
||||
"Reframes one image into several resolutions, as the Art Pipeline Hub's marketing pipeline does. "
|
||||
"Sizes within crop_threshold of the image's shape are cropped; for the rest GPT Image 2.5 "
|
||||
"recomposes the image in the nearest shape it supports, then it is cover-resized and cropped "
|
||||
"to the exact pixels. One output per resolution."
|
||||
),
|
||||
inputs=[
|
||||
IO.Custom("WAVESPEED_AI_API_CLIENT").Input("client"),
|
||||
IO.Image.Input("image", tooltip="The finished artwork. Only the first frame of a batch is used."),
|
||||
IO.String.Input("resolutions", multiline=True, default="1920x1080\n800x450",
|
||||
tooltip=f"One WIDTHxHEIGHT per line (up to {MAX_OUTPUTS}). Each adds an output, "
|
||||
"in the same order."),
|
||||
IO.String.Input("reflow_prompt", multiline=True, default=DEFAULT_REFLOW_PROMPT,
|
||||
tooltip="Sent to GPT Image with the image for every size that is recomposed. "
|
||||
"{width} and {height} are replaced with the size. Connect your own text to override."),
|
||||
IO.Combo.Input("gpt_model", options=list(GPT_MODELS), default="gpt-image-2.5-sunburst"),
|
||||
IO.Combo.Input("quality", options=["low", "medium", "high", "xhigh", "max"], default="high",
|
||||
tooltip="GPT Image quality. Affects cost."),
|
||||
IO.Combo.Input("generation_resolution", options=["auto", "1k", "2k", "4k"], default="auto",
|
||||
tooltip="GPT output tier. 'auto' picks by the largest size sharing that shape."),
|
||||
IO.Float.Input("crop_threshold", default=0.08, min=0.0, max=0.5, step=0.01,
|
||||
tooltip="Sizes a crop of at most this fraction of the image can reach are only "
|
||||
"cropped and resized: no AI, no cost. The Hub uses 0.08."),
|
||||
IO.Int.Input("seed", default=0, min=0, max=2147483647, control_after_generate=True,
|
||||
tooltip="Not sent anywhere. Change it to force a re-run."),
|
||||
IO.Int.Input("timeout_seconds", default=900, min=60, max=3600, step=30, advanced=True),
|
||||
],
|
||||
outputs=[IO.String.Output("plan", tooltip="What was done for each resolution.")]
|
||||
+ [IO.Image.Output(f"image_{i}") for i in range(1, MAX_OUTPUTS + 1)],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def execute(cls, client, image, resolutions, reflow_prompt, gpt_model, quality, generation_resolution,
|
||||
crop_threshold, seed=0, timeout_seconds=900):
|
||||
sizes = parse_resolutions(resolutions)
|
||||
original = tensor2images(image)[0].convert("RGB")
|
||||
w, h = original.size
|
||||
|
||||
# Sizes that need recomposing share one generation per GPT ratio, made for the largest of them.
|
||||
groups = {}
|
||||
for W, H in sizes:
|
||||
if needs_reflow(w, h, W, H, crop_threshold):
|
||||
groups.setdefault(nearest_ratio(W, H), []).append((W, H))
|
||||
|
||||
api = WaveSpeedClient(api_key=client["api_key"])
|
||||
bases = {}
|
||||
if groups:
|
||||
[url] = await upload_images(api, client, [original])
|
||||
|
||||
async def reflow(ratio, members):
|
||||
W, H = max(members, key=lambda s: s[0] * s[1])
|
||||
tier = generation_tier(W, H, generation_resolution)
|
||||
prompt = reflow_prompt.replace("{width}", str(W)).replace("{height}", str(H))
|
||||
payload = {"prompt": prompt, "images": [url], "aspect_ratio": ratio, "resolution": tier,
|
||||
"quality": quality, "output_format": "png"}
|
||||
print(f"[SF Banner Resizer] Reflowing to {ratio} at {tier}/{quality} for "
|
||||
f"{', '.join(f'{a}x{b}' for a, b in members)}.")
|
||||
urls = await submit_and_collect(api, client, f"/api/v3/{GPT_MODELS[gpt_model]}/edit", payload, 1,
|
||||
timeout_seconds, f"Banner Resizer {ratio}")
|
||||
[generated] = await fetch_images(urls)
|
||||
return ratio, generated.convert("RGB")
|
||||
|
||||
bases = dict(await asyncio.gather(*(reflow(r, m) for r, m in groups.items())))
|
||||
|
||||
results, report = [], []
|
||||
for W, H in sizes:
|
||||
if needs_reflow(w, h, W, H, crop_threshold):
|
||||
ratio = nearest_ratio(W, H)
|
||||
final = fill_crop(bases[ratio], W, H)
|
||||
report.append({"size": f"{W}x{H}", "mode": "reflowed by GPT Image", "gpt_ratio": ratio})
|
||||
else:
|
||||
final = fill_crop(original, W, H)
|
||||
report.append({"size": f"{W}x{H}", "mode": "crop and resize"})
|
||||
results.append(to_tensor(final))
|
||||
|
||||
print(f"[SF Banner Resizer] Done: {len(results)} image(s), {len(groups)} GPT generation(s).")
|
||||
plan = json.dumps({"source": f"{w}x{h}", "outputs": report}, indent=2)
|
||||
return IO.NodeOutput(plan, *results, *([None] * (MAX_OUTPUTS - len(results))))
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"SFBannerResizer": SFBannerResizer}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"SFBannerResizer": "SF Banner Resizer"}
|
||||
@@ -0,0 +1,417 @@
|
||||
# ABOUTME: Reframes one image into several resolutions WITHOUT redrawing it: a WaveSpeed fill model paints only the
|
||||
# ABOUTME: new strips (FLUX Fill, Bria GenFill, Bria Expand), the original is pasted back, then each size is scaled
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image, ImageFilter, ImageOps
|
||||
|
||||
from comfy_api.latest import IO
|
||||
|
||||
from .wavespeed_api.client import WaveSpeedClient
|
||||
from .wavespeed_api.outpaint import (
|
||||
canvas_and_mask,
|
||||
crop_around_original,
|
||||
fit_source_to_target,
|
||||
geometry,
|
||||
locate_original,
|
||||
model_size,
|
||||
paste_back,
|
||||
pick_expand_ratio,
|
||||
same_shape,
|
||||
)
|
||||
from .wavespeed_api.utils import tensor2images
|
||||
from .wavespeed_api.v3 import fetch_images, submit_and_wait, upload_images
|
||||
|
||||
MAX_OUTPUTS = 8
|
||||
LABEL = "Outpaint Resizer"
|
||||
|
||||
# method -> (endpoint, price per generation in USD, read from wavespeed.ai 2026-11-09)
|
||||
METHODS = {
|
||||
"flux-fill": ("/api/v3/wavespeed-ai/flux-fill-dev", 0.035),
|
||||
"bria-genfill": ("/api/v3/bria/fibo-edit/genfill", 0.04),
|
||||
"bria-expand": ("/api/v3/bria/expand", 0.04),
|
||||
}
|
||||
EXPAND_RATIOS = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9"]
|
||||
|
||||
# The whole prompt, shown in the node's prompt box; nothing is added to it. The last sentence is said
|
||||
# positively on purpose: FLUX's text encoder handles "no X" poorly, and naming "signature" in the prompt
|
||||
# can make one MORE likely (an earlier "No text, no signature..." wording did not stop it).
|
||||
DEFAULT_PROMPT = (
|
||||
"Continue the surrounding scene seamlessly into the empty areas, in exactly the same art style, "
|
||||
"lighting, colour palette and level of detail as the rest of the image. Plain, unmarked painted "
|
||||
"surface right up to every edge and corner of the picture."
|
||||
)
|
||||
DEFAULT_NEGATIVE = "text, signature, watermark, logo, letters, initials, border, frame"
|
||||
|
||||
# Where FLUX tends to sign: the bottom corners of the whole picture. Fraction of the canvas per corner.
|
||||
CORNER_W, CORNER_H = 0.22, 0.18
|
||||
|
||||
|
||||
MODES = ("auto", "crop", "outpaint")
|
||||
|
||||
|
||||
def parse_resolutions(text):
|
||||
"""The size list as [(W, H, mode)]. The list editor stores JSON rows; plain text (one WIDTHxHEIGHT
|
||||
per line, optionally followed by a mode) still works, so older workflows keep their sizes."""
|
||||
rows = []
|
||||
raw = (text or "").strip()
|
||||
if raw.startswith("["):
|
||||
try:
|
||||
entries = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"The resolution list could not be read ({e}).")
|
||||
for e in entries:
|
||||
rows.append((int(e.get("w", 0)), int(e.get("h", 0)), str(e.get("mode", "auto")).lower()))
|
||||
else:
|
||||
for token in re.split(r"[,\n;]+", raw):
|
||||
token = token.strip()
|
||||
if not token:
|
||||
continue
|
||||
match = re.fullmatch(r"(\d+)\s*[xX×*]\s*(\d+)(?:\s+(\w+))?", token)
|
||||
if not match:
|
||||
raise ValueError(f"Can't read resolution '{token}'. Write each as WIDTHxHEIGHT, e.g. 1920x1080.")
|
||||
rows.append((int(match.group(1)), int(match.group(2)), (match.group(3) or "auto").lower()))
|
||||
for w, h, mode in rows:
|
||||
if not (16 <= w <= 8192 and 16 <= h <= 8192):
|
||||
raise ValueError(f"Resolution {w}x{h} is outside 16-8192 pixels.")
|
||||
if mode not in MODES:
|
||||
raise ValueError(f"Unknown mode '{mode}' for {w}x{h}; use auto, crop or outpaint.")
|
||||
if not rows:
|
||||
raise ValueError("Add at least one resolution.")
|
||||
if len(rows) > MAX_OUTPUTS:
|
||||
raise ValueError(f"At most {MAX_OUTPUTS} resolutions per node; got {len(rows)}.")
|
||||
return rows
|
||||
|
||||
|
||||
def crop_fraction(sw, sh, W, H):
|
||||
"""How much of the image a scale-and-centre-crop to WxH cuts away (0..1)."""
|
||||
a, A = sw / sh, W / H
|
||||
return 1 - min(a, A) / max(a, A)
|
||||
|
||||
|
||||
def decide(sw, sh, W, H, mode, auto_limit):
|
||||
"""'resize' (same shape), 'crop' or 'outpaint' for one size."""
|
||||
if same_shape(sw, sh, W, H):
|
||||
return "resize"
|
||||
if mode == "crop" or (mode == "auto" and crop_fraction(sw, sh, W, H) <= auto_limit + 1e-9):
|
||||
return "crop"
|
||||
return "outpaint"
|
||||
|
||||
|
||||
def shape_key(w, h):
|
||||
"""Sizes whose shapes differ by under 0.5% share one generation (1920x1080 and 1280x720 do)."""
|
||||
return round(math.log(w / h), 2)
|
||||
|
||||
|
||||
def to_tensor(image):
|
||||
return torch.from_numpy(np.asarray(image.convert("RGB"), dtype=np.float32) / 255.0)[None,]
|
||||
|
||||
|
||||
def exact(image, W, H):
|
||||
"""Scale to WxH; the canvas already has that shape, so at most a pixel of rounding is cropped."""
|
||||
return ImageOps.fit(image, (W, H), method=Image.LANCZOS, centering=(0.5, 0.5))
|
||||
|
||||
|
||||
MASK_SIDES = ["right", "left", "top", "bottom"]
|
||||
|
||||
|
||||
def fit_edge_mask(mask, side, W, H, threshold=0.02):
|
||||
"""Carries a shaped edge (e.g. a torn-paper right edge) over to a WxH output.
|
||||
|
||||
`mask` is HxW in 0..1, 1 = cut away. The band of the mask that holds the shape is taken from
|
||||
`side` (from that edge in to the furthest masked pixel), scaled uniformly so the whole shape spans
|
||||
exactly the length of the output's edge on that side, and placed flush against it. Never tiled,
|
||||
mirrored or stretched. Everything else is kept (0)."""
|
||||
mh, mw = mask.shape
|
||||
out = np.zeros((H, W), dtype=np.float32)
|
||||
horizontal = side in ("left", "right")
|
||||
profile = mask.max(axis=0 if horizontal else 1) > threshold
|
||||
if not profile.any():
|
||||
return out
|
||||
idx = np.nonzero(profile)[0]
|
||||
if side == "right":
|
||||
band = mask[:, idx.min():]
|
||||
elif side == "left":
|
||||
band = mask[:, :idx.max() + 1]
|
||||
elif side == "bottom":
|
||||
band = mask[idx.min():, :]
|
||||
else:
|
||||
band = mask[:idx.max() + 1, :]
|
||||
bh, bw = band.shape
|
||||
scale = H / mh if horizontal else W / mw
|
||||
nw, nh = (max(1, round(bw * scale)), H) if horizontal else (W, max(1, round(bh * scale)))
|
||||
resized = np.asarray(Image.fromarray(band.astype(np.float32), mode="F").resize((nw, nh), Image.BILINEAR))
|
||||
if side == "right":
|
||||
take = min(nw, W)
|
||||
out[:, W - take:] = resized[:, nw - take:]
|
||||
elif side == "left":
|
||||
take = min(nw, W)
|
||||
out[:, :take] = resized[:, :take]
|
||||
elif side == "bottom":
|
||||
take = min(nh, H)
|
||||
out[H - take:, :] = resized[nh - take:, :]
|
||||
else:
|
||||
take = min(nh, H)
|
||||
out[:take, :] = resized[:take, :]
|
||||
return np.clip(out, 0.0, 1.0)
|
||||
|
||||
|
||||
def clean_masked_area(image, mask):
|
||||
"""Fills the cut-away area of the input from its surroundings, so the model never sees (and
|
||||
extends) whatever sits under a torn edge."""
|
||||
import cv2
|
||||
|
||||
hole = (np.asarray(Image.fromarray((mask * 255).astype(np.uint8)).resize(image.size, Image.BILINEAR)) > 127)
|
||||
if not hole.any():
|
||||
return image
|
||||
hole = cv2.dilate(hole.astype(np.uint8) * 255, np.ones((5, 5), np.uint8))
|
||||
filled = cv2.inpaint(np.asarray(image.convert("RGB")), hole, 7, cv2.INPAINT_TELEA)
|
||||
return Image.fromarray(filled)
|
||||
|
||||
|
||||
def with_alpha(image_tensor, cut):
|
||||
"""RGB tensor (1,H,W,3) plus a cut-away mask (H,W) -> RGBA tensor, transparent where cut is 1."""
|
||||
alpha = torch.from_numpy(1.0 - cut)[None, :, :, None].to(image_tensor)
|
||||
return torch.cat((image_tensor[..., :3], alpha), dim=-1)
|
||||
|
||||
|
||||
def corner_mask(cw, ch, ox, oy, sw, sh):
|
||||
"""White over the bottom-left and bottom-right corners of the canvas, but only where the canvas is new:
|
||||
the original is never part of it. None when neither corner touches new canvas."""
|
||||
mask = Image.new("L", (cw, ch), 0)
|
||||
w, h = max(48, round(cw * CORNER_W)), max(48, round(ch * CORNER_H))
|
||||
mask.paste(255, (0, ch - h, w, ch))
|
||||
mask.paste(255, (cw - w, ch - h, cw, ch))
|
||||
mask.paste(0, (ox, oy, ox + sw, oy + sh))
|
||||
return mask if mask.getbbox() else None
|
||||
|
||||
|
||||
class SFWaveSpeedOutpaintResizer(IO.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="SFWaveSpeedOutpaintResizer",
|
||||
display_name="SF WaveSpeed Outpaint Resizer",
|
||||
category="Stillfront/WaveSpeed",
|
||||
description=(
|
||||
"Turns one image into several resolutions without redrawing it. For each new shape, a WaveSpeed "
|
||||
"fill model paints only the strips that need adding (FLUX Fill, Bria GenFill or Bria Expand); "
|
||||
"the original's own pixels are pasted back on top, and the whole picture is scaled to each "
|
||||
"exact size so old and new areas share one level of detail. Sizes with the image's own shape are "
|
||||
"just resized, for free. One output per resolution."
|
||||
),
|
||||
inputs=[
|
||||
IO.Custom("WAVESPEED_AI_API_CLIENT").Input("client"),
|
||||
IO.Image.Input("image", tooltip="The finished artwork. Only the first frame of a batch is used."),
|
||||
IO.String.Input("resolutions", multiline=True,
|
||||
default='[{"w": 380, "h": 380, "mode": "auto"}, {"w": 998, "h": 432, "mode": "auto"}]',
|
||||
tooltip=f"The output sizes (up to {MAX_OUTPUTS}), edited in the list on the node. "
|
||||
"Each adds an output, in the same order. Per size: auto crops when that cuts "
|
||||
"away no more than auto_crop_limit of the picture and outpaints otherwise; crop "
|
||||
"always crops; outpaint always paints new area."),
|
||||
IO.String.Input("prompt", multiline=True, default=DEFAULT_PROMPT,
|
||||
tooltip="The whole prompt every model receives, sent exactly as written. Naming "
|
||||
"the scene helps a lot, e.g. 'World War I trench at night, sandbags, barbed wire, "
|
||||
"smoke, painterly'. Empty the box to go back to the default."),
|
||||
IO.DynamicCombo.Input(
|
||||
"method",
|
||||
tooltip="Price is per distinct shape. flux-fill: cleanest joins, best when strips go "
|
||||
"above/below. bria-genfill: best when strips go left/right. bria-expand: strong on squares.",
|
||||
options=[
|
||||
IO.DynamicCombo.Option("flux-fill", [
|
||||
IO.Int.Input("steps", default=28, min=1, max=50,
|
||||
tooltip="Inference steps. WaveSpeed's default is 28."),
|
||||
IO.Float.Input("guidance", default=30.0, min=28.0, max=35.0, step=0.5,
|
||||
tooltip="How closely it follows the prompt (28-35)."),
|
||||
IO.Boolean.Input("clean_corners", default=True,
|
||||
tooltip="FLUX often paints a fake artist signature in a bottom "
|
||||
"corner. When on, Bria GenFill repaints the bottom corners of the "
|
||||
"NEW areas afterwards (never the original). +$0.04 per shape."),
|
||||
]),
|
||||
IO.DynamicCombo.Option("bria-genfill", []),
|
||||
IO.DynamicCombo.Option("bria-expand", [
|
||||
IO.String.Input("negative_prompt", multiline=True, default=DEFAULT_NEGATIVE,
|
||||
tooltip="What Bria Expand should avoid painting."),
|
||||
IO.Combo.Input("if_shape_impossible", options=["use bria-genfill", "stop with error"],
|
||||
default="use bria-genfill",
|
||||
tooltip="Bria Expand only makes 1:1, 2:3, 3:2, 3:4, 4:3, 4:5, 5:4, "
|
||||
"9:16 and 16:9 canvases, so it can't, for example, widen a 16:9 "
|
||||
"image. Those sizes are made with Bria GenFill instead, or stop."),
|
||||
]),
|
||||
],
|
||||
),
|
||||
IO.Int.Input("seed", default=0, min=0, max=2147483647, control_after_generate=True,
|
||||
tooltip="Sent to FLUX Fill and Bria Expand; Bria GenFill has no seed, so there "
|
||||
"it only forces a re-run."),
|
||||
IO.Int.Input("timeout_seconds", default=600, min=60, max=3600, step=30, advanced=True),
|
||||
# Appended last: saved workflows restore widget values by position.
|
||||
IO.Mask.Input("edge_mask", optional=True,
|
||||
tooltip="Optional. A mask of a shaped edge (torn paper, brush stroke) to cut out of "
|
||||
"every output. White = cut away. Leave unconnected for plain images."),
|
||||
IO.Combo.Input("mask_side", options=MASK_SIDES, default="right",
|
||||
tooltip="Which side of the image the edge_mask shape belongs to. The whole shape is "
|
||||
"scaled to the full length of that side of each output (never stretched or "
|
||||
"tiled) and placed flush against it."),
|
||||
IO.Boolean.Input("invert_mask", default=False,
|
||||
tooltip="Turn on if the wrong part comes out transparent."),
|
||||
IO.Float.Input("auto_crop_limit", default=0.15, min=0.0, max=0.5, step=0.01,
|
||||
tooltip="For sizes set to auto: crop (free) when a crop cuts away at most this "
|
||||
"fraction of the picture, otherwise outpaint."),
|
||||
],
|
||||
outputs=[IO.String.Output("plan", tooltip="What was done for each resolution."),
|
||||
IO.Image.Output("mask_preview", tooltip="The first generation's mask: white is what the "
|
||||
"model was allowed to paint.")]
|
||||
+ [IO.Image.Output(f"image_{i}") for i in range(1, MAX_OUTPUTS + 1)],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def execute(cls, client, image, resolutions, prompt, method, seed=0, timeout_seconds=600,
|
||||
edge_mask=None, mask_side="right", invert_mask=False, auto_crop_limit=0.15):
|
||||
chosen = method["method"]
|
||||
if chosen not in METHODS:
|
||||
raise ValueError(f"Unknown method '{chosen}'. Pick one of: {', '.join(METHODS)}.")
|
||||
sizes = parse_resolutions(resolutions)
|
||||
original = tensor2images(image)[0].convert("RGB")
|
||||
sw, sh = original.size
|
||||
cut = None
|
||||
if edge_mask is not None:
|
||||
cut = edge_mask[0] if edge_mask.dim() == 3 else edge_mask
|
||||
cut = cut.detach().cpu().numpy().astype(np.float32)
|
||||
if invert_mask:
|
||||
cut = 1.0 - cut
|
||||
original = clean_masked_area(original, cut)
|
||||
|
||||
actions = [decide(sw, sh, W, H, mode, auto_crop_limit) for W, H, mode in sizes]
|
||||
sizes = [(W, H) for W, H, _ in sizes]
|
||||
|
||||
# One generation per distinct shape that is outpainted, sized for the largest resolution of that shape.
|
||||
groups = {}
|
||||
for (W, H), action in zip(sizes, actions):
|
||||
if action == "outpaint":
|
||||
groups.setdefault(shape_key(W, H), []).append((W, H))
|
||||
largest = {k: max(m, key=lambda s: s[0] * s[1]) for k, m in groups.items()}
|
||||
|
||||
# Which model makes each shape. Bria Expand can't make every shape; decided before anything is spent.
|
||||
made_with = {k: chosen for k in groups}
|
||||
if chosen == "bria-expand":
|
||||
impossible = [k for k, (W, H) in largest.items()
|
||||
if pick_expand_ratio(sw, sh, W, H, EXPAND_RATIOS) is None]
|
||||
if impossible and method.get("if_shape_impossible") == "stop with error":
|
||||
names = ", ".join("x".join(map(str, largest[k])) for k in impossible)
|
||||
raise ValueError(
|
||||
f"Bria Expand can't make {names} from a {sw}x{sh} image: it only builds "
|
||||
f"{', '.join(EXPAND_RATIOS)} canvases, none of which contains that shape. Set "
|
||||
"if_shape_impossible to 'use bria-genfill', or use another method.")
|
||||
for k in impossible:
|
||||
made_with[k] = "bria-genfill"
|
||||
|
||||
# Sent exactly as written; an empty box falls back to the default.
|
||||
text = prompt.strip() or DEFAULT_PROMPT
|
||||
api = WaveSpeedClient(api_key=client["api_key"])
|
||||
masks, notes, spent = {}, {}, []
|
||||
|
||||
async def masked_fill(model, canvas, mask, cw, ch, label):
|
||||
"""One FLUX Fill or Bria GenFill call on `canvas`, painting where `mask` is white. Returns the
|
||||
result at canvas size."""
|
||||
mw, mh = model_size(cw, ch)
|
||||
canvas_url, mask_url = await upload_images(
|
||||
api, client, [canvas.resize((mw, mh), Image.LANCZOS), mask.resize((mw, mh), Image.LANCZOS)])
|
||||
if model == "flux-fill":
|
||||
payload = {"prompt": text, "image": canvas_url, "mask_image": mask_url, "size": f"{mw}*{mh}",
|
||||
"num_inference_steps": method.get("steps", 28),
|
||||
"guidance_scale": method.get("guidance", 30.0), "seed": seed}
|
||||
else:
|
||||
payload = {"prompt": text, "image": canvas_url, "mask_image": mask_url}
|
||||
endpoint, price = METHODS[model]
|
||||
_, result = await submit_and_wait(api, client, endpoint, payload, timeout_seconds, label)
|
||||
spent.append(price)
|
||||
[generated] = await fetch_images(result.get("outputs") or [])
|
||||
return generated.convert("RGB").resize((cw, ch), Image.LANCZOS)
|
||||
|
||||
async def fill(key):
|
||||
W, H = largest[key]
|
||||
model = made_with[key]
|
||||
src = fit_source_to_target(original, W, H)
|
||||
if model == "bria-expand":
|
||||
return key, await expand(src, W, H)
|
||||
cw, ch, ox, oy = geometry(src.width, src.height, W, H)
|
||||
canvas, mask = canvas_and_mask(src, cw, ch, ox, oy)
|
||||
masks[key] = mask
|
||||
print(f"[SF {LABEL}] {model}: {src.width}x{src.height} -> canvas {cw}x{ch} for {W}x{H}.")
|
||||
result = paste_back(await masked_fill(model, canvas, mask, cw, ch, f"{LABEL} {W}x{H}"), src, ox, oy)
|
||||
|
||||
if model == "flux-fill" and method.get("clean_corners", True):
|
||||
corners = corner_mask(cw, ch, ox, oy, src.width, src.height)
|
||||
if corners is not None:
|
||||
print(f"[SF {LABEL}] Repainting the new bottom corners of {W}x{H} with Bria GenFill.")
|
||||
redone = await masked_fill("bria-genfill", result, corners, cw, ch, f"{LABEL} corners {W}x{H}")
|
||||
soft = corners.filter(ImageFilter.GaussianBlur(max(2, round(max(cw, ch) / 200))))
|
||||
result = paste_back(Image.composite(redone, result, soft), src, ox, oy)
|
||||
notes[key] = "bottom corners repainted with bria-genfill"
|
||||
return key, result
|
||||
|
||||
async def expand(src, W, H):
|
||||
ratio = pick_expand_ratio(src.width, src.height, W, H, EXPAND_RATIOS)
|
||||
[url] = await upload_images(api, client, [src])
|
||||
payload = {"image": url, "aspect_ratio": ratio, "seed": seed, "prompt": text,
|
||||
"negative_prompt": method.get("negative_prompt") or DEFAULT_NEGATIVE}
|
||||
print(f"[SF {LABEL}] bria-expand: {src.width}x{src.height} at {ratio} for {W}x{H}.")
|
||||
endpoint, price = METHODS["bria-expand"]
|
||||
_, result = await submit_and_wait(api, client, endpoint, payload, timeout_seconds, f"{LABEL} {W}x{H}")
|
||||
spent.append(price)
|
||||
[out] = await fetch_images(result.get("outputs") or [])
|
||||
out = out.convert("RGB")
|
||||
found = locate_original(out, src)
|
||||
if found is None:
|
||||
raise RuntimeError(
|
||||
f"Bria Expand returned a {out.width}x{out.height} image, but the original could not be found "
|
||||
"in it, so it can't be pasted back safely. Try another seed, or flux-fill/bria-genfill.")
|
||||
scale, x, y = found
|
||||
# Bring the result to the original's own scale, paste the original back, then cut only new margin.
|
||||
out = out.resize((round(out.width / scale), round(out.height / scale)), Image.LANCZOS)
|
||||
ox, oy = min(round(x / scale), out.width - src.width), min(round(y / scale), out.height - src.height)
|
||||
out = paste_back(out, src, ox, oy)
|
||||
return crop_around_original(out, (ox, oy, ox + src.width, oy + src.height), W, H)
|
||||
|
||||
bases = dict(await asyncio.gather(*(fill(k) for k in groups)))
|
||||
|
||||
results, report = [], []
|
||||
for (W, H), action in zip(sizes, actions):
|
||||
if action == "resize":
|
||||
results.append(to_tensor(exact(original, W, H)))
|
||||
report.append({"size": f"{W}x{H}", "mode": "resized (same shape as the image), free"})
|
||||
elif action == "crop":
|
||||
results.append(to_tensor(exact(original, W, H)))
|
||||
report.append({"size": f"{W}x{H}", "mode": "scaled and centre-cropped, free",
|
||||
"cropped_away": f"{crop_fraction(sw, sh, W, H) * 100:.1f}%"})
|
||||
else:
|
||||
key = shape_key(W, H)
|
||||
results.append(to_tensor(exact(bases[key], W, H)))
|
||||
entry = {"size": f"{W}x{H}", "mode": f"outpainted with {made_with[key]}",
|
||||
"generated_at": "x".join(map(str, largest[key]))}
|
||||
if made_with[key] != chosen:
|
||||
entry["note"] = f"{chosen} can't make this shape, so {made_with[key]} was used"
|
||||
if key in notes:
|
||||
entry["note"] = notes[key]
|
||||
report.append(entry)
|
||||
|
||||
if cut is not None:
|
||||
for i, (W, H) in enumerate(sizes):
|
||||
results[i] = with_alpha(results[i], fit_edge_mask(cut, mask_side, W, H))
|
||||
report[i]["edge_mask"] = f"{mask_side} edge cut out (transparent)"
|
||||
|
||||
mask_out = to_tensor(next(iter(masks.values())).convert("RGB")) if masks else None
|
||||
cost = round(sum(spent), 3)
|
||||
print(f"[SF {LABEL}] Done: {len(results)} image(s), {len(spent)} paid call(s), ~${cost:.3f}.")
|
||||
plan = json.dumps({"source": f"{sw}x{sh}", "method": chosen, "paid_calls": len(spent),
|
||||
"estimated_cost_usd": cost, "outputs": report}, indent=2)
|
||||
return IO.NodeOutput(plan, mask_out, *results, *([None] * (MAX_OUTPUTS - len(results))))
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"SFWaveSpeedOutpaintResizer": SFWaveSpeedOutpaintResizer}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"SFWaveSpeedOutpaintResizer": "SF WaveSpeed Outpaint Resizer"}
|
||||
@@ -0,0 +1,295 @@
|
||||
# ABOUTME: Geometry for keep-the-original outpainting: canvas, mask, paste-back, Bria Expand placement
|
||||
# ABOUTME: Pure Pillow + numpy, ported from the Art Pipeline Hub resolution-variant-generator (tested there)
|
||||
|
||||
"""Geometry for "keep the original" reframing. Pure Pillow and numpy.
|
||||
|
||||
Nothing here talks to a network or imports the runner library, on purpose:
|
||||
every function takes pictures and numbers and returns pictures and numbers, so
|
||||
the tests can check the part that decides whether the artist's original
|
||||
survives (where it sits, what the mask covers, what the paste-back may touch)
|
||||
without any model in the loop.
|
||||
|
||||
THE IDEA. A whole-image edit model (GPT Image, Nano Banana) redraws the
|
||||
picture and moves the subject. A fill model paints only what it is told to
|
||||
paint. So:
|
||||
|
||||
1. build a canvas at the TARGET shape that holds the source at its own pixels
|
||||
(geometry, canvas_and_mask);
|
||||
2. mask only the new strips, reaching a little into the original so the model
|
||||
blends the seam (OVERLAP);
|
||||
3. after the model answers, paste the original back over the result with a
|
||||
soft edge on the grown sides only (paste_back);
|
||||
4. scale the whole canvas to the target together, so old and new areas share
|
||||
one level of detail.
|
||||
|
||||
Ported from the 2026-11-09 bake-off prototype; the prototype's detail-matching
|
||||
blur is gone because the winning results did not need it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageFilter
|
||||
|
||||
# Same-shape tolerance: a target within 0.5% of the source's aspect ratio is a
|
||||
# plain resize, not a generation.
|
||||
SAME_SHAPE_TOLERANCE = 0.005
|
||||
|
||||
MODEL_MIN_EDGE = 512
|
||||
MODEL_MAX_EDGE = 1536
|
||||
MODEL_SHORT_MIN = 256 # flux-fill-dev refuses anything under 256 on a side
|
||||
MODEL_STEP = 16
|
||||
|
||||
|
||||
def same_shape(sw: int, sh: int, tw: int, th: int, tolerance: float = SAME_SHAPE_TOLERANCE) -> bool:
|
||||
return abs(math.log(tw / th) - math.log(sw / sh)) <= math.log(1 + tolerance)
|
||||
|
||||
|
||||
def overlap_feather(canvas_longest: int) -> tuple[int, int]:
|
||||
"""OVERLAP 24 / FEATHER 16 at ~800 px, scaled linearly, clamped to 8..64."""
|
||||
scale = canvas_longest / 800
|
||||
return (
|
||||
int(min(64, max(8, round(24 * scale)))),
|
||||
int(min(64, max(8, round(16 * scale)))),
|
||||
)
|
||||
|
||||
|
||||
def geometry(sw: int, sh: int, tw: int, th: int) -> tuple[int, int, int, int]:
|
||||
"""Smallest canvas at the target's shape that holds the source at its own
|
||||
pixels. Returns (canvas_w, canvas_h, offset_x, offset_y), the source
|
||||
centred on the axis that grew."""
|
||||
if tw / th >= sw / sh:
|
||||
cw, ch = round(sh * tw / th), sh
|
||||
else:
|
||||
cw, ch = sw, round(sw * th / tw)
|
||||
cw, ch = max(cw, sw), max(ch, sh)
|
||||
return cw, ch, (cw - sw) // 2, (ch - sh) // 2
|
||||
|
||||
|
||||
def fit_source_to_target(src: Image.Image, largest_tw: int, largest_th: int) -> Image.Image:
|
||||
"""The downscale rule: if the canvas would be bigger than the largest
|
||||
target in the group needs, scale the source down first so the model works
|
||||
at the size that will be delivered. Never scales UP — the original's own
|
||||
pixels are the point."""
|
||||
cw, ch, _, _ = geometry(src.width, src.height, largest_tw, largest_th)
|
||||
scale = min(1.0, largest_tw / cw, largest_th / ch)
|
||||
if scale >= 1.0:
|
||||
return src
|
||||
size = (max(1, round(src.width * scale)), max(1, round(src.height * scale)))
|
||||
return src.resize(size, Image.LANCZOS)
|
||||
|
||||
|
||||
def _grown(cw: int, ch: int, ox: int, oy: int, sw: int, sh: int) -> tuple[bool, bool, bool, bool]:
|
||||
"""Which sides of the source have new canvas beside them: left, top, right, bottom."""
|
||||
return ox > 0, oy > 0, cw - ox - sw > 0, ch - oy - sh > 0
|
||||
|
||||
|
||||
def canvas_and_mask(
|
||||
src: Image.Image, cw: int, ch: int, ox: int, oy: int
|
||||
) -> tuple[Image.Image, Image.Image]:
|
||||
"""The canvas the model is given and the mask over it (255 = paint here).
|
||||
|
||||
The new area is prefilled with the source stretched and blurred so the
|
||||
model starts from the right colours. The mask is 255 over everything new
|
||||
and 0 over the original, EXCEPT an OVERLAP band reaching into the original
|
||||
on the sides that grew — a side that did not grow keeps its 0 right to the
|
||||
edge."""
|
||||
sw, sh = src.size
|
||||
overlap, _ = overlap_feather(max(cw, ch))
|
||||
overlap = max(1, min(overlap, sw // 4, sh // 4))
|
||||
blur = min(80, max(8, round(40 * max(cw, ch) / 800)))
|
||||
canvas = src.resize((cw, ch), Image.BICUBIC).filter(ImageFilter.GaussianBlur(blur))
|
||||
canvas.paste(src, (ox, oy))
|
||||
|
||||
left, top, right, bottom = _grown(cw, ch, ox, oy, sw, sh)
|
||||
mask = Image.new("L", (cw, ch), 255)
|
||||
inner = (
|
||||
ox + (overlap if left else 0),
|
||||
oy + (overlap if top else 0),
|
||||
ox + sw - (overlap if right else 0),
|
||||
oy + sh - (overlap if bottom else 0),
|
||||
)
|
||||
mask.paste(0, inner)
|
||||
return canvas, mask
|
||||
|
||||
|
||||
def model_size(cw: int, ch: int) -> tuple[int, int]:
|
||||
"""Both sides multiples of 16; longest edge clamp(canvas longest, 512,
|
||||
1536), raised only as far as needed to keep the short side at 256+ (a
|
||||
very wide canvas would otherwise fall under the model's minimum)."""
|
||||
longest = min(MODEL_MAX_EDGE, max(MODEL_MIN_EDGE, max(cw, ch)))
|
||||
shortest = min(cw, ch)
|
||||
longest = min(MODEL_MAX_EDGE, max(longest, math.ceil(MODEL_SHORT_MIN * max(cw, ch) / shortest)))
|
||||
scale = longest / max(cw, ch)
|
||||
step = MODEL_STEP
|
||||
return (
|
||||
max(MODEL_SHORT_MIN, round(cw * scale / step) * step),
|
||||
max(MODEL_SHORT_MIN, round(ch * scale / step) * step),
|
||||
)
|
||||
|
||||
|
||||
def paste_back(gen: Image.Image, src: Image.Image, ox: int, oy: int) -> Image.Image:
|
||||
"""The original's own pixels win everywhere except a soft band at the seam
|
||||
on the sides that grew. On a side that did not grow the original is kept
|
||||
to the very edge."""
|
||||
sw, sh = src.size
|
||||
_, feather = overlap_feather(max(gen.width, gen.height))
|
||||
feather = max(1, min(feather, sw // 4, sh // 4))
|
||||
left, top, right, bottom = _grown(gen.width, gen.height, ox, oy, sw, sh)
|
||||
|
||||
# Built on a padded sheet and cropped, so the blur cannot fade the edges
|
||||
# of a side that did not grow: there the 255 runs on into the padding.
|
||||
pad = feather * 4
|
||||
sheet = Image.new("L", (sw + 2 * pad, sh + 2 * pad), 0)
|
||||
sheet.paste(255, (
|
||||
pad + (feather if left else -pad),
|
||||
pad + (feather if top else -pad),
|
||||
pad + sw - (feather if right else -pad),
|
||||
pad + sh - (feather if bottom else -pad),
|
||||
))
|
||||
alpha = sheet.filter(ImageFilter.GaussianBlur(feather / 2)).crop((pad, pad, pad + sw, pad + sh))
|
||||
out = gen.copy()
|
||||
out.paste(src, (ox, oy), alpha)
|
||||
return out
|
||||
|
||||
|
||||
def new_boxes(cw: int, ch: int, ox: int, oy: int, sw: int, sh: int) -> list[tuple[int, int, int, int]]:
|
||||
"""The strips of new canvas, one box per side that grew."""
|
||||
left, top, right, bottom = _grown(cw, ch, ox, oy, sw, sh)
|
||||
boxes = []
|
||||
if left:
|
||||
boxes.append((0, 0, ox, ch))
|
||||
if right:
|
||||
boxes.append((ox + sw, 0, cw, ch))
|
||||
if top:
|
||||
boxes.append((0, 0, cw, oy))
|
||||
if bottom:
|
||||
boxes.append((0, oy + sh, cw, ch))
|
||||
return boxes
|
||||
|
||||
|
||||
# ---- Bria Expand: it takes only a ratio from a short list ----
|
||||
|
||||
def _ratio(label: str) -> float:
|
||||
left, _, right = str(label).partition(":")
|
||||
return float(left) / float(right)
|
||||
|
||||
|
||||
def pick_expand_ratio(
|
||||
sw: int, sh: int, tw: int, th: int, enum_labels: list[str] | tuple[str, ...]
|
||||
) -> str | None:
|
||||
"""The enum ratio whose expanded canvas still contains the target's
|
||||
minimal canvas, nearest to the target. None when no ratio can.
|
||||
|
||||
The expanded canvas holds the source at its own scale, centred. A wide
|
||||
target (wider than the source) is contained by a canvas at least as wide;
|
||||
a narrow one by a canvas at least as narrow; a canvas on the other side of
|
||||
the source's own shape never contains it. So a 16:9 source cannot be made
|
||||
wider than 16:9 here."""
|
||||
target = tw / th
|
||||
source = sw / sh
|
||||
slack = 1 + SAME_SHAPE_TOLERANCE
|
||||
best: tuple[float, str] | None = None
|
||||
for label in enum_labels:
|
||||
r = _ratio(label)
|
||||
if target >= source:
|
||||
ok = r >= target / slack and r >= source / slack
|
||||
distance = r - target
|
||||
else:
|
||||
ok = r <= target * slack and r <= source * slack
|
||||
distance = target - r
|
||||
if ok and (best is None or abs(distance) < best[0]):
|
||||
best = (abs(distance), label)
|
||||
return best[1] if best else None
|
||||
|
||||
|
||||
def locate_original(
|
||||
out: Image.Image,
|
||||
src: Image.Image,
|
||||
max_mean_diff: float = 20.0,
|
||||
) -> tuple[float, int, int] | None:
|
||||
"""Where did the model put the original? Returns (scale, x, y) with the
|
||||
source scaled by `scale` landing at (x, y) in `out`, or None when nothing
|
||||
in `out` looks like the source — a refusal beats a guess, because a guess
|
||||
would paste the original over the wrong place.
|
||||
|
||||
The scale starts from the axis that filled (the one that did not grow) and
|
||||
is tried at 0.97-1.03 of that. A coarse search on a small grayscale copy
|
||||
finds the neighbourhood; a finer one at full resolution settles the pixel."""
|
||||
sw, sh = src.size
|
||||
base = min(out.width / sw, out.height / sh)
|
||||
candidates = [round(base * f, 4) for f in np.linspace(0.97, 1.03, 7)]
|
||||
|
||||
out_gray = np.asarray(out.convert("L"), dtype=np.float32)
|
||||
src_img = src.convert("L")
|
||||
|
||||
coarse_f = min(1.0, 128 / max(out.size))
|
||||
out_small = np.asarray(out.convert("L").resize(
|
||||
(max(1, round(out.width * coarse_f)), max(1, round(out.height * coarse_f))), Image.BILINEAR
|
||||
), dtype=np.float32)
|
||||
|
||||
per_scale: list[tuple[float, float, int, int]] = [] # coarse diff, scale, x, y (full res)
|
||||
for scale in candidates:
|
||||
w, h = round(sw * scale), round(sh * scale)
|
||||
if w < 8 or h < 8 or w > out.width or h > out.height:
|
||||
continue
|
||||
sw_s, sh_s = max(1, round(w * coarse_f)), max(1, round(h * coarse_f))
|
||||
if sw_s > out_small.shape[1] or sh_s > out_small.shape[0]:
|
||||
continue
|
||||
template = np.asarray(src_img.resize((sw_s, sh_s), Image.BILINEAR), dtype=np.float32)
|
||||
here: tuple[float, int, int] | None = None
|
||||
for y in range(out_small.shape[0] - sh_s + 1):
|
||||
for x in range(out_small.shape[1] - sw_s + 1):
|
||||
diff = float(np.abs(out_small[y:y + sh_s, x:x + sw_s] - template).mean())
|
||||
if here is None or diff < here[0]:
|
||||
here = (diff, x, y)
|
||||
if here is not None:
|
||||
per_scale.append((here[0], float(scale), round(here[1] / coarse_f), round(here[2] / coarse_f)))
|
||||
if not per_scale:
|
||||
return None
|
||||
|
||||
# The coarse pass cannot tell 0.99 from 1.00 on smooth pictures, so the
|
||||
# few best scales are all settled at full resolution and the best of those wins.
|
||||
reach = int(math.ceil(1 / coarse_f)) + 4
|
||||
best: tuple[float, float, int, int] | None = None # diff, scale, x, y
|
||||
for _, scale, cx, cy in sorted(per_scale)[:3]:
|
||||
w, h = round(sw * scale), round(sh * scale)
|
||||
template = np.asarray(src_img.resize((w, h), Image.LANCZOS), dtype=np.float32)[::4, ::4]
|
||||
for y in range(max(0, cy - reach), min(out.height - h, cy + reach) + 1):
|
||||
for x in range(max(0, cx - reach), min(out.width - w, cx + reach) + 1):
|
||||
window = out_gray[y:y + h:4, x:x + w:4][:template.shape[0], :template.shape[1]]
|
||||
diff = float(np.abs(window - template).mean())
|
||||
if best is None or diff < best[0]:
|
||||
best = (diff, scale, x, y)
|
||||
if best is None or best[0] > max_mean_diff:
|
||||
return None
|
||||
return best[1], best[2], best[3]
|
||||
|
||||
|
||||
def crop_box_around_original(
|
||||
image_size: tuple[int, int], box: tuple[int, int, int, int], tw: int, th: int
|
||||
) -> tuple[int, int, int, int]:
|
||||
"""The smallest window at the target's ratio that contains `box`, centred
|
||||
on it and slid (never shrunk) to stay inside the image. Only generated
|
||||
margin is ever cut away."""
|
||||
iw, ih = image_size
|
||||
x0, y0, x1, y1 = box
|
||||
bw, bh = x1 - x0, y1 - y0
|
||||
target = tw / th
|
||||
if target >= bw / bh:
|
||||
w, h = math.ceil(bh * target), bh
|
||||
else:
|
||||
w, h = bw, math.ceil(bw / target)
|
||||
w, h = min(w, iw), min(h, ih)
|
||||
cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
|
||||
left = int(round(min(max(cx - w / 2, 0), iw - w)))
|
||||
top = int(round(min(max(cy - h / 2, 0), ih - h)))
|
||||
return left, top, left + w, top + h
|
||||
|
||||
|
||||
def crop_around_original(
|
||||
img: Image.Image, box: tuple[int, int, int, int], tw: int, th: int
|
||||
) -> Image.Image:
|
||||
return img.crop(crop_box_around_original(img.size, box, tw, th))
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-sf-nodes"
|
||||
description = "Custom nodes for ComfyUI including LLM Chat with support for Claude, Gemini, and OpenAI models, WaveSpeed AI image/video generation, VertexAI integration, and workflow utilities"
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = [
|
||||
"requests>=2.31.0",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* SF Banner Resizer — shows one image output per line of the "resolutions" box,
|
||||
* each labelled with its size, so they can be wired straight to Save Image nodes.
|
||||
*
|
||||
* The Python node always declares the plan output plus image_1..image_8; this
|
||||
* trims the unused trailing ones from view (never one that is connected) and
|
||||
* puts them back as lines are added.
|
||||
*/
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import { parseResolutions } from "./sf_wavespeed_pricing.js";
|
||||
|
||||
const NODE_NAME = "SFBannerResizer";
|
||||
const MAX_OUTPUTS = 8;
|
||||
const FIRST_IMAGE = 1; // output 0 is "plan"
|
||||
|
||||
function syncOutputs(node) {
|
||||
const widget = node.widgets?.find((w) => w.name === "resolutions");
|
||||
if (!widget || !node.outputs) return;
|
||||
const sizes = parseResolutions(widget.value).slice(0, MAX_OUTPUTS);
|
||||
const want = FIRST_IMAGE + Math.max(1, sizes.length);
|
||||
|
||||
let changed = false;
|
||||
while (node.outputs.length > want) {
|
||||
const last = node.outputs[node.outputs.length - 1];
|
||||
if (last.links?.length) break;
|
||||
node.removeOutput(node.outputs.length - 1);
|
||||
changed = true;
|
||||
}
|
||||
while (node.outputs.length < want) {
|
||||
node.addOutput(`image_${node.outputs.length}`, "IMAGE");
|
||||
changed = true;
|
||||
}
|
||||
for (let i = FIRST_IMAGE; i < node.outputs.length; i++) {
|
||||
const size = sizes[i - FIRST_IMAGE];
|
||||
const label = size ? `${size[0]}x${size[1]}` : `image_${i} (unused)`;
|
||||
if (node.outputs[i].label !== label) {
|
||||
node.outputs[i].label = label;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
node.setSize?.([node.size[0], Math.max(node.size[1], node.computeSize()[1])]);
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Stillfront.BannerResizer",
|
||||
setup() {
|
||||
// Polled: multiline text widgets don't reliably report each edit.
|
||||
setInterval(() => {
|
||||
for (const node of app.graph?.nodes ?? []) {
|
||||
if (node.type === NODE_NAME) syncOutputs(node);
|
||||
}
|
||||
}, 400);
|
||||
},
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== NODE_NAME) return;
|
||||
const onConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function () {
|
||||
const result = onConfigure?.apply(this, arguments);
|
||||
syncOutputs(this);
|
||||
return result;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* SF WaveSpeed Outpaint Resizer — a list editor for the output sizes, and one image output per size.
|
||||
*
|
||||
* Each row is a width, a height and a mode (auto / crop / outpaint). The rows are stored as JSON in the
|
||||
* node's hidden "resolutions" widget, which is what Python reads; older workflows that still hold plain
|
||||
* "WIDTHxHEIGHT" lines are turned into rows when the node loads.
|
||||
*
|
||||
* The Python node always declares the plan and mask_preview outputs plus image_1..image_8; this trims the
|
||||
* unused trailing ones from view (never one that is connected), puts them back as rows are added, and
|
||||
* labels each with its size.
|
||||
*/
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
const NODE_NAME = "SFWaveSpeedOutpaintResizer";
|
||||
const MAX_OUTPUTS = 8;
|
||||
const FIRST_IMAGE = 2; // output 0 is "plan", 1 is "mask_preview"
|
||||
const MODES = ["auto", "crop", "outpaint"];
|
||||
|
||||
/** [{w, h, mode}] from the stored JSON, or from legacy "WIDTHxHEIGHT [mode]" lines. */
|
||||
export function parseRows(value) {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (raw.startsWith("[")) {
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
.map((r) => ({ w: Number(r.w) || 0, h: Number(r.h) || 0, mode: MODES.includes(r.mode) ? r.mode : "auto" }))
|
||||
.slice(0, MAX_OUTPUTS);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return raw
|
||||
.split(/[,\n;]+/)
|
||||
.map((t) => t.trim().match(/^(\d+)\s*[xX×*]\s*(\d+)(?:\s+(\w+))?$/))
|
||||
.filter(Boolean)
|
||||
.map((m) => ({ w: Number(m[1]), h: Number(m[2]), mode: MODES.includes(m[3]) ? m[3] : "auto" }))
|
||||
.slice(0, MAX_OUTPUTS);
|
||||
}
|
||||
|
||||
function sizesWidget(node) {
|
||||
return node.widgets?.find((w) => w.name === "resolutions");
|
||||
}
|
||||
|
||||
function store(node, rows) {
|
||||
const widget = sizesWidget(node);
|
||||
if (!widget) return;
|
||||
widget.value = JSON.stringify(rows);
|
||||
widget.callback?.(widget.value);
|
||||
syncOutputs(node);
|
||||
}
|
||||
|
||||
function syncOutputs(node) {
|
||||
const widget = sizesWidget(node);
|
||||
if (!widget || !node.outputs) return;
|
||||
const rows = parseRows(widget.value);
|
||||
const want = FIRST_IMAGE + Math.max(1, rows.length);
|
||||
|
||||
let changed = false;
|
||||
while (node.outputs.length > want) {
|
||||
const last = node.outputs[node.outputs.length - 1];
|
||||
if (last.links?.length) break;
|
||||
node.removeOutput(node.outputs.length - 1);
|
||||
changed = true;
|
||||
}
|
||||
while (node.outputs.length < want) {
|
||||
node.addOutput(`image_${node.outputs.length}`, "IMAGE");
|
||||
changed = true;
|
||||
}
|
||||
for (let i = FIRST_IMAGE; i < node.outputs.length; i++) {
|
||||
const row = rows[i - FIRST_IMAGE];
|
||||
const label = row ? `${row.w}x${row.h}` : `image_${i} (unused)`;
|
||||
if (node.outputs[i].label !== label) {
|
||||
node.outputs[i].label = label;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
node.setSize?.([node.size[0], Math.max(node.size[1], node.computeSize()[1])]);
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
}
|
||||
}
|
||||
|
||||
const CSS = `
|
||||
.sf-res-list { display: flex; flex-direction: column; gap: 4px; padding: 4px 2px; font-size: 12px;
|
||||
color: var(--input-text, #ddd); box-sizing: border-box; width: 100%; }
|
||||
.sf-res-title { opacity: .7; font-size: 11px; padding: 0 2px; }
|
||||
.sf-res-row { display: flex; align-items: center; gap: 4px; }
|
||||
.sf-res-row input, .sf-res-row select { background: var(--comfy-input-bg, #222); color: inherit;
|
||||
border: 1px solid var(--border-color, #444); border-radius: 4px; padding: 2px 4px; font-size: 12px; min-width: 0; }
|
||||
.sf-res-row input { width: 64px; }
|
||||
.sf-res-row select { flex: 1; }
|
||||
.sf-res-row button, .sf-res-add { background: none; color: inherit; border: 1px solid var(--border-color, #444);
|
||||
border-radius: 4px; cursor: pointer; font-size: 12px; padding: 2px 6px; }
|
||||
.sf-res-add { align-self: flex-start; margin-top: 2px; }
|
||||
.sf-res-add:disabled { opacity: .4; cursor: default; }
|
||||
`;
|
||||
|
||||
function ensureStyle() {
|
||||
if (document.getElementById("sf-res-list-style")) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = "sf-res-list-style";
|
||||
style.textContent = CSS;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function buildEditor(node) {
|
||||
ensureStyle();
|
||||
const root = document.createElement("div");
|
||||
root.className = "sf-res-list";
|
||||
// Keep typing and clicks inside the editor from reaching the canvas.
|
||||
for (const type of ["pointerdown", "mousedown", "wheel", "keydown"]) {
|
||||
root.addEventListener(type, (e) => e.stopPropagation());
|
||||
}
|
||||
|
||||
function render() {
|
||||
const rows = parseRows(sizesWidget(node)?.value);
|
||||
root.replaceChildren();
|
||||
const title = document.createElement("div");
|
||||
title.className = "sf-res-title";
|
||||
title.textContent = "Output sizes";
|
||||
root.appendChild(title);
|
||||
|
||||
rows.forEach((row, i) => {
|
||||
const line = document.createElement("div");
|
||||
line.className = "sf-res-row";
|
||||
const num = (key) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.min = "16";
|
||||
input.max = "8192";
|
||||
input.value = row[key];
|
||||
input.title = key === "w" ? "Width" : "Height";
|
||||
input.addEventListener("change", () => {
|
||||
const next = parseRows(sizesWidget(node)?.value);
|
||||
next[i][key] = Math.max(16, Math.min(8192, Math.round(Number(input.value) || 16)));
|
||||
input.value = next[i][key];
|
||||
store(node, next);
|
||||
});
|
||||
return input;
|
||||
};
|
||||
const times = document.createElement("span");
|
||||
times.textContent = "×";
|
||||
const mode = document.createElement("select");
|
||||
mode.title = "auto: crop if that cuts away no more than auto_crop_limit, otherwise outpaint";
|
||||
for (const m of MODES) {
|
||||
const option = document.createElement("option");
|
||||
option.value = m;
|
||||
option.textContent = m;
|
||||
mode.appendChild(option);
|
||||
}
|
||||
mode.value = row.mode;
|
||||
mode.addEventListener("change", () => {
|
||||
const next = parseRows(sizesWidget(node)?.value);
|
||||
next[i].mode = mode.value;
|
||||
store(node, next);
|
||||
});
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.textContent = "✕";
|
||||
remove.title = "Remove this size";
|
||||
remove.addEventListener("click", () => {
|
||||
const next = parseRows(sizesWidget(node)?.value);
|
||||
next.splice(i, 1);
|
||||
store(node, next);
|
||||
render();
|
||||
});
|
||||
line.append(num("w"), times, num("h"), mode, remove);
|
||||
root.appendChild(line);
|
||||
});
|
||||
|
||||
const add = document.createElement("button");
|
||||
add.type = "button";
|
||||
add.className = "sf-res-add";
|
||||
add.textContent = "+ Add resolution";
|
||||
add.disabled = rows.length >= MAX_OUTPUTS;
|
||||
add.addEventListener("click", () => {
|
||||
const next = parseRows(sizesWidget(node)?.value);
|
||||
const last = next[next.length - 1] ?? { w: 1920, h: 1080, mode: "auto" };
|
||||
next.push({ ...last });
|
||||
store(node, next);
|
||||
render();
|
||||
});
|
||||
root.appendChild(add);
|
||||
}
|
||||
|
||||
return { root, render };
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Stillfront.OutpaintResizer",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== NODE_NAME) return;
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const result = onNodeCreated?.apply(this, arguments);
|
||||
const widget = sizesWidget(this);
|
||||
if (widget) {
|
||||
// The JSON stays in this widget (it is what gets saved and sent); the list replaces it on screen.
|
||||
widget.hidden = true;
|
||||
widget.options = { ...(widget.options ?? {}), hidden: true };
|
||||
widget.computeSize = () => [0, -4];
|
||||
const editor = buildEditor(this);
|
||||
this.sfResolutionEditor = editor;
|
||||
const dom = this.addDOMWidget("resolution_list", "sf_resolution_list", editor.root, {
|
||||
serialize: false,
|
||||
hideOnZoom: false,
|
||||
getMinHeight: () => 40 + 26 * (parseRows(widget.value).length + 1),
|
||||
});
|
||||
dom.serialize = false;
|
||||
// Show the list where the text box was.
|
||||
const all = this.widgets;
|
||||
all.splice(all.indexOf(dom), 1);
|
||||
all.splice(all.indexOf(widget) + 1, 0, dom);
|
||||
editor.render();
|
||||
syncOutputs(this);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const onConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function () {
|
||||
const result = onConfigure?.apply(this, arguments);
|
||||
const widget = sizesWidget(this);
|
||||
// Older workflows hold plain text: turn it into rows once.
|
||||
if (widget && !String(widget.value ?? "").trim().startsWith("[")) {
|
||||
widget.value = JSON.stringify(parseRows(widget.value));
|
||||
}
|
||||
this.sfResolutionEditor?.render();
|
||||
syncOutputs(this);
|
||||
return result;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
import {
|
||||
bannerResizerPrice,
|
||||
outpaintResizerPrice,
|
||||
formatUsd,
|
||||
gptImagePrice,
|
||||
meshyPrice,
|
||||
@@ -83,6 +85,25 @@ const PRICERS = {
|
||||
}),
|
||||
count: 1,
|
||||
}),
|
||||
SFBannerResizer: (node) => ({
|
||||
usd: bannerResizerPrice({
|
||||
resolutions: widgetValue(node, "resolutions"),
|
||||
model: widgetValue(node, "gpt_model"),
|
||||
quality: widgetValue(node, "quality"),
|
||||
generation_resolution: widgetValue(node, "generation_resolution"),
|
||||
}),
|
||||
count: 1,
|
||||
upTo: true,
|
||||
}),
|
||||
SFWaveSpeedOutpaintResizer: (node) => ({
|
||||
usd: outpaintResizerPrice({
|
||||
resolutions: widgetValue(node, "resolutions"),
|
||||
method: widgetValue(node, "method"),
|
||||
clean_corners: widgetValue(node, "clean_corners"),
|
||||
}),
|
||||
count: 1,
|
||||
upTo: true,
|
||||
}),
|
||||
SFWaveSpeedTripoP2ImageTo3D: (node) => ({
|
||||
usd: tripoP2Price({ texture: widgetValue(node, "texture") }),
|
||||
count: 1,
|
||||
@@ -98,9 +119,9 @@ const PRICERS = {
|
||||
};
|
||||
|
||||
function badgeText(node, pricer) {
|
||||
const { usd, count } = pricer(node);
|
||||
const { usd, count, upTo } = pricer(node);
|
||||
if (usd == null) return "";
|
||||
const total = `~${formatUsd(usd * count)}/run`;
|
||||
const total = `${upTo ? "up to " : "~"}${formatUsd(usd * count)}/run`;
|
||||
return count > 1 ? `${total} (${count} × ${formatUsd(usd)})` : total;
|
||||
}
|
||||
|
||||
|
||||
@@ -129,3 +129,74 @@ export function meshyPrice({ should_texture, model_type, geometry_resolution, en
|
||||
if (enable_rigging) usd += 0.24 + (enable_animation ? 0.144 : 0);
|
||||
return usd;
|
||||
}
|
||||
|
||||
/** "1920x1080\n800x450" -> [[1920, 1080], [800, 450]]; unreadable lines are skipped. */
|
||||
export function parseResolutions(text) {
|
||||
return String(text ?? "")
|
||||
.split(/[,\n;]+/)
|
||||
.map((t) => t.trim().match(/^(\d+)\s*[xX×*]\s*(\d+)$/))
|
||||
.filter(Boolean)
|
||||
.map((m) => [Number(m[1]), Number(m[2])]);
|
||||
}
|
||||
|
||||
const GPT_RATIOS = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9",
|
||||
"1:2", "2:1", "1:3", "3:1", "9:21", "21:9"];
|
||||
|
||||
function nearestRatio(w, h) {
|
||||
const wanted = Math.log(w / h);
|
||||
const dist = (r) => { const [a, b] = r.split(":").map(Number); return Math.abs(Math.log(a / b) - wanted); };
|
||||
return GPT_RATIOS.reduce((best, r) => (dist(r) < dist(best) ? r : best));
|
||||
}
|
||||
|
||||
/** Worst case for SF Banner Resizer: one GPT Image 2.5 edit per distinct shape, sized for the
|
||||
* largest resolution of that shape. Sizes that only need a crop cost nothing, but that depends
|
||||
* on the input image, which the badge can't see. */
|
||||
export function bannerResizerPrice({ resolutions, model, quality, generation_resolution }) {
|
||||
const largest = new Map();
|
||||
for (const [w, h] of parseResolutions(resolutions).slice(0, 8)) {
|
||||
const ratio = nearestRatio(w, h);
|
||||
largest.set(ratio, Math.max(largest.get(ratio) ?? 0, w, h));
|
||||
}
|
||||
let total = 0;
|
||||
for (const side of largest.values()) {
|
||||
const tier = generation_resolution && generation_resolution !== "auto"
|
||||
? generation_resolution
|
||||
: side <= 1100 ? "1k" : side <= 2200 ? "2k" : "4k";
|
||||
const usd = gptImagePrice({ model, quality, resolution: tier, references: 1 });
|
||||
if (usd == null) return undefined;
|
||||
total += usd;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// SF WaveSpeed Outpaint Resizer, per generation. WaveSpeed model pages, read 2026-11-09:
|
||||
// wavespeed-ai/flux-fill-dev $0.035, bria/fibo-edit/genfill $0.04, bria/expand $0.04.
|
||||
const OUTPAINT_PRICES = { "flux-fill": 0.035, "bria-genfill": 0.04, "bria-expand": 0.04 };
|
||||
|
||||
/** Rows of the Outpaint Resizer's size list: its JSON, or older "WIDTHxHEIGHT [mode]" lines. */
|
||||
function outpaintRows(value) {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (raw.startsWith("[")) {
|
||||
try {
|
||||
return JSON.parse(raw).map((r) => ({ w: Number(r.w) || 0, h: Number(r.h) || 0, mode: r.mode || "auto" }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return raw.split(/[,\n;]+/).map((t) => t.trim().match(/^(\d+)\s*[xX×*]\s*(\d+)(?:\s+(\w+))?$/))
|
||||
.filter(Boolean).map((m) => ({ w: Number(m[1]), h: Number(m[2]), mode: m[3] || "auto" }));
|
||||
}
|
||||
|
||||
/** Worst case for SF WaveSpeed Outpaint Resizer: one generation per distinct shape (shapes within
|
||||
* ~0.5% share one, as in the Python node). Sizes set to "crop" are free. Sizes on "auto", or with the
|
||||
* input image's own shape, may also turn out free, but the badge can't see the image, so this is an
|
||||
* upper bound. */
|
||||
export function outpaintResizerPrice({ resolutions, method, clean_corners }) {
|
||||
// FLUX's optional corner clean-up is one Bria GenFill call per shape on top.
|
||||
const corners = method === "flux-fill" && clean_corners !== false ? OUTPAINT_PRICES["bria-genfill"] : 0;
|
||||
const each = OUTPAINT_PRICES[method] == null ? undefined : OUTPAINT_PRICES[method] + corners;
|
||||
if (each == null) return undefined;
|
||||
const shapes = new Set(outpaintRows(resolutions).slice(0, 8).filter((r) => r.mode !== "crop" && r.w && r.h)
|
||||
.map((r) => Math.round(Math.log(r.w / r.h) * 100) / 100));
|
||||
return shapes.size * each;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user