diff --git a/README.md b/README.md index 9b04926..da54b06 100644 --- a/README.md +++ b/README.md @@ -50,13 +50,15 @@ That's it — you're ready to use the tools. | Erase | Remove objects with inpainting | | Expand | Extend image canvas with AI generation | | FIBO Edit | Prompt-driven image editing | -| FIBO Edit Recipes | FIBO Edit with 99 curated preset prompts across 11 categories | -| FIBO Generate | Text-to-image generation | +| FIBO Edit Recipes | FIBO Edit with 100+ curated preset prompts across 11 categories — including Create Mask, Create Soft Mask, and Remove Greenscreen Spill compositing presets | +| FIBO Generate | Text-to-image generation with multi-variant batch (1–4 in parallel) | | Generate VGL | Structured-prompt JSON generator (chains into FIBO nodes) | | Sequence Output | Batch render a chain of upstream Bria nodes across a frame range | All tools are accessible from the **Bria** menu or by pressing Tab and typing "bria". +Every Bria node also includes **View Generation Data** and **Copy Generation Data** buttons (Settings tab) for inspecting or copying the most recent run's prompt, seed, response, and full sidecar JSON. + ## Shared configuration Bria tools store configuration in `~/.bria/bria.json`. This file is shared across @@ -76,10 +78,10 @@ Dev launcher scripts are also provided in `tools/dev/`. ## Documentation -- Detailed setup and node reference: [docs/bria_nuke/README.md](docs/bria_nuke/README.md) -- Configuration reference: [docs/CONFIG.md](docs/CONFIG.md) -- Repo structure: [docs/contributing/STRUCTURE.md](docs/contributing/STRUCTURE.md) -- Troubleshooting: [docs/bria_nuke/TROUBLESHOOTING.md](docs/bria_nuke/TROUBLESHOOTING.md) +- Detailed setup and node reference: [documentation/bria_nuke/README.md](documentation/bria_nuke/README.md) +- Configuration reference: [documentation/CONFIG.md](documentation/CONFIG.md) +- Repo structure: [STRUCTURE.md](STRUCTURE.md) +- Troubleshooting: [documentation/bria_nuke/TROUBLESHOOTING.md](documentation/bria_nuke/TROUBLESHOOTING.md) ## License diff --git a/bria_core/utils.py b/bria_core/utils.py index ecde0e9..bc7d060 100644 --- a/bria_core/utils.py +++ b/bria_core/utils.py @@ -116,28 +116,61 @@ def resolve_proxies( def extract_image_url(data: dict) -> str | None: + urls = extract_image_urls(data) + return urls[0] if urls else None + + +def extract_image_urls(data: dict) -> list[str]: + """Return every image URL the API response contains, in order. + + Handles a few shapes Bria's endpoints have used: + - {"result": {"image_url": "..."}} (single, FIBO Generate sync) + - {"result": {"images": ["url1", "url2"]}} (multi as list of strings) + - {"result": {"images": [{"image_url": "..."}, ...]}} (multi as list of dicts) + - {"result": [{"image_url": "..."}, ...]} (result as list of dicts) + - {"image_url": "..."} (top-level) + - {"images": [...]} (top-level array) + """ if not isinstance(data, dict): - return None + return [] + + seen: set[str] = set() + out: list[str] = [] + + def _add(value: object) -> None: + if isinstance(value, str): + v = value.strip() + if v and v not in seen: + seen.add(v) + out.append(v) + + def _walk_image_dict(obj: object) -> None: + if isinstance(obj, dict): + for key in ("image_url", "url", "imageUrl"): + _add(obj.get(key)) + + def _walk_image_list(items: object) -> None: + if isinstance(items, list): + for item in items: + if isinstance(item, str): + _add(item) + elif isinstance(item, dict): + _walk_image_dict(item) + result = data.get("result") if isinstance(result, dict): - url = result.get("image_url") - if isinstance(url, str) and url.strip(): - return url.strip() - - images = result.get("images") - if isinstance(images, list) and images: - first = images[0] - if isinstance(first, str) and first.strip(): - return first.strip() - if isinstance(first, dict): - first_url = first.get("image_url") - if isinstance(first_url, str) and first_url.strip(): - return first_url.strip() - - url_top = data.get("image_url") - if isinstance(url_top, str) and url_top.strip(): - return url_top.strip() - return None + _walk_image_dict(result) + _walk_image_list(result.get("images")) + _walk_image_list(result.get("results")) + elif isinstance(result, list): + _walk_image_list(result) + + # Top-level fallbacks + _walk_image_dict(data) + _walk_image_list(data.get("images")) + _walk_image_list(data.get("results")) + + return out def png_ihdr_info(path: str) -> dict | None: diff --git a/docs/bria_nuke/README.md b/docs/bria_nuke/README.md index 87cb2fa..6f40b51 100644 --- a/docs/bria_nuke/README.md +++ b/docs/bria_nuke/README.md @@ -105,7 +105,7 @@ Current node set (11): Enhance defaults and controls: - `preserve_alpha` defaults to **on** -- `sync` is hidden on current new nodes and forced to `true` at runtime because async polling is not implemented for Enhance yet +- `sync` is hidden on current new nodes and forced to `false` at runtime — Bria's `/v2/image/edit/enhance` endpoint stopped accepting `sync=true` (returns 422) in May 2026, and `BriaClient._poll_status` handles the async response transparently - `seed` is optional - `Use Mask` toggles region-limited internal composite (`Keymix`) between original and enhanced result - mask inputs without alpha are normalized internally (mask red -> alpha) before compositing @@ -153,6 +153,13 @@ FIBO Generate UX highlights: - includes `status` field (`idle` / `processing` / `done` / `error`) - includes `Open Bria Dashboard` button - `Before` / `After` compares previous generated result against latest generated result +- **Multi-variant batch (Generate Multiple Images)**: + - `Generate Multiple Images` toggle reveals a `Num Images` dropdown (2, 3, or 4), an `Image Index` slider, and a `Use This Image` button + - toggle ON → on Run, the node fires N parallel async calls to `/v2/image/generate` (the endpoint returns one image per call, so true batching is parallel-call style; per-variant seed offset is applied when a base seed is set so variants are actually different) + - saved files are suffixed `_v01.png` … `_vNN.png` next to the canonical `result_path`; the canonical path points at variant 1 by default + - after a multi-variant run, drag the `Image Index` slider to switch the internal Read between variants without re-running + - `Use This Image` promotes the currently-displayed variant to be the canonical `result_path` so downstream nodes (Sequence Output, etc.) consume the locked-in variant + - sidecar JSON records every variant in `result_paths`, with `result_path` / `image_path` still pointing at the first for backward compatibility FIBO Edit UX highlights: @@ -197,10 +204,18 @@ Generate VGL node history: FIBO Edit Recipes UX highlights: -- categorized preset launcher built on FIBO Edit's execution path; ships with 99 prompts across 11 categories (Style, Weather, Seasons, Time of Day, Camera, Lighting, Compositing, Cleanup, AI Corrections, Object Edits, plus Custom for user prompts) +- categorized preset launcher built on FIBO Edit's execution path; ships with 100+ prompts across 11 categories (Style, Weather, Seasons, Time of Day, Camera, Lighting, Compositing, Cleanup, AI Corrections, Object Edits, plus Custom for user prompts) - pick a category → the matching Preset dropdown appears with that category's options; selecting a preset auto-fills the prompt field -- **Object-targeted presets** (5 of them: delete, replace, isolate, change color, change material) reveal 5 fixed Object slots — fill in the names of objects in your image and the prompt is rebuilt with constraint-injection ("STRICT LOCKS") wording so the model only edits what you specify -- **Compositing-targeted presets** (7 of them: harmonize lighting, match shadows, blend edges, color harmonize, fix reflections, integrate elements, match ambient) reveal 5 fixed Element slots — describe which composited elements need attention +- **Object-targeted presets** (delete, replace, isolate, change color, change material) reveal 5 fixed Object slots — fill in the names of objects in your image and the prompt is rebuilt with constraint-injection ("STRICT LOCKS") wording so the model only edits what you specify. Isolate Object and Delete Object work with object name only (no modifier required); other presets fall back to the generic preset when the modifier is missing. +- **Compositing-targeted presets** (harmonize lighting, match shadows, blend edges, color harmonize, fix reflections, integrate elements, match ambient, **Create Mask**, **Create Soft Mask**, **Remove Greenscreen Spill**) reveal 5 fixed Element slots — describe which composited elements need attention +- **Create Mask** outputs a clean binary alpha matte (white over named element(s), black elsewhere) suitable as a luminance key +- **Create Soft Mask** outputs a grayscale density matte for atmospherics — smoke, fire, dust, fog, mist — where solid objects in the same frame remain pure black +- **Remove Greenscreen Spill** removes green/blue-screen color contamination, fringing, and edge spill from named element(s) while preserving naturally-occurring greens (foliage, signage, wardrobe). Designed to run after the keyed subject is composited over its new background. +- **Premultiply with Mask toggle + Mask Edge Softness slider** appear automatically when Create Mask or Create Soft Mask is the active preset + - toggle ON → routes the API-returned mask through an internal `Blur → FilterErode → Copy(rgb=input, a=mask.red) → Premult` chain, producing a clean cutout element with anti-aliased edges + - toggle OFF → output is the raw API mask (the existing behavior) + - Mask Edge Softness drives the Blur size and a half-size FilterErode that compensates for blur expansion, so the alpha stays tight to the named element + - both controls are hidden for any other preset and auto-clear when the user switches preset/category, so subsequent runs do not unexpectedly produce cutouts - auto-vs-manual prompt tracking: if you edit the prompt manually after a preset is loaded, switching presets won't clobber your edits - delegates the actual API call to the existing FIBO Edit pipeline — same history, presets, metadata sidecar, and viewer behavior diff --git a/nuke/adapter.py b/nuke/adapter.py index 521d7e8..74d75cf 100644 --- a/nuke/adapter.py +++ b/nuke/adapter.py @@ -468,9 +468,13 @@ def upscale_from_files( session=session, ) + # As of 2026-05, /v2/image/edit/enhance returns 422 for sync=true ("sync request is not + # supported with this endpoint"). BriaClient._poll_status handles async responses, so + # default Enhance to sync=False. Other modes (increase_resolution) retain the prior default. + default_sync = False if endpoint_name == "enhance" else True payload: Dict[str, object] = { "image": file_to_base64(image_path), - "sync": True if sync is None else bool(sync), + "sync": default_sync if sync is None else bool(sync), } if seed is not None: diff --git a/nuke/menu.py b/nuke/menu.py index a1c7a7c..d7169f3 100644 --- a/nuke/menu.py +++ b/nuke/menu.py @@ -1597,6 +1597,43 @@ def _add_knob(knob): for i in range(1, max_comp_slots + 1): _add_knob(nuke.String_Knob(f"comp_element_desc_{i}", f"Element {i}", "")) + # "Premultiply with Mask" — only visible when the Create Mask preset is active. + # When ON, an internal Copy+Premult chain produces a cutout (input image with + # alpha sourced from the API-returned mask's red channel). Auto-clears when the + # user switches preset/category away from create_mask. + premult_with_mask_knob = nuke.Boolean_Knob( + "premultiply_with_mask", "Premultiply with Mask", False + ) + try: + premult_with_mask_knob.setTooltip( + "When 'Create Mask' is active, premultiply the API-returned mask against " + "the original input image to output a clean cutout (RGB from input, alpha " + "from mask's red channel). Disabled by default; auto-clears on preset change." + ) + premult_with_mask_knob.setFlag(nuke.STARTLINE) + premult_with_mask_knob.setVisible(False) + except Exception: + pass + _add_knob(premult_with_mask_knob) + # Mask Edge Softness slider — Bria's mask is sometimes returned at lower-than-source + # resolution and upscaled with hard binary edges, leaving a stair-step pattern. A + # small Gaussian blur smooths the boundary; a paired FilterErode at half the blur + # size compensates for silhouette expansion so the alpha stays tight to the named + # element. Higher values = softer edges (use for hair, motion blur, atmospherics). + mask_softness_knob = nuke.Double_Knob("mask_edge_softness", "Mask Edge Softness") + try: + mask_softness_knob.setRange(0.0, 5.0) + mask_softness_knob.setValue(1.5) + mask_softness_knob.setTooltip( + "Smooths staircase aliasing on the mask boundary before premultiplying. " + "0 = raw model output (may be aliased). 1-2 = clean anti-aliased edges. " + "3-5 = softer feather (good for hair, fur, motion blur)." + ) + mask_softness_knob.setVisible(False) + except Exception: + pass + _add_knob(mask_softness_knob) + # Hidden auto-prompt tracker auto_prompt_knob = nuke.String_Knob("_auto_prompt", "_auto_prompt", "") _add_knob(auto_prompt_knob) @@ -1878,11 +1915,102 @@ def _add_knob(knob): pass _repair_fibo_edit_internal_graph(node) + _add_premultiply_with_mask_path(node) _connect_created_node_inputs(node, selected_inputs) nuke.tprint(f"[Bria] Created node: {node.name()}") +def _add_premultiply_with_mask_path(node) -> None: + """Extend the recipes node's internal graph with a Copy + Premult path so the + 'Premultiply with Mask' toggle can produce a clean cutout when Create Mask + preset is active. + + Graph shape after this: + input_image ─┬───────────────────────────────────────────┐ + │ │ + ├─ Copy(rgb=in1, a=in2.red) ─ Premult ──────┐│ + loader_result┘ ││ + │├ switch_premult ─ output1 + loader_before ─┐ ││ + ├ switch_before_after ────────────────────┘│ + loader_result ─┘ │ + │ + switch_premult.which is bound to `parent.premultiply_with_mask`. + """ + import nuke # type: ignore + + try: + node.begin() + input_image = nuke.toNode("input_image") + loader_result = nuke.toNode("loader_result") + existing_switch = nuke.toNode("switch_before_after") + output1 = nuke.toNode("output1") + if not all((input_image, loader_result, existing_switch, output1)): + return + + # Don't double-build if node was repaired and already has the chain. + if nuke.toNode("switch_premult") is not None: + return + + # Mask edge softening: Blur smooths the staircase from the model's coarse + # binary output; FilterErode at half size pulls the silhouette back in to + # compensate for blur expansion, keeping the alpha tight to the named element. + # Both controlled by `parent.mask_edge_softness` so size=0 leaves mask as-is. + mask_blur = nuke.nodes.Blur(name="mask_blur") + mask_blur.setInput(0, loader_result) + try: + mask_blur.knob("size").setExpression("parent.mask_edge_softness") + mask_blur["label"].setValue("AA staircase") + except Exception: + pass + + mask_erode = nuke.nodes.FilterErode(name="mask_erode") + mask_erode.setInput(0, mask_blur) + try: + mask_erode.knob("size").setExpression("parent.mask_edge_softness * 0.5") + mask_erode["label"].setValue("compensate blur expansion") + except Exception: + pass + + copy_node = nuke.nodes.Copy(name="copy_mask_to_alpha") + try: + copy_node.knob("from0").setValue("rgba.red") + copy_node.knob("to0").setValue("rgba.alpha") + except Exception: + pass + copy_node.setInput(0, input_image) + copy_node.setInput(1, mask_erode) + try: + copy_node["label"].setValue("mask.r → alpha") + except Exception: + pass + + premult = nuke.nodes.Premult(name="premult_mask") + premult.setInput(0, copy_node) + + switch_premult = nuke.nodes.Switch(name="switch_premult") + switch_premult.setInput(0, existing_switch) + switch_premult.setInput(1, premult) + try: + switch_premult.knob("which").setExpression("parent.premultiply_with_mask") + switch_premult["label"].setValue("0=raw / 1=premultiplied") + except Exception: + pass + + output1.setInput(0, switch_premult) + except Exception as exc: + try: + nuke.tprint(f"[Bria] Premultiply path build skipped: {exc}") + except Exception: + pass + finally: + try: + node.end() + except Exception: + pass + + def _create_sequence_output_node() -> None: """Batch-renders a chain of upstream Bria nodes across a frame range.""" import nuke # type: ignore @@ -2279,6 +2407,70 @@ def _add_knob(knob): _add_knob(nuke.Int_Knob("seed", "Seed")) _add_knob(nuke.Int_Knob("guidance_scale", "Guidance Scale")) _add_knob(nuke.Int_Knob("steps_num", "Steps")) + # "Generate Multiple Images" — toggle that gates a 4-control block. All 4 + # appear together when the toggle is ON; the slider's range and the button's + # behavior become meaningful once a multi-variant run completes. + # 1. use_multiple_images (this toggle, always visible) + # 2. num_images dropdown (visible when toggle is on) + # 3. result_variant slider (visible when toggle is on) + # 4. use_this_image button (visible when toggle is on) + use_multiple_knob = nuke.Boolean_Knob( + "use_multiple_images", "Generate Multiple Images", False + ) + try: + use_multiple_knob.setTooltip( + "Generate 2-4 variants per click. After Run, drag Image Index to preview them; " + "click 'Use This Image' to lock in the one you want." + ) + use_multiple_knob.setFlag(nuke.STARTLINE) + except Exception: + pass + _add_knob(use_multiple_knob) + # num_images cap of 4 mirrors the proven Background Replace HDA precedent + # (the only Bria endpoint we have empirically validated num_results > 1 against). + num_images_knob = nuke.Enumeration_Knob("num_images", "Num Images", ["2", "3", "4"]) + try: + num_images_knob.setTooltip( + "Number of variants to generate per call (2-4). All variants are saved to disk." + ) + num_images_knob.setVisible(False) + except Exception: + pass + _add_knob(num_images_knob) + # Image Index — Double_Knob renders as a real slider in Nuke 17 (Int_Knob does not, + # even with setRange + nuke.SLIDER flag). Slider range is set dynamically per-run + # to match the ACTUAL variants saved. Value is read with int(round(...)). + result_variant_knob = nuke.Double_Knob("result_variant", "Image Index") + try: + result_variant_knob.setRange(1, 4) + result_variant_knob.setValue(1) + result_variant_knob.setVisible(False) + result_variant_knob.setTooltip( + "Drag to preview each variant from the most recent multi-image run. " + "Click 'Use This Image' to lock in the selected variant." + ) + except Exception: + pass + _add_knob(result_variant_knob) + use_this_image_knob = nuke.PyScript_Knob( + "use_this_image", + "Use This Image", + "import importlib, fibo_generate as _bria_fg; importlib.reload(_bria_fg); _bria_fg.on_use_this_image()", + ) + try: + use_this_image_knob.setVisible(False) + use_this_image_knob.setTooltip( + "Promote the currently-displayed variant to the canonical Result Path." + ) + except Exception: + pass + _add_knob(use_this_image_knob) + variant_count_knob = nuke.String_Knob("result_variant_count", "_variant_count", "1") + _add_knob(variant_count_knob) + try: + variant_count_knob.setVisible(False) + except Exception: + pass _add_knob(nuke.Text_Knob("prompt_sep", "", "")) _add_knob(nuke.Multiline_Eval_String_Knob("prompt", "Prompt")) @@ -2539,9 +2731,12 @@ def _add_knob(knob): pass # Keep optional prompt/model controls tidy when knobs change. + # importlib.reload mirrors the gizmo file's pattern so subsequent code edits + # are picked up without requiring a Nuke restart. try: node.knob("knobChanged").setValue( - "import fibo_generate as _bria_fg; _bria_fg.on_knob_changed()" + "import importlib, fibo_generate as _bria_fg; " + "importlib.reload(_bria_fg); _bria_fg.on_knob_changed()" ) except Exception: pass diff --git a/nuke/nodes/enhance.py b/nuke/nodes/enhance.py index 0ac78c4..1a57f9b 100644 --- a/nuke/nodes/enhance.py +++ b/nuke/nodes/enhance.py @@ -536,12 +536,9 @@ def enhance_bria(node: Any) -> None: resolution = (_opt_parm_menu_str(node, "resolution") or "2MP").strip() seed = _opt_parm_int(node, "seed") - sync = True - if _opt_parm_bool(node, "sync") is False: - _debug_log( - "Enhance sync disabled on node; forcing sync=True because async polling is not implemented." - ) - _on_main_thread(lambda: node.knob("sync").setValue(True) if node.knob("sync") is not None else None) + # Bria /v2/image/edit/enhance no longer accepts sync=true (returns 422 since 2026-05). + # BriaClient._poll_status handles the async response transparently, so always send False. + sync = False cfg = load_config() out_path = _resolve_output_path(node, cfg, run_id, "enhance") diff --git a/nuke/nodes/fibo_edit_recipes.py b/nuke/nodes/fibo_edit_recipes.py index b51cc00..004c7f5 100644 --- a/nuke/nodes/fibo_edit_recipes.py +++ b/nuke/nodes/fibo_edit_recipes.py @@ -53,6 +53,10 @@ "change_object_material", "isolate_object", }) +# Presets whose action templates do NOT reference {mod1} — modifier is optional. +# Multi-target action template uses {obj_list} (just names) instead of {obj_mod_list}. +_PRESETS_WITHOUT_MOD = frozenset({"delete_object", "isolate_object"}) + _OBJ_ACTIONS = { "delete_object": { "fallback": "Delete the specified object from the scene.", @@ -187,9 +191,13 @@ def _obj_fallback_prompt(key: str) -> str: # --------------------------------------------------------------------------- TARGETED_COMP_PRESETS = frozenset({ "harmonize_lighting", "match_shadows", "blend_edges", "color_harmonize", - "fix_reflections", "integrate_elements", "match_ambient", + "fix_reflections", "integrate_elements", "match_ambient", "create_mask", + "create_soft_mask", "remove_spill", }) +# Mask presets that show the Premultiply/Edge Softness controls. +_MASK_PRESETS = frozenset({"create_mask", "create_soft_mask"}) + _COMP_ACTIONS = { "harmonize_lighting": { "fallback": "Perform a targeted lighting harmonization pass on this composite image.", @@ -226,6 +234,21 @@ def _obj_fallback_prompt(key: str) -> str: "single": "Perform a targeted ambient light matching pass on this composite image, focusing on {elem1}.", "multi": "Perform a targeted ambient light matching pass on this composite image, focusing on the following composited elements: {elem_list}.", }, + "create_mask": { + "fallback": "Output a flat-fill rotoscope holdout matte for compositing: render the specified element as a solid white silhouette filled completely on a pure black background, pixel-aligned with the input image. Output is two-tone only \u2014 pure white and pure black, no other colors.", + "single": "Output a flat-fill rotoscope holdout matte for compositing: render {elem1} as a solid white silhouette filled completely on a pure black background, pixel-aligned with the input image. Output is two-tone only \u2014 pure white and pure black, no other colors.", + "multi": "Output a flat-fill rotoscope holdout matte for compositing: render the following elements as solid white silhouettes filled completely on a pure black background, pixel-aligned with the input image: {elem_list}. Output is two-tone only \u2014 pure white and pure black, no other colors.", + }, + "remove_spill": { + "fallback": "Remove all green-screen and blue-screen spill, fringing, and color contamination from the specified element(s), blending them naturally into the surrounding scene.", + "single": "Remove all green-screen and blue-screen spill, fringing, and color contamination from {elem1}, blending {elem1} naturally into the surrounding scene.", + "multi": "Remove all green-screen and blue-screen spill, fringing, and color contamination from the following elements: {elem_list}. Blend each element naturally into the surrounding scene.", + }, + "create_soft_mask": { + "fallback": "Output a grayscale density matte for compositing: render the specified soft element with intermediate gray values mapping its local density across the image, pixel-aligned with the input.", + "single": "Output a grayscale density matte for compositing: render {elem1} with intermediate gray values mapping its local density across the image \u2014 pure white where {elem1} is at maximum density, pure black where {elem1} is absent, gray gradients tracking partial density and feathered edges. Pixel-aligned with the input.", + "multi": "Output a grayscale density matte for compositing: render the following soft elements with density-mapped gray values, pixel-aligned with the input: {elem_list}. White = maximum density, black = absent, gray = partial density.", + }, } _COMP_BODIES = { @@ -401,6 +424,97 @@ def _obj_fallback_prompt(key: str) -> str: "Result requirement:\n" "The output should have consistent ambient illumination across all elements as if they exist in the same environment. If no ambient mismatch is detected, return the image unchanged." ), + "remove_spill": ( + "STRICT LOCKS (non-negotiable):\n" + "Do NOT move, warp, scale, rotate, reshape, or reposition the specified element(s) or anything else in the image.\n" + "Preserve the specified element(s)' silhouette, pose, position, and scale EXACTLY as they appear in the input image. The output must align pixel-for-pixel with the input.\n" + "Do NOT regenerate, redraw, or reinterpret the scene's geometry, layout, or composition.\n" + "Do NOT change the background, environment, lighting setup, or other elements that are not affected by green/blue-screen spill.\n" + "Do NOT alter materials, textures, or fundamental colors that are NOT spill-related.\n" + "Scope of work:\n" + "Identify and remove green-screen and blue-screen spill on the specified element(s). Spill is the unwanted color contamination caused by the chroma key environment reflecting onto the subject during the original capture, including:\n" + "- green or yellow-green color tint on skin, hair, and edges\n" + "- green halos or fringing along the silhouette boundary of the specified element(s)\n" + "- green-tinted highlights and reflections on shiny or reflective surfaces (armor, helmets, glass, metal, plastic)\n" + "- green color cast on white or light-colored clothing, fabric, hair, and props\n" + "- green ambient bounce in shadow regions of the specified element(s)\n" + "Apply targeted, localized despill to the specified element(s):\n" + "- neutralize unwanted green/blue color casts back to the element's natural color\n" + "- restore correct skin tone, hair color, and surface color where spill was present\n" + "- clean fringing along the element silhouette to remove green/blue edge contamination\n" + "- adjust reflections and highlights so they pick up the surrounding scene's color rather than the original chroma green/blue\n" + "CRITICAL RULES:\n" + "All edits must be confined to the specified element(s). Do NOT recolor or relight the background or any other elements.\n" + "Do NOT remove naturally-occurring green or blue colors (e.g., a green shirt, foliage, signage, sky, or water that is supposed to be green/blue). Only remove green/blue contamination that is clearly chroma-key spill on the specified element(s).\n" + "Do NOT shift the specified element(s)' overall color grade beyond what is needed to remove the unwanted spill.\n" + "Preserve natural shadows, highlights, material properties, and texture detail on the specified element(s).\n" + "Pixel-accurate registration with the input image is mandatory.\n" + "Result requirement:\n" + "The output should look as if the specified element(s) were originally photographed in the current background environment, with no remaining trace of green-screen or blue-screen color contamination. If no spill is detected on the specified element(s), return the image unchanged." + ), + "create_mask": ( + "ALIGNMENT LOCK (highest priority, non-negotiable):\n" + "Treat this as a tracing task, not a generation task. The input image is the absolute ground truth for ALL geometry, position, scale, pose, and silhouette boundaries. Your only job is to trace the input.\n" + "The output must align PIXEL-FOR-PIXEL with the input image. The dimensions, the specified element(s)' positions, their silhouette outlines, and every edge must match the input exactly.\n" + "Do NOT move, warp, scale, rotate, shift, reshape, repose, or reposition the specified element(s) or anything else by even one pixel.\n" + "Do NOT regenerate, redraw, reinterpret, restage, or re-imagine any element.\n" + "Do NOT apply any creative interpretation \u2014 you are tracing, not creating.\n" + "OUTPUT FORMAT (non-negotiable):\n" + "This is a ROTOSCOPE HOLDOUT MATTE for compositing \u2014 the kind of flat-fill silhouette a roto artist paints to use as an alpha channel. It is NOT a stylized illustration, line drawing, halftone, or comic-book rendering of the subject.\n" + "The output is two-tone: pure white (RGB 255,255,255) inside the silhouette of the named element(s); pure black (RGB 0,0,0) outside. The subject's actual color, texture, and visual appearance must NOT be visible \u2014 only its filled silhouette.\n" + "Render the matte at FULL native resolution of the input image with anti-aliased silhouette edges. Edges should be smooth and crisp, not pixel-staircase aliased. Anti-aliasing gradient values are permitted ONLY at the 1-2 pixel boundary where the silhouette meets black; the interior of the silhouette must remain solid pure white.\n" + "WHITE pixels: cover the silhouette of the specified element(s) in the input \u2014 edge to edge, fully filled, no interior detail. Treat each named element as one solid filled-in shape, like a paper cutout. NO internal panel lines, NO armor seams, NO shadows, NO line work, NO holes, NO black pixels at all inside the silhouette.\n" + "BLACK pixels: everywhere else.\n" + "EXCLUDE from the white silhouette \u2014 these MUST remain pure black in the matte even when they overlap, surround, or touch the named element(s):\n" + "- atmospheric elements: smoke, fog, dust, haze, mist, vapor, steam\n" + "- particles: sparks, embers, debris dust, droplets, splashes, water\n" + "- motion blur trails, motion streaks, lens flares, light glows, lens artifacts\n" + "- any other transparent or semi-transparent secondary VFX elements\n" + "The named element(s) are SOLID PHYSICAL OBJECTS only. If smoke or atmosphere overlaps or wraps around the silhouette of a named element, the smoke must be black and the named element must remain a clean filled silhouette underneath \u2014 do NOT carve notches into the named element where it meets atmospherics.\n" + "EDGE BEHAVIOR:\n" + "Edges of the white silhouette follow the EXACT outline of the specified element(s) in the input. Sharp by default. 1-2 pixel feather only at boundaries that are genuinely soft in the input (hair, fur, motion blur, atmospheric edges).\n" + "Areas of green-screen or blue-screen contamination on the named element(s) are STILL PART OF the named element(s) \u2014 include them as white in the matte. Do NOT exclude spill-affected areas from the silhouette.\n" + "EXPLICIT ANTI-PATTERNS \u2014 do NOT do any of these:\n" + "Do NOT output the original image with a black background (that is isolation, not masking).\n" + "Do NOT output a stylized line drawing, illustration, halftone, or comic-book rendering of the subject.\n" + "Do NOT output color, grayscale, or any non-binary values inside the silhouette.\n" + "Do NOT add new content, props, environmental details, or speculative geometry.\n" + "Result requirement:\n" + "The output is a clean flat-fill binary matte aligned pixel-perfectly with the input \u2014 solid white over the silhouette of the specified element(s) at their actual positions in the input, solid black everywhere else, with NO internal detail of any kind. " + "If the specified element(s) cannot be identified, return a fully black image of the same dimensions as the input." + ), + "create_soft_mask": ( + "ALIGNMENT LOCK (highest priority, non-negotiable):\n" + "Treat this as a tracing task, not a generation task. The input image is the absolute ground truth for ALL geometry, position, scale, and density distribution. Your only job is to trace the input.\n" + "The output must align PIXEL-FOR-PIXEL with the input image. Every visible feature of the named soft element(s) must be at its EXACT pixel position from the input.\n" + "Do NOT move, warp, scale, rotate, shift, or reposition any element by even one pixel.\n" + "Do NOT regenerate, redraw, reinterpret, or restage any element.\n" + "OUTPUT FORMAT (non-negotiable):\n" + "This is a GRAYSCALE DENSITY MATTE for compositing \u2014 the kind of soft alpha matte used for atmospherics (smoke, fire, fog, rain, dust, mist, vapor, splashes) and other semi-transparent VFX elements where each pixel's brightness represents how dense or opaque the named element is at that location.\n" + "The output is grayscale: pure black (RGB 0,0,0), pure white (RGB 255,255,255), and the full continuous range of intermediate gray values. It is NOT binary, NOT a hard cutout, NOT an artistic stylization, NOT a line drawing, NOT a comic-book rendering.\n" + "The matte is monochromatic \u2014 the subject's color, texture, and visual appearance must NOT be visible in the output, only its density distribution as gray values.\n" + "Render the matte at FULL native resolution of the input image with smooth anti-aliased gradients between density levels.\n" + "DENSITY MAPPING:\n" + "- Pure white: where the named element(s) are at maximum density / fully opaque in the input image\n" + "- Pure black: where the named element(s) are completely absent\n" + "- Intermediate gray: scales linearly with the local density of the named element(s) \u2014 denser regions are lighter gray approaching white; wispier, sparser, more transparent regions are darker gray approaching black\n" + "- Edges and boundaries: smooth gradient falloff matching the natural softness of the named element(s) in the input\n" + "INCLUDE in the matte (these get gray-to-white values based on their density in the input):\n" + "- The named element(s), at their ACTUAL pixel positions and density distribution from the input\n" + "EXCLUDE from the matte \u2014 these MUST be PURE BLACK regardless of where they are or what they overlap:\n" + "- Solid physical objects (people, props, vehicles, set pieces, sets, environments) unless explicitly named\n" + "- Background, sky, ground, walls, surfaces unless explicitly named\n" + "- Any element not in the named list\n" + "If a solid object overlaps or is partially occluded by the named soft element, the solid object stays black; only the soft element contributes gray/white density on top of it in the matte.\n" + "CRITICAL RULES:\n" + "Pixel-accurate registration with the input image is mandatory. Output dimensions and density positions must match the input exactly.\n" + "Do NOT add new content, atmospherics, or VFX not present in the input.\n" + "Do NOT shift the position, scale, shape, or density distribution of the named element(s).\n" + "Do NOT introduce color, hue tints, or non-grayscale values \u2014 the output is monochromatic gray only.\n" + "Result requirement:\n" + "The output is a smooth grayscale density matte aligned pixel-perfectly with the input \u2014 white where the named element(s) are most dense, black where they are absent or where solid non-named objects exist, gray gradients faithfully tracking partial density. Usable directly as an alpha channel for compositing the soft element(s) into a new scene with proper transparency falloff. " + "If the specified element(s) cannot be identified, return a fully black image of the same dimensions as the input." + ), } @@ -752,6 +866,9 @@ def _comp_fallback_prompt(key: str) -> str: ("fix_reflections", "Fix Reflections", _comp_fallback_prompt("fix_reflections")), ("integrate_elements", "Integrate Elements", _comp_fallback_prompt("integrate_elements")), ("match_ambient", "Match Ambient Light", _comp_fallback_prompt("match_ambient")), + ("create_mask", "Create Mask", _comp_fallback_prompt("create_mask")), + ("create_soft_mask", "Create Soft Mask", _comp_fallback_prompt("create_soft_mask")), + ("remove_spill", "Remove Greenscreen Spill", _comp_fallback_prompt("remove_spill")), ("depth_consistency", "Depth Consistency", "Perform a targeted depth consistency pass on this composite image.\n" "STRICT LOCKS (non-negotiable):\n" @@ -840,6 +957,129 @@ def _collect_object_targets(node: Any) -> List[Tuple[str, str]]: return targets +def _join_natural(items: List[str]) -> Optional[str]: + """Join with natural English: 'a', 'a and b', 'a, b, and c'.""" + items = [s for s in items if s] + if not items: + return None + if len(items) == 1: + return items[0] + if len(items) == 2: + return f"{items[0]} and {items[1]}" + return ", ".join(items[:-1]) + f", and {items[-1]}" + + +def _possessive(name: str) -> str: + """English possessive: cat -> cat's, cats -> cats'.""" + return f"{name}'" if name.endswith("s") else f"{name}'s" + + +def _format_object_phrase(targets: List[Tuple[str, str]]) -> Optional[str]: + return _join_natural([name for name, _ in targets if name]) + + +def _format_object_possessive(targets: List[Tuple[str, str]]) -> Optional[str]: + return _join_natural([_possessive(name) for name, _ in targets if name]) + + +def _format_modifier_phrase(targets: List[Tuple[str, str]]) -> Optional[str]: + return _join_natural([mod for _, mod in targets if mod]) + + +# Phrase tables: each (needle, possessive?) pair is a placeholder we look for in +# body text. Possessive needles are replaced with the possessive form of the +# object phrase; non-possessive with the bare phrase. Order matters — longer +# needles must come first so they match before any shorter substring of them. +_OBJ_POSSESSIVE_NEEDLES = ( + "the specified object(s)'", + "the specified objects'", + "the specified object's", + "specified object(s)'", + "specified objects'", + "specified object's", + "the specified element(s)'", + "the specified elements'", + "the specified element's", + "specified element(s)'", + "specified elements'", + "specified element's", +) +_OBJ_NEEDLES = ( + "the specified object(s)", + "the specified objects", + "the specified object", + "The specified object(s)", + "The specified objects", + "The specified object", + "the isolated object(s)", + "the isolated objects", + "the isolated object", + "specified object(s)", + "specified objects", + "specified object", + "the specified element(s)", + "the specified elements", + "the specified element", + "The specified element(s)", + "The specified elements", + "The specified element", + "specified element(s)", + "specified elements", + "specified element", +) + +_MOD_NEEDLES_BY_PRESET = { + "replace_object": ( + "the described replacement(s)", + "the described replacement", + "the replacement object(s)", + "the replacement(s)", + "the replacement", + ), + "change_object_color": ( + "the described target color(s)", + "the described target color", + "the target color(s)", + "the new color(s)", + "the new color", + "the target color", + ), + "change_object_material": ( + "the described target material(s)", + "the described target material", + "the target material(s)", + "the new material(s)", + "the new material", + "the target material", + ), +} + + +def _substitute_body(body: str, preset_key: str, targets: List[Tuple[str, str]]) -> str: + """Replace generic 'specified object(s)' / replacement / target color phrases in + the body text with the user's actual object names and modifiers. + + Without this, the long body keeps saying 'the specified object(s)' even after + the user has named a target — the model gets ambiguous targeting and tends to + return generic results regardless of the named object. + """ + obj_phrase = _format_object_phrase(targets) + obj_poss = _format_object_possessive(targets) + mod_phrase = _format_modifier_phrase(targets) + + if obj_phrase: + for needle in _OBJ_POSSESSIVE_NEEDLES: + body = body.replace(needle, obj_poss or obj_phrase) + for needle in _OBJ_NEEDLES: + body = body.replace(needle, obj_phrase) + + if mod_phrase: + for needle in _MOD_NEEDLES_BY_PRESET.get(preset_key, ()): + body = body.replace(needle, mod_phrase) + + return body + + def _build_object_prompt(preset_key: str, targets: List[Tuple[str, str]]) -> Optional[str]: if preset_key not in TARGETED_OBJECT_PRESETS or not targets: return None @@ -852,10 +1092,11 @@ def _build_object_prompt(preset_key: str, targets: List[Tuple[str, str]]) -> Opt action = actions["single"].replace("{obj1}", obj1) if mod1: action = action.replace("{mod1}", mod1) - elif preset_key != "delete_object": + elif preset_key not in _PRESETS_WITHOUT_MOD: + # replace/color/material need a modifier; isolate/delete don't. return None else: - if preset_key == "delete_object": + if preset_key in _PRESETS_WITHOUT_MOD: obj_list = ", ".join(name for name, _ in targets) action = actions["multi"].replace("{obj_list}", obj_list) else: @@ -868,6 +1109,7 @@ def _build_object_prompt(preset_key: str, targets: List[Tuple[str, str]]) -> Opt obj_mod_list = "; ".join(pairs) action = actions["multi"].replace("{obj_mod_list}", obj_mod_list) + body = _substitute_body(body, preset_key, targets) return action + "\n" + body @@ -890,9 +1132,13 @@ def _build_comp_prompt(preset_key: str, elements: List[str]) -> Optional[str]: if len(elements) == 1: action = actions["single"].replace("{elem1}", elements[0]) else: - elem_list = ", ".join(elements) + elem_list = _join_natural(elements) or ", ".join(elements) action = actions["multi"].replace("{elem_list}", elem_list) + # Substitute generic "specified element(s)" / "specified object(s)" patterns + # in the body too. Existing comp presets (harmonize_lighting etc.) don't use + # these phrases, so this is a no-op for them; create_mask relies on it. + body = _substitute_body(body, preset_key, [(e, "") for e in elements]) return action + "\n" + body @@ -1006,6 +1252,17 @@ def _apply_visibility(node: Any) -> None: comp_header.setVisible(show_comp) except Exception: pass + + # 'Premultiply with Mask' + 'Mask Edge Softness' are mask-preset-only controls + # (Create Mask hard, Create Soft Mask). Same internal Premultiply chain serves both. + show_premult = category == "compositing" and preset_token in _MASK_PRESETS + for name in ("premultiply_with_mask", "mask_edge_softness"): + k = node.knob(name) + if k is not None: + try: + k.setVisible(show_premult) + except Exception: + pass for i in range(1, MAX_COMP_SLOTS + 1): k = node.knob(f"comp_element_desc_{i}") if k is not None: @@ -1043,11 +1300,13 @@ def on_category_changed(node: Any) -> None: category = _current_category(node) if category == "custom": _set_prompt(node, "") + _clear_premultiply_if_not_create_mask(node) _apply_visibility(node) return presets = PRESET_CATEGORIES.get(category, []) if not presets: + _clear_premultiply_if_not_create_mask(node) _apply_visibility(node) return @@ -1074,6 +1333,7 @@ def on_category_changed(node: Any) -> None: except Exception: pass + _clear_premultiply_if_not_create_mask(node) _apply_visibility(node) @@ -1103,6 +1363,8 @@ def on_preset_changed(node: Any) -> None: targeting_active = True _set_prompt(node, prompt_text) + _clear_premultiply_if_not_create_mask(node) + _apply_visibility(node) auto_knob = node.knob("_auto_prompt") if auto_knob is not None: @@ -1124,6 +1386,24 @@ def on_target_changed(node: Any) -> None: _sync_comp_prompt(node, preset_token) +def _clear_premultiply_if_not_create_mask(node: Any) -> None: + """Clear the Premultiply with Mask toggle whenever the active preset is not a + mask preset (create_mask or create_soft_mask), so users don't get unpredictable + output after switching presets. Name kept for backward compat.""" + category = _current_category(node) + preset_token = _current_preset_token(node, category) + if category == "compositing" and preset_token in _MASK_PRESETS: + return + pkn = node.knob("premultiply_with_mask") + if pkn is None: + return + try: + if bool(pkn.value()): + pkn.setValue(False) + except Exception: + pass + + # --------------------------------------------------------------------------- # Knob changed dispatcher + run entry point # --------------------------------------------------------------------------- diff --git a/nuke/nodes/fibo_generate.py b/nuke/nodes/fibo_generate.py index 6f5b2fc..c08b79f 100644 --- a/nuke/nodes/fibo_generate.py +++ b/nuke/nodes/fibo_generate.py @@ -13,7 +13,7 @@ from adapter import fibo_generate_from_payload from bria_core.config import load_config, resolve_api_endpoint from bria_core.errors import BriaConfigError, BriaRequestError -from bria_core.utils import download_url, extract_image_url, normalize_aspect_ratio as _normalize_aspect_ratio, png_ihdr_info, resolve_proxies +from bria_core.utils import download_url, extract_image_url, extract_image_urls, normalize_aspect_ratio as _normalize_aspect_ratio, png_ihdr_info, resolve_proxies from common import ( is_batch_mode as _is_batch_mode, append_generation_history as _append_generation_history, @@ -261,6 +261,103 @@ def sync_preview(node: Any = None) -> None: _sync_group_preview(node) +def _variant_path_for(result_path: str, variant_idx: int) -> Optional[str]: + """Compute the path to variant N given the canonical result_path. + + After a multi-variant run, result_path holds the v01 file (e.g. `foo_v01.png`). + We strip the trailing `_vNN` suffix, then re-append `_v{idx:02d}`. + For idx=1, also accept the legacy single-variant filename (no suffix). + """ + if not result_path: + return None + root, ext = os.path.splitext(result_path) + m = re.match(r"^(.*)_v\d{2}$", root) + base = m.group(1) if m else root + if variant_idx <= 1: + candidate_v01 = f"{base}_v01{ext}" + if os.path.isfile(candidate_v01): + return candidate_v01 + return f"{base}{ext}" + return f"{base}_v{variant_idx:02d}{ext}" + + +def _apply_multiple_images_visibility(node: Any) -> None: + """Show/hide the entire multi-image control block (num_images + slider + button) + based on the use_multiple_images toggle. Visibility is tied to the toggle alone + so the user sees the controls exist even before running.""" + use_multi = bool(_opt_parm_bool(node, "use_multiple_images")) + for name in ("num_images", "result_variant", "use_this_image"): + try: + k = node.knob(name) + if k is not None: + k.setVisible(use_multi) + except Exception: + pass + + +def _read_variant_idx(node: Any) -> int: + """Read the Image Index slider value as an int (rounded), clamped to [1, count]. + + Nuke's Double_Knob slider does not constrain typed values to setRange — a user + can type 7 even when the slider visually goes 1..3. We enforce the bound here + by reading the hidden result_variant_count knob (set after each successful run). + """ + try: + knob = node.knob("result_variant") + if knob is None: + return 1 + raw = knob.value() + if raw is None: + return 1 + idx = int(round(float(raw))) + except (TypeError, ValueError): + idx = 1 + try: + count_knob = node.knob("result_variant_count") + max_count = int(count_knob.value()) if count_knob is not None else 1 + except (TypeError, ValueError): + max_count = 1 + if max_count < 1: + max_count = 1 + return max(1, min(idx, max_count)) + + +def _apply_result_variant(node: Any) -> None: + """When the Image Index slider moves, swap loader_result to the selected variant.""" + variant_idx = _read_variant_idx(node) + result_path = _opt_parm_str(node, "result_path") or "" + target = _variant_path_for(result_path, variant_idx) + if target and os.path.isfile(target): + try: + _set_internal_read_file(node, "loader_result", target) + _set_knob_value_safe(node, "output_view", "After") + except Exception: + pass + + +def on_use_this_image() -> None: + """Promote the currently-displayed variant to be the canonical result_path.""" + try: + node = nuke.thisNode() + except Exception: + return + if node is None: + return + variant_idx = _read_variant_idx(node) + result_path = _opt_parm_str(node, "result_path") or "" + target = _variant_path_for(result_path, variant_idx) + if not target or not os.path.isfile(target): + nuke.message( + f"Variant {variant_idx} not found on disk.\n\n" + f"Looked for: {target or ''}" + ) + return + _set_result_path(node, target) + _set_internal_read_file(node, "loader_result", target) + _set_knob_value_safe(node, "output_view", "After") + nuke.tprint(f"[Bria] Locked in variant {variant_idx}: {target}") + + def on_knob_changed() -> None: try: node = nuke.thisNode() @@ -325,6 +422,14 @@ def on_knob_changed() -> None: _sync_group_preview(node) return + if knob_name == "use_multiple_images": + _apply_multiple_images_visibility(node) + return + + if knob_name == "result_variant": + _apply_result_variant(node) + return + if knob_name in {"result_path", "use_global_project_path", "preset_path", "load_presets_from_examples"}: _sync_group_preview(node) _ensure_generation_preset_ui(node) @@ -363,6 +468,18 @@ def fibo_generate_bria(node: Any) -> None: tailored_model_influence = _opt_parm_float(node, "tailored_model_influence") sync = _opt_parm_bool(node, "sync") + # Multi-image generation: only active when the use_multiple_images toggle is on. + # When off, num_images = 1 regardless of what the (hidden) dropdown holds. + use_multiple_images = bool(_opt_parm_bool(node, "use_multiple_images")) + if use_multiple_images: + num_images_str = _opt_parm_menu_str(node, "num_images") or "2" + try: + num_images = max(2, min(4, int(num_images_str))) + except (TypeError, ValueError): + num_images = 2 + else: + num_images = 1 + # 4MP generation requires async processing; override sync knob. if resolution == "4MP": sync = False @@ -400,6 +517,8 @@ def fibo_generate_bria(node: Any) -> None: payload["steps_num"] = int(steps_num) if sync is not None: payload["sync"] = bool(sync) + if num_images > 1: + payload["num_results"] = num_images if pipeline.strip().lower() == "tailored": if not tailored_model_id: @@ -437,34 +556,119 @@ def fibo_generate_bria(node: Any) -> None: if resolution == "4MP": _update_progress(progress, "Generating (4MP, async — may take ~30s)...", 25) - data = fibo_generate_from_payload( - payload=payload, - pipeline=pipeline, - use_structured_prompt=use_structured, - api_key=None, - api_endpoint=None, - use_bearer=use_bearer_auth, - proxies=proxies, - session=None, - ) - _update_progress(progress, "Downloading result image...", 70) - - dl_url = extract_image_url(data) - if not dl_url: - raise BriaRequestError(f"Unexpected API response (no image_url): {data}") t_dl_start = time.perf_counter() - img_bytes, content_type = download_url(dl_url, timeout_s=300, proxies=proxies) - t_dl_end = time.perf_counter() - save_path = _resolve_result_save_path(out_path, content_type) - save_path = _timestamp_result_path(save_path, run_id) - _ensure_parent_dir(save_path) - with open(save_path, "wb") as f: - f.write(img_bytes) + # Multi-image generation: Bria's /v2/image/generate endpoint returns ONE image + # per call regardless of any batch field (confirmed empirically 2026-05-05). + # Per Bria's documented batch pattern, fire N parallel async calls, each + # returning a unique request_id; the BriaClient handles status_url polling. + # Per-variant seed offset gives actual variety when the user has set a seed. + if num_images > 1: + from concurrent.futures import ThreadPoolExecutor, as_completed + + base_payload = dict(payload) + base_payload["sync"] = False # async pattern: each call returns a status_url + base_seed = int(base_payload.get("seed") or 0) + + def _one_variant(variant_idx: int) -> tuple: + per_payload = dict(base_payload) + if base_seed > 0: + # Vary seed per call so variants are actually different. + per_payload["seed"] = base_seed + (variant_idx - 1) + resp = fibo_generate_from_payload( + payload=per_payload, + pipeline=pipeline, + use_structured_prompt=use_structured, + api_key=None, + api_endpoint=None, + use_bearer=use_bearer_auth, + proxies=proxies, + session=None, + ) + urls = extract_image_urls(resp) + if not urls: + raise BriaRequestError( + f"Variant {variant_idx}: API returned no image_url. Response: {resp}" + ) + img_bytes, content_type = download_url(urls[0], timeout_s=300, proxies=proxies) + base_save_path = _resolve_result_save_path(out_path, content_type) + base_save_path = _timestamp_result_path(base_save_path, run_id) + root, ext = os.path.splitext(base_save_path) + variant_save_path = f"{root}_v{variant_idx:02d}{ext}" + _ensure_parent_dir(variant_save_path) + with open(variant_save_path, "wb") as f: + f.write(img_bytes) + if not os.path.exists(variant_save_path) or os.path.getsize(variant_save_path) <= 0: + raise RuntimeError(f"Variant {variant_idx}: empty file at {variant_save_path}") + return variant_idx, variant_save_path, content_type, resp + + _update_progress( + progress, f"Generating {num_images} variants in parallel...", 30 + ) + + results_by_idx: dict = {} + errors: list = [] + completed = 0 + with ThreadPoolExecutor(max_workers=num_images) as executor: + futures = { + executor.submit(_one_variant, i): i for i in range(1, num_images + 1) + } + for fut in as_completed(futures): + variant_idx = futures[fut] + try: + idx, vpath, ctype, vresp = fut.result() + results_by_idx[idx] = (vpath, ctype, vresp) + except Exception as exc: + errors.append((variant_idx, exc)) + completed += 1 + _update_progress( + progress, + f"Variant {completed}/{num_images} done", + 30 + int(55 * completed / num_images), + ) + + if not results_by_idx: + # Every variant failed + first_err = errors[0][1] if errors else RuntimeError("All variants failed") + raise first_err + + sorted_keys = sorted(results_by_idx.keys()) + result_paths = [results_by_idx[k][0] for k in sorted_keys] + save_path = result_paths[0] + content_type = results_by_idx[sorted_keys[0]][1] + data = results_by_idx[sorted_keys[0]][2] + + if errors: + err_summary = ", ".join(f"v{i:02d}: {e}" for i, e in errors) + nuke.tprint(f"[Bria] Some variants failed: {err_summary}") + else: + # Single-image path: one call, one save (no _vNN suffix for backward compat). + data = fibo_generate_from_payload( + payload=payload, + pipeline=pipeline, + use_structured_prompt=use_structured, + api_key=None, + api_endpoint=None, + use_bearer=use_bearer_auth, + proxies=proxies, + session=None, + ) + _update_progress(progress, "Downloading result image...", 70) + dl_urls = extract_image_urls(data) + if not dl_urls: + raise BriaRequestError(f"Unexpected API response (no image_url): {data}") + img_bytes, content_type = download_url(dl_urls[0], timeout_s=300, proxies=proxies) + save_path = _resolve_result_save_path(out_path, content_type) + save_path = _timestamp_result_path(save_path, run_id) + _ensure_parent_dir(save_path) + with open(save_path, "wb") as f: + f.write(img_bytes) + if not os.path.exists(save_path) or os.path.getsize(save_path) <= 0: + raise RuntimeError(f"Bria download produced an empty file: {save_path}") + result_paths = [save_path] - if not os.path.exists(save_path) or os.path.getsize(save_path) <= 0: - raise RuntimeError(f"Bria download produced an empty file: {save_path}") + t_dl_end = time.perf_counter() def _apply_success_ui() -> None: try: @@ -478,6 +682,7 @@ def _apply_success_ui() -> None: "operation": "fibo_generate", "endpoint": resolve_api_endpoint(cfg), "result_path": save_path, + "result_paths": list(result_paths), "response": data, "settings": { "pipeline": pipeline, @@ -493,6 +698,7 @@ def _apply_success_ui() -> None: "structured_prompt": payload.get("structured_prompt"), "tailored_model_id": payload.get("tailored_model_id"), "tailored_model_influence": payload.get("tailored_model_influence"), + "num_images": num_images, }, }, ) @@ -503,6 +709,7 @@ def _apply_success_ui() -> None: "operation": "fibo_generate", "endpoint": resolve_api_endpoint(cfg), "result_path": save_path, + "result_paths": list(result_paths), "response": data, "settings": { "pipeline": pipeline, @@ -518,6 +725,7 @@ def _apply_success_ui() -> None: "structured_prompt": payload.get("structured_prompt"), "tailored_model_id": payload.get("tailored_model_id"), "tailored_model_influence": payload.get("tailored_model_influence"), + "num_images": num_images, }, }, ) @@ -525,6 +733,34 @@ def _apply_success_ui() -> None: _set_result_path(node, save_path) _set_knob_value_safe(node, "output_view", "After") _sync_group_preview_explicit(node, save_path, "After") + + # Update the Image Index slider range to match the ACTUAL number of + # variants saved. Visibility is owned by the toggle handler — we DO NOT + # touch it here. Bria's /v2/image/generate may quietly cap num_results, + # so we log what came back. + actual_count = len(result_paths) + if use_multiple_images: + nuke.tprint(f"[Bria] Saved {actual_count} variant(s) to disk") + if actual_count != num_images: + nuke.tprint( + f"[Bria] Requested {num_images} variants but API returned " + f"{actual_count}. The /image/generate endpoint may not " + f"honor num_results > 1 for this pipeline." + ) + try: + variant_knob = node.knob("result_variant") + if variant_knob is not None: + variant_knob.setRange(1, max(actual_count, 1)) + variant_knob.setValue(1) + count_knob = node.knob("result_variant_count") + if count_knob is not None: + count_knob.setValue(str(actual_count)) + # Re-apply toggle-driven visibility so the slider/button are shown + # whenever the toggle is on, regardless of how many came back. + _apply_multiple_images_visibility(node) + except Exception: + pass + _set_status(node, "done") except Exception as exc: _set_history_debug(node, f"success ui failed: {exc}")