Compare commits

..
Author SHA1 Message Date
Alisson AnjosandClaude Opus 5.5 160c884c3a Shot Planner: Colored Mark styles and optional MolmoPoint selection
Colored Mark can be drawn as the translucent overlay (as before), colored
hatching + contour, or a tinted normal map, reusing BFS Video Segment
Picker's drawing. The normal map takes MoGe normals from a new optional
`normals` input on both BFS Shot H3 Conditioning nodes. The prompt names the
style; overlay wording and auto-prompt fingerprints stay unchanged, and the
style only enters a shot's signature when it is not the default.

The planner gets an optional `point_model` input (MolmoPoint-8B). While it is
connected, a text selection is pointed by MolmoPoint and SAM 3 tracks from
that point; it falls back to SAM 3 text when nothing is found.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 13:06:57 +00:00
Alisson AnjosandClaude Opus 5.5 647b3212ca Simplify the Shot Planner panel and remove shot filters / face cast
Four tabs in working order (Video, Pictures & prompt, Shots, Run). The shot
editor's selection is one subject list under "What changes": Subject 1 is the
shot's own selection and, with Colored Mark on, every row picks the picture it
becomes; "+ Add subject" adds rows. The five mode cards are one Generate
dropdown. Rare settings are collapsed, labels and tooltips are shorter, and
the pt/es/zh dictionary drops its unused entries.

The shot filters and the face cast are removed from the panel and the server
(routes, detection and tests). Saved plans still load: a shot skipped by hand
opens disabled and the old settings are ignored. Bump to 1.122.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 12:07:58 +00:00
Alisson Anjos 8862f44812 feat: add automatic H3 image subjects, editable reference resize and model surgery 2026-11-12 16:04:25 +00:00
Alisson Anjos 9dcb9e4d08 Add H3 Image character swap nodes and MolmoPoint picker support 2026-11-10 21:15:50 +00:00
Alisson Anjos b29bb93dd4 Shorten registry description and release v1.119.5 2026-11-09 19:46:21 +00:00
Alisson Anjos 194690985a Improve video segment picker repair workflow 2026-11-09 18:30:04 +00:00
Alisson Anjos 0fc2f69257 feat(vsegpick): auto-unmark extra subjects, one-click clear button, and explicit Molmo text tracking (v1.119.4) 2026-11-09 16:08:24 +00:00
Alisson Anjos 93bcf3806d Merge branch 'feat/swap-guide-nodes' 2026-11-09 13:24:38 +00:00
Alisson Anjos a6cae2b9c1 feat(vsegpick,h3-swap): limit subjects and reference pictures to 3 (red, blue, green) (v1.119.3) 2026-11-09 13:07:25 +00:00
Alisson Anjos e6a9d384b7 fix(h3-swap,vsegpick): runtime MiniMaxH3 VRAM estimate/peak patch and two-pass DWPose rendering (v1.119.2) 2026-11-09 12:25:46 +00:00
Alisson Anjos 6d434b246e Fix multi-subject propagation & unload H3 conditioning models from VRAM (1.119.1)
- BFSVideoSegmentPicker: track Molmo2 strictly forward from first_pick,
  highlight the clicked person on frame 0, visually verify candidate
  re-anchors (Picture 1 crop vs Picture 2 crop + color histogram + non-overlap
  with already-claimed subjects), bound backward SAM 3 tracking to the
  previous strip frame with spatial/color continuity checks, and force GC +
  CUDA cache flush on _free.
- BFSH3KeyframeConditioning: unload TE and Video VAE from GPU after
  ReferenceToVideo and AddGuide so MiniMaxH3 has full VRAM during sampling.
- Add CHANGELOG.md and bump pyproject.toml to 1.119.1.
2026-11-09 09:57:24 +00:00
NRDX 9b41672085 Merge pull request #18 from alisson-anjos/feat/swap-guide-nodes
Character swap guide nodes: Video Segment Picker + H3 Character Swap Conditioning (1.119.0)
2026-11-09 02:09:10 +00:00
Alisson AnjosandClaude Opus 5.5 33fd830581 Add character swap guide nodes: Video Segment Picker, H3 Character Swap Conditioning (1.119.0)
- BFS Video Segment Picker (SAM 3): pick each subject on frames of a video, propagate
  shot by shot (camera cuts detected), optional Molmo2 relocation after cuts, per-shot
  and per-subject caches, status/log, whole-video scrubber with lost stretches, and a
  hybrid guide output (normal map, DWPose skeleton with face, texture-free styles).
  MoGe / DWPose / Molmo2 inputs are lazy (only on Propagate); models are unloaded
  before them and after the node.
- BFS H3 Character Swap Conditioning: keyframe pins, normal map guide or DWPose guide,
  Pictures 1-4 in color order, pictures list output.
- BFS Keyframe Planner, Keyframe Marks, Track From Frame, Workflow Loop, XY Sweep and
  Frame Loop helpers.
- Segment Picker: export Panel/styles for the video picker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 02:07:56 +00:00
NRDX 22bb235c1d Merge pull request #16 from alisson-anjos/fix/panel-inference-mode
Panel buttons run models under inference_mode (fixes #14)
2026-11-07 19:04:25 +00:00
46 changed files with 5956 additions and 1160 deletions
+137
View File
@@ -0,0 +1,137 @@
# Changelog
All notable changes to **ComfyUI-BFSNodes** are documented in this file.
## [1.122.0] - 2026-11-13
### Changed
- **Shot Planner panel reorganized**: four tabs in working order (Video → Pictures & prompt → Shots → Run). The shot editor's selection is one list under **What changes**: *Subject 1* is the shot's own selection and, with Colored Mark on, each row says which picture it becomes (**→ Picture N**); **+ Add subject** adds rows. The five generation-mode cards are one **Generate** dropdown. Rare settings (cut detector, frame grid, fps, VLM, picture descriptions, memory, second pass, mask settings) are collapsed. Shorter labels and tooltips; Portuguese, Spanish and Chinese updated.
### Added
- **Shot Planner Colored Mark styles**: *translucent* (as before), *hatching + contour* and *normal map*, the BFS Video Segment Picker styles. The normal map takes MoGe normals of the shot's frames from the new optional `normals` input of BFS Shot H3 Conditioning (both nodes). The prompt names the style of the marks.
- **Shot Planner MolmoPoint**: optional `point_model` input (MolmoPoint-8B). While connected, a text selection is pointed by MolmoPoint and SAM 3 tracks from that point, so phrases like "the man on the left" pick the right subject. Falls back to SAM 3 text when MolmoPoint finds nothing.
### Removed
- **Shot Planner shot filters and face cast**: *Analyse people & faces*, the person/face/dark/static filters, the per-shot *filters decide / always run / always skip* selector, *Find people*, *Split where the main person changes* and *Only run shots with a detected person*, with their server routes. Use **⏸ Disable** to turn a shot off. Saved plans still load: a shot skipped by hand opens disabled, and the old filter and cast settings are ignored.
## [1.121.0] - 2026-11-12
### Added
- **H3 Image Auto Subjects conditioning**: reads the selected colors and guide style from the Picker report, includes only the corresponding character references and generates a matching prompt. Supports non-contiguous colors, an optional full `prompt_override`, and a blocked first pass while the user makes selections.
- **Optional Reference Image**: an unused `[none]` slot skips loading/resizing. Active images use native proportional resizing with editable `megapixels` and `scale_method` controls (defaults: 4 MP, Lanczos).
- **H3 Image Swap Comparison**: shows the original, marked guide, active references with their Picture numbering, and result; unused reference branches remain lazy.
- **H3 Image Character Swap Prompt**: standalone prompt generation, including the new hatching format.
- **Model Surgery**: inspect model parameter names and shapes, filter layers, and apply reversible scaling rules by glob, regex or row range using native ModelPatcher patches. Includes a browser panel.
- **H3 PDD Surgery**: experimental independent scaling of the shared output head and PDD offset blocks, without changing sigmas or allocating full-size deltas. It does not undo distillation.
### Changed
- **Video Segment Picker**: adds colored hatching with a matching contour, editable line width/spacing and protected-object handling. The existing report output now includes the active subject IDs and mark style for automatic downstream conditioning.
- Register the new modules independently so unavailable optional features do not prevent the rest of BFSNodes from loading.
## [1.120.0] - 2026-11-10
### Added
- **BFS H3 Image Character Swap Conditioning**: native H3 Image conditioning and automatic still-image prompts. The guide is Picture 1; red, blue and green references are Pictures 2, 3 and 4. Supports normal maps, DWPose, overlays and outlines. Requires ComfyUI 0.45.0 or later.
- **Add Guide for MiniMax H3 Image (BFS)**: experimental frozen guide reference aligned to the target image positions through a cloned ModelPatcher. Uses native H3 Image latents without an audio stream.
- **Video Segment Picker**: optional `point_model` input and MolmoPoint-8B tracking method. Connect the loader from ComfyUI-Molmo2-H3.
- Protected-object selections, a normal-map-inside-mask mode with feathered edges, translucent overlays and colored outlines.
### Fixed
- Bound picker mask caches by memory size, keep cached masks as boolean tensors and preserve existing masks when filling empty stretches.
- Track empty stretches and nearest-anchor windows instead of repeatedly recomputing the full clip.
- Correct SAM inference tensor handling for point edits, rendering invalidation, normal-map lazy input requests and Apply fix messages.
- Stabilize compare preview sizing; expose memory cleanup after comparison.
## [1.119.5] - 2026-11-09
### Changed
- **BFS Video Segment Picker (`BFSVideoSegmentPicker`)**: Restored the classic Molmo2 full-video text tracking as the default, with the current SAM 3 tracking available in a dropdown.
- Added repair scopes for one frame, the active frame onward, recomputing all frames, and filling only empty masks. Filling empty masks is the default and preserves existing non-empty results.
- Preserve the last propagated preview while adding selections, disable auto-propagation by default, and hide the manual eraser in the video picker while retaining SAM-guided remove points.
- Shortened the package description to fit Comfy Registry metadata validation.
## [1.119.4] - 2026-11-09
### Added
- **BFS Video Segment Picker (`BFSVideoSegmentPicker`)**:
- **Automatic Unmarking / Subject Limiting (`max_subjects`)**: Added `"max_subjects"` parameter (`["auto", "1", "2", "3"]`, default `"auto"`). In `"auto"` mode, the node dynamically inspects the execution workflow graph (`prompt`) to count connected reference pictures (`picture_N`) on downstream conditioning nodes (e.g. `BFSH3KeyframeConditioning`). If only 2 pictures are connected, Subject 3 (`3 · green`) is automatically excluded from tracking, masking, and hybrid guide rendering, preventing unmatched green skeletons or normal maps from reaching MiniMax H3.
- **One-Click Subject Clear (`✕`)**: Added a clear button `✕` on each subject badge (`1 · red`, `2 · blue`, `3 · green`) in the UI header. Clicking it instantly removes all picks for that subject across all frames and triggers auto-propagation.
- **Explicit Text Tracking (`molmo_descriptions`)**: Added optional `"molmo_descriptions"` multiline input (line $k$ = Subject $k$). When provided, Molmo2 tracks using the exact text descriptions across camera cuts and shots instead of guessing from low-resolution crops, avoiding tracking errors.
---
## [1.119.3] - 2026-11-09
### Changed
- **BFS Video Segment Picker (`BFSVideoSegmentPicker`) & BFS H3 Character Swap Conditioning (`BFSH3KeyframeConditioning`)**:
- Limited selectable subjects and reference pictures to 3 (`1 · red`, `2 · blue`, `3 · green` / `picture_1`..`picture_3`) to match the 3-subject training distribution of the Normal Map and DWPose Character Swap LoRAs.
---
## [1.119.2] - 2026-11-09
### Fixed
- **BFS H3 Character Swap Conditioning (`BFSH3KeyframeConditioning`)**:
- Added runtime VRAM estimation and peak activation patching (`_patch_vram_estimate`) onto the `MiniMaxH3` model instance so ComfyUI accounts for `minimax_keyframes` (`guide_video`), `minimax_refs` (`Picture 1`–`Picture 4`), and `cross_attn` tokens when computing `minimum_memory_required`, and releases intermediate `q, k, v` and `h` buffers early in `DiTBlock` / `Attention` without modifying ComfyUI core.
- Cleared cached DWPose detector/pose sessions (`global_cached_dwpose`) safely in `_free_gpu()` without triggering lazy-import errors on unrelated modules in `sys.modules`.
- **BFS Video Segment Picker (`BFSVideoSegmentPicker`)**:
- Separated multi-subject DWPose guide rendering into two passes (erase all subject regions to neutral gray first, then draw all subject skeletons on top) so overlapping gray regions of adjacent/hugging subjects never erase each other's skeletons.
- Added per-frame DWPose matching exclusion (`exclude`), relaxed overlap fallback (`min_inside=0.25`), and neighbor-frame pose fallback (`±2` frames) so closely interacting subjects do not lose their skeleton on intermediate frames.
---
## [1.119.1] - 2026-11-09
### Fixed
- **BFS Video Segment Picker (`BFSVideoSegmentPicker`)**:
- Prevented subjects picked on later frames from being falsely propagated or re-anchored onto other people in earlier frames before `first_pick`.
- Restricted Molmo2 tracking (`molmo_relocate`) to search strictly forward from `first_pick` (`image[first_pick:]`), highlighting the selected person on the initial frame (`t=0.0s`) and dims background distractions.
- Added visual candidate verification (`molmo_verify_candidate`): compares the reference person crop (`Picture 1`) against each candidate person crop (`Picture 2`) with Molmo2, checks RGB/chroma histogram similarity (`person_color_sim`), and rejects candidates that overlap masks already claimed by other subjects (`claimed_at`).
- Bounded backward SAM 3 tracking before `first_pick` to the immediately preceding strip frame (`prev_strip`) with frame-to-frame spatial (`IoU >= 0.15`), non-overlap, and color continuity checks.
- Enforced `gc.collect()` and `mm.soft_empty_cache(True)` when freeing GPU models after segmentation/tracking.
- **BFS H3 Character Swap Conditioning (`BFSH3KeyframeConditioning`)**:
- Added `_free_gpu()` to explicitly unload `MiniMaxH3TEModel_` (15 GB) and `MiniMaxH3VideoVAE` (5 GB) and flush the CUDA cache after `MiniMaxH3ReferenceToVideo` and `MiniMaxH3AddGuide`, preventing partial-unload VRAM retention and CUDA OOM when `MiniMaxH3` (20 GB) starts sampling.
---
## [1.119.0] - 2026-11-09
### Added
- **BFS Video Segment Picker (`BFSVideoSegmentPicker`)**:
- Interactive multi-subject (1–4) SAM 3.1 video segmentation and shot-by-shot propagation with automatic camera cut detection.
- Optional Molmo2 re-anchoring across camera cuts and lost stretches, with per-shot and per-subject caching.
- Interactive timeline scrubber displaying per-frame subject coverage and lost stretches.
- Hybrid guide video output supporting `normal map`, `openpose + face` (DWPose skeleton + face landmarks over neutral gray), and texture-free styles (`flat color`, `shading`, `blur`, `pixelate`, `gray detail`, `edges`).
- Configurable `pose_region` (`convex hull` or `person outline`) and `pose_gray_opacity`.
- **BFS H3 Character Swap Conditioning (`BFSH3KeyframeConditioning`)**:
- Unified conditioning node supporting `keyframe pins`, `normal map guide`, and `DWPose guide` modes with up to 4 reference pictures (`Picture 1`–`Picture 4`) mapped to red, blue, green, and yellow subject marks.
- **Keyframe & Loop Helpers**:
- Added `BFSKeyframePlanner`, `BFSKeyframeMarks`, `BFSTrackFromFrame`, `BFSWorkflowLoop`, `BFSXYSweep`, and `BFSFrameLoop`.
---
## [1.118.1] - 2026-11-07
### Fixed
- **Panel Server Routes**: Wrapped interactive panel button model executions under `torch.inference_mode()` to match standard ComfyUI node execution.
---
## [1.118.0] - 2026-11-04
### Added
- **BFS Segment Picker (`BFSSegmentPicker`)**: Added interactive brush strokes, negative/remove points, and eraser mode on the segmentation canvas.
---
## [1.117.0] - 2026-11-03
### Added
- **BFS Video Compare (`BFSVideoCompare`)**: Added current-frame PNG download button in the comparison UI and automatic PNG output when comparing single images.
---
## [1.116.0] - 2026-11-03
### Added
- **BFS Segment Picker (`BFSSegmentPicker`)**: Interactive SAM 3.1 object picker canvas with automatic concept detection, text search, and point prompts.
+72 -9
View File
@@ -46,9 +46,8 @@ cd ComfyUI-BFSNodes
pip install -r requirements.txt
```
Optional: `pip install insightface` with `onnxruntime` or `onnxruntime-gpu`, only for LTX Identity. The Shot Planner's
*Find people* / Cast no longer needs it (1.93.0): it uses OpenCV's YuNet + SFace (Apache-2.0, downloaded once), and uses
InsightFace only when it is installed. They are not in the requirements because insightface pins packages
Optional: `pip install insightface` with `onnxruntime` or `onnxruntime-gpu`, only for LTX Identity. It is not in the
requirements because insightface pins packages
that conflict with many environments; without them everything else works and those features say what to install.
Restart ComfyUI after installation.
@@ -146,18 +145,13 @@ workflow once per shot, and join the results back with the original timing and s
- **BFS Shot Join** — concatenates, trims to the true lengths, cross-fades soft joins, returns audio. With `comparison` on, its `guide` output is the original video frame for frame.
- **BFS Video Compare** — one player for two videos (Join's `guide` and `images`): wipe ↔ / ↕, overlay or side by side, any of guide / target / difference on top, plus a per-pixel difference map and the difference per frame (click to jump). 🖼 PNG downloads the frame on screen as shown; with two images (one frame) ⬇ save PNG downloads it from lossless PNGs at the compared size.
**Cast (1.49.0)**: *Find people* tracks faces through the video (InsightFace) and groups them
into people. Link each person to a reference and every shot uses the reference of the person
with the most screen time in it; optionally split where the main person changes and run only the
shots where a linked person appears.
Two run modes: *auto loop* (all shots in one run) and *queue loop* (one shot per run, stored on
disk, re-queued automatically; nodes after the join only run on the last shot). See
[`web/js/docs/BFSShotPlanner.md`](web/js/docs/BFSShotPlanner.md).
Shots can also be cropped to a **SAM 3 mask** (text or points per shot) and pasted back by the join, continue
from the previous shot's result, and get **VLM suggestions** (what to segment, a shot description for the
prompt, run/skip) from a Qwen3-VL connected to the planner.
prompt) from a Qwen3-VL connected to the planner. It works for people, objects and vehicles alike.
## H3 Duet / Side Panel (1.50.0)
@@ -272,3 +266,72 @@ paints what to cut out. Each becomes a chip; unselect it to switch it off. Brush
eraser strokes belong to the image they were painted on and are dropped when the image
changes. Fixed: clicking inside the node no longer shrinks its canvas while the side
panel is open.
### Character swap with marked guides (1.119.0)
Mark who is replaced in the guide video, then condition MiniMax H3 Character Swap on it.
**BFS Video Segment Picker (SAM 3)** — the Segment Picker for a video. A strip shows
frames of the batch; click a frame, pick the subject tab (1 red, 2 blue, 3 green,
4 yellow = Picture 1–4) and click the person. **↔ Propagate** tracks every subject from
its picked frames to the whole video with SAM 3, shot by shot (camera cuts are detected
and never tracked across). Optional `molmo_clip` (Molmo2 H3 encoder): Molmo2 describes
the picked person, follows them through the video and re-anchors shots that have no
pick, so a person is found again after a cut. Picking runs only segment the strip;
tracking, Molmo2, MoGe and DWPose run on Propagate. Repeated runs reuse what did not
change (per subject and per shot). After the first Propagate, picking on a frame
propagates by itself (**auto**). The **Whole video** scrubber shows every frame with
its masks, the lost stretches in red; **+ add this frame** puts any frame in the strip
to fix it. A status line with a progress bar and a log shows what runs.
Outputs: `masks`, `marked` (tinted), `overlay`, `report`, and `hybrid`: the guide
with each subject drawn in its color, by `mark_style`:
- `normal map` — its MoGe normal map in gray detail, tinted (connect `normals`:
Run MoGe Inference → Render MoGe Geometry, a normal mode);
- `openpose + face` — erased to gray (`pose_region`: convex hull or person outline,
`pose_gray_opacity`) and drawn as its DWPose skeleton with the eyes and the mouth
opening (connect `pose_keypoint`: DWPose Estimator with body, hands and face);
- texture-free alternatives: flat color, shading, blur, pixelate, gray detail, edges.
`hybrid_grow` sets the margin around each subject; `hybrid_base` stacks pickers.
`free_after` unloads the models from the GPU when the node is done (default: all).
**BFS H3 Character Swap Conditioning** — `mode`:
- `keyframe pins`: the source video plus one Add Guide per edited keyframe at its own
frame (`keyframes` + `keyframe_spec` from BFS Keyframe Planner, or `keyframe_N` /
`time_N`). `keyframe_until` drops the keyframes (or every guide) below that sigma on
the returned `model`.
- `normal map guide` / `DWPose guide`: the Video Segment Picker's `hybrid` as the guide.
Pictures 1–4 are the references in color order; the prompt is written from the mode
and the number of Pictures (or `prompt_override`). Outputs: model, positive, latent,
prompt, keyframe_video and `pictures` (the Pictures as a list, e.g. for BFS Video
Compare's `reference`).
Also: **BFS Keyframe Planner** (choose the keyframe frames on the video and export
them with their spec), **BFS Keyframe Marks** / **BFS Track From Frame (SAM 3)** (a
tracked person's mask at each keyframe), **BFS Workflow Loop** (run a part of the
graph once per item, injecting the item and changing widgets per iteration),
**BFS XY Sweep** (a grid of any two widgets' values) and **BFS Frame Loop Start/End**.
### MiniMax H3 Image character swap
**Auto Subjects:** connect the Picker's `hybrid` and `report` outputs to **BFS H3 Image Character Swap • Auto Subjects**, then connect references to the fixed red, blue and green slots. The node reads the actual selected colors; no manual subject count is needed. The guide is Picture 1 and active references follow from Picture 2 in color order. Blue or green may be used without red. Leave `prompt_override` empty for the generated prompt, or provide a complete replacement prompt; reference selection remains automatic.
**BFS Optional Reference Image** loads a reference or returns no image for `[none]`. Both the target `megapixels` and interpolation `scale_method` are editable; 4 MP + Lanczos are defaults. Aspect ratio is preserved, and empty references skip resizing. Native H3 encoding still applies its own reference-size limits.
The Picker's **colored hatching + contour** mode preserves the source pixels between colored lines, with `hatch_width` and `hatch_spacing` controls. MoGe is not executed for hatching. For normal-map guidance, connect rendered MoGe normals to the Picker and keep its normal convention consistent. The automatic prompt follows the Picker mode. **BFS H3 Image Swap Comparison** shows only active references and labels their actual Picture numbers.
Requires **ComfyUI 0.45.0+** and the H3 Image model, image VAE and matching text encoder.
Use **BFS H3 Image Character Swap Conditioning** with the Picker's single-image `hybrid` as `guide_image`. Connect character references in red, blue and green order. The generated prompt treats the guide as **Picture 1** and the characters as **Pictures 2–4**. Connect `positive` and `latent` to the sampler; the node also outputs the prompt for inspection. Normal map, DWPose, translucent overlay and outline guides are supported.
**Add Guide for MiniMax H3 Image (BFS)** is an optional experimental alignment step. Connect the model, positive conditioning, native image latent, image VAE and marked guide; use its returned MODEL and positive with the sampler. It aligns the guide's frozen reference positions to the target canvas. It does not use the video/audio Add Guide latent format. Structural checks passed; visual equivalence to a video-trained guide LoRA is not guaranteed.
For **MolmoPoint-8B** in Video Segment Picker, connect `point_model` from the loader in [ComfyUI-Molmo2-H3](https://github.com/alisson-anjos/ComfyUI-Molmo2-H3), select `MolmoPoint-8B` in `propagation_method`, and use **Apply fix** with the desired repair scope. SAM 3 still produces the masks.
### Model surgery
**BFS Model Surgery** takes a MODEL and returns a cloned MODEL with reversible weight scaling. Execute once to populate the layer panel; filter names and add scale rules. Rules are a JSON list using `pattern` (glob by default, or `regex: true`), `scale`, optional `enabled`, and optional `rows: [start, exclusive_stop]`. Overlapping rules multiply in list order. Empty rules preserve the model. Connect after LoRA loaders to scale the materialized weights, including their preceding patches. This scales the model weights themselves, rather than just the LoRA delta; CLIP and VAE are separate.
**BFS H3 PDD Surgery** provides experimental shared-base and per-offset controls for native H3 PDD output heads. All strengths at 1 preserve the original model. Setting offsets to zero does **not** recover a pre-distillation checkpoint, and sigmas stay unchanged.
+14
View File
@@ -227,6 +227,20 @@ NODE_DISPLAY_NAME_MAPPINGS = {
**SEGPICK_NODE_DISPLAY_NAME_MAPPINGS,
}
# Character swap with marked guides (Video Segment Picker -> H3 Character Swap Conditioning), keyframe planning, and
# the loop / sweep helpers. Each module loads on its own: a missing optional dependency only skips that module.
for _mod in ("bfs_keyframe_marks", "bfs_video_segment_picker", "bfs_h3_swap_conditioning", "bfs_keyframe_planner",
"bfs_frame_loop", "bfs_xy_sweep", "bfs_workflow_loop", "bfs_h3_image_swap_prompt",
"bfs_h3_image_swap_conditioning", "bfs_h3_image_swap_tools", "bfs_model_surgery",
"bfs_h3_pdd_surgery"):
try:
import importlib as _il
_m = _il.import_module(f".{_mod}", __package__)
NODE_CLASS_MAPPINGS.update(_m.NODE_CLASS_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(_m.NODE_DISPLAY_NAME_MAPPINGS)
except Exception as _e: # noqa: BLE001
print(f"[BFSNodes] {_mod} not loaded: {_e!r}")
WEB_DIRECTORY = "./web/js"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
+136
View File
@@ -0,0 +1,136 @@
"""BFS Frame Loop: run any part of a workflow once per frame, one after another, and collect the results.
Wire a batch of frames (e.g. the Keyframe Planner's source_frames, or BFS Keyframe Marks' marked_frames) into
BFS Frame Loop Start; it gives one frame (plus its mask, index and count). Build the per-frame work after it (an image
edit: Qwen 2.1 body swap, Klein, ...), and wire its result into BFS Frame Loop End. The End node expands the body
between Start and End again for the next frame until all are done, then returns them as one batch (same order).
Models stay outside the loop body. Results are kept on disk between iterations; no global state.
The expansion follows the BFS H3 Native Shot Loop pattern (start/end graph expansion).
"""
import json
import os
import uuid
from pathlib import Path
import torch
import torch.nn.functional as F
try:
import folder_paths
except Exception: # tests run without ComfyUI
folder_paths = None
CATEGORY = "BFS/Loop"
class BFSFrameLoopStart:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"images": ("IMAGE", {"tooltip": "The frames to process one by one."})},
"optional": {"masks": ("MASK", {"tooltip": "One mask per frame (optional), handed out with its frame."}),
"loop_state": ("BFS_FRAME_LOOP", {"tooltip": "Set by Frame Loop End; leave unconnected."})}}
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "BFS_FRAME_LOOP")
RETURN_NAMES = ("image", "mask", "index", "count", "loop_state")
OUTPUT_TOOLTIPS = ("The current frame.", "Its mask (zeros when no masks are given).", "Index of the current frame (0-based).",
"How many frames the loop runs.", "Connect to BFS Frame Loop End.")
FUNCTION = "start"
CATEGORY = CATEGORY
DESCRIPTION = "Hands out one frame per loop iteration; pair it with BFS Frame Loop End."
def start(self, images, masks=None, loop_state=None):
n = int(images.shape[0])
if loop_state is None:
temp = folder_paths.get_temp_directory() if folder_paths else "."
folder = os.path.join(temp, "bfs_frame_loop", uuid.uuid4().hex[:12])
os.makedirs(folder, exist_ok=True)
loop_state = {"folder": folder, "index": 0, "count": n, "done": []}
state = dict(loop_state)
if state["count"] != n:
raise ValueError(f"The loop started with {state['count']} frames but now gets {n}.")
i = int(state["index"])
h, w = images.shape[1:3]
if masks is not None:
m = masks if masks.ndim == 3 else masks[None]
if m.shape[0] != n:
raise ValueError(f"{m.shape[0]} masks for {n} frames.")
mask = m[i:i + 1]
if mask.shape[1:] != (h, w):
mask = F.interpolate(mask[:, None].float(), size=(h, w), mode="bilinear", align_corners=False)[:, 0]
else:
mask = torch.zeros(1, h, w)
print(f"[BFS Frame Loop] frame {i + 1}/{n}", flush=True)
return images[i:i + 1], mask, i, n, state
class BFSFrameLoopEnd:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"image": ("IMAGE", {"tooltip": "The processed frame of this iteration."}),
"loop_state": ("BFS_FRAME_LOOP", {"tooltip": "From BFS Frame Loop Start."})},
"optional": {"resize_to_first": ("BOOLEAN", {"default": True,
"tooltip": "Resize every result to the first result's size so they form one batch."})},
"hidden": {"dynprompt": "DYNPROMPT", "unique_id": "UNIQUE_ID"}}
RETURN_TYPES = ("IMAGE", "INT")
RETURN_NAMES = ("images", "count")
OUTPUT_TOOLTIPS = ("All processed frames, in order, as one batch.", "How many frames.")
FUNCTION = "finish"
CATEGORY = CATEGORY
DESCRIPTION = "Collects one processed frame per iteration and expands the loop body until all frames are done."
def finish(self, image, loop_state, resize_to_first=True, dynprompt=None, unique_id=None):
state = dict(loop_state)
i = int(state["index"])
path = Path(state["folder"]) / f"{i:05d}.pt"
tmp = path.with_suffix(".tmp"); torch.save(image[:1].detach().cpu().contiguous(), tmp); tmp.replace(path)
state["done"] = list(state["done"]) + [str(path)]
state["index"] = i + 1
if state["index"] < state["count"]:
if dynprompt is None:
raise ValueError("BFS Frame Loop needs ComfyUI's dynamic graph execution")
return self.expand(dynprompt, str(unique_id), state)
parts = [torch.load(p, map_location="cpu", weights_only=True) for p in state["done"]]
if resize_to_first:
h, w = parts[0].shape[1:3]
parts = [p if p.shape[1:3] == (h, w) else
F.interpolate(p.movedim(-1, 1), size=(h, w), mode="bilinear", align_corners=False).movedim(1, -1) for p in parts]
if len({tuple(p.shape[1:]) for p in parts}) != 1:
raise ValueError("The processed frames have different sizes; turn on resize_to_first.")
(Path(state["folder"]) / "manifest.json").write_text(json.dumps(state, indent=1))
return torch.cat(parts), len(parts)
@staticmethod
def expand(dynprompt, end, state):
from comfy_execution.graph_utils import GraphBuilder, is_link
feeds, seen = {}, set()
def visit(nid):
if nid in seen:
return
seen.add(nid)
for value in dynprompt.get_node(nid).get("inputs", {}).values():
if is_link(value):
parent = str(value[0]); feeds.setdefault(parent, []).append(nid); visit(parent)
visit(end)
starts = [n for n in seen if dynprompt.get_node(n)["class_type"] == "BFSFrameLoopStart"]
if len(starts) != 1:
raise ValueError("Connect exactly one BFS Frame Loop Start upstream of this Frame Loop End")
start = starts[0]; body = {start}; pending = [start]
while pending:
for child in feeds.get(pending.pop(), []):
if child not in body:
body.add(child); pending.append(child)
graph = GraphBuilder()
clones = {n: graph.node(dynprompt.get_node(n)["class_type"], "Next" if n == end else n) for n in body}
for nid, node in clones.items():
node.set_override_display_id(dynprompt.get_display_node_id(nid))
for key, value in dynprompt.get_node(nid).get("inputs", {}).items():
node.set_input(key, clones[str(value[0])].out(value[1]) if is_link(value) and str(value[0]) in body else value)
clones[start].set_input("loop_state", state)
return {"result": tuple(clones[end].out(i) for i in range(2)), "expand": graph.finalize()}
NODE_CLASS_MAPPINGS = {"BFSFrameLoopStart": BFSFrameLoopStart, "BFSFrameLoopEnd": BFSFrameLoopEnd}
NODE_DISPLAY_NAME_MAPPINGS = {"BFSFrameLoopStart": "BFS Frame Loop Start", "BFSFrameLoopEnd": "BFS Frame Loop End"}
+81
View File
@@ -0,0 +1,81 @@
"""Aligned image guide for the native MiniMax H3 Image reference stream."""
import copy
import comfy.utils
from comfy_api.latest import io
MARKER = 'bfs_h3_image_guide'
def align_guide_layout(layout, refs):
result = copy.copy(layout)
result.position_ids = layout.position_ids.clone()
target = next((a, b) for a, b, kind in layout.segments if kind == 'video')
image_refs = iter(ref for ref in refs if ref['kind'] == 'image')
for a, b, kind in layout.segments:
if kind != 'ref_img':
continue
ref = next(image_refs)
if ref.get(MARKER):
ta, tb = target
if b - a != tb - ta:
raise ValueError('H3 Image guide must match the target canvas')
result.position_ids[a:b] = layout.position_ids[ta:tb]
return result
def patch_image_guide(model):
patched = model.clone()
if patched.model_options.get(MARKER):
return patched
previous = patched.model_options.get('model_function_wrapper')
def wrapper(model_function, args):
conds = args['c']
payload = conds.get('minimax_payload', {})
refs = payload.get('refs', [])
if any(ref.get(MARKER) for ref in refs):
layout = align_guide_layout(payload['layout'], refs)
args = dict(args, c=dict(conds, minimax_payload=dict(payload, layout=layout)))
if previous is not None:
return previous(model_function, args)
return model_function(args['input'], args['timestep'], **args['c'])
patched.set_model_unet_function_wrapper(wrapper)
patched.model_options[MARKER] = True
return patched
class BFSH3ImageAddGuide(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(node_id='BFSH3ImageAddGuide', display_name='Add Guide for MiniMax H3 Image (BFS)',
category='BFS/MiniMax H3',
description='Experimental aligned guide for H3 Image. Uses a frozen image reference with target-aligned positions. Connect the returned MODEL and positive to the sampler; use the native H3 Image latent.',
inputs=[io.Model.Input('model'), io.Conditioning.Input('positive'), io.Vae.Input('vae'),
io.Image.Input('guide'), io.Latent.Input('latent')],
outputs=[io.Model.Output(), io.Conditioning.Output(display_name='positive')])
@classmethod
def execute(cls, model, positive, vae, guide, latent):
samples = latent['samples']
if samples.is_nested or samples.ndim != 5 or samples.shape[1:3] != (24, 1):
raise ValueError('Use the LATENT from MiniMax H3 Image Conditioning or Empty MiniMax H3 Image Latent')
if model.model.diffusion_model.__class__.__name__ != 'MiniMaxH3ImageModel':
raise ValueError('This guide requires the native MiniMax H3 Image model')
h, w = samples.shape[-2:]
pixels = comfy.utils.common_upscale(guide[:1, ..., :3].movedim(-1, 1), w * 16, h * 16, 'bicubic', 'center').movedim(1, -1)
z = vae.encode(pixels)
if z.ndim != 5 or tuple(z.shape[1:]) != (24, 1, h, w):
raise ValueError('Guide VAE must produce native H3 Image latents matching the target')
guide_ref = {'kind': 'image', 'latent_h': h, 'latent_w': w, 'latent': z, MARKER: True}
out = []
for cond, options in positive:
refs = [r for r in options.get('minimax_refs', []) if not r.get(MARKER)]
updated = dict(options, minimax_refs=refs + [guide_ref])
out.append([cond, updated])
return io.NodeOutput(patch_image_guide(model), out)
NODE_CLASS_MAPPINGS = {'BFSH3ImageAddGuide': BFSH3ImageAddGuide}
NODE_DISPLAY_NAME_MAPPINGS = {'BFSH3ImageAddGuide': 'Add Guide for MiniMax H3 Image (BFS)'}
+168
View File
@@ -0,0 +1,168 @@
"""Native MiniMax H3 Image conditioning with color-marked character references."""
from comfy_api.latest import io
from comfy_extras.nodes_minimax_h3 import MiniMaxH3ImageConditioning
COLORS = ('red', 'blue', 'green')
MODES = ('normal map guide', 'DWPose guide', 'translucent overlay guide', 'outline guide', 'colored hatching + contour')
def hatching_swap_prompt(count):
definitions = ' '.join(
f'<Subject {i+1}> is identified by the {color} hatching lines and matching {color} contour in <Picture 1>; '
f'its complete appearance comes from <Picture {i+2}>.'
for i, color in enumerate(COLORS[:count]))
return (
'character_swap: subject_definitions: <Picture 1> is the source image. Colored hatching lines and matching contours identify the subjects to replace; '
'the visible pixels between the lines provide pose, expression, lighting and scene context. ' + definitions
+ ' summary: [image editing + reference generation] Replace each marked subject with its assigned reference character. '
'Remove all colored lines and contours from the result. retention_analysis: Transfer the face, hair, skin, body proportions and complete outfit '
'from each assigned reference. Preserve the source pose, hand positions, head orientation, gaze and facial expression. '
'Adapt each replacement to the source lighting, shadows and exposure. Do not copy the pose or lighting of the reference. '
'Do not retain the original marked subject\'s identity or clothing. Preserve every unmarked person, the background, held objects and foreground occluders. '
'The hatching identifies who changes and does not constrain the replacement silhouette.')
def image_swap_prompt(count, mode):
if mode not in MODES:
raise ValueError(f'Unknown guide mode: {mode}')
if not 1 <= count <= len(COLORS):
raise ValueError('Connect one to three character reference images')
if mode == 'colored hatching + contour':
return hatching_swap_prompt(count)
marks = {
'normal map guide': ('normal map', 'each subject to replace is marked with its surface-normal map colorized in one solid color (its 3D surface, without its real colors)'),
'DWPose guide': ('pose skeleton', 'each subject to replace is shown as its pose skeleton (body, hands and face) drawn in one color'),
'translucent overlay guide': ('translucent overlay', 'each subject to replace is identified by a translucent color overlay; the appearance beneath the tint is the source appearance to replace'),
'outline guide': ('outline', 'each subject to replace is identified by a thin colored outline around that subject'),
}
mark, explanation = marks[mode]
definitions = '\n'.join(
f'<Subject {j}> is the subject marked by the {COLORS[j-1]} {mark} in <Picture 1>; its appearance comes from <Picture {j+1}>, its position, pose, head orientation, gaze and facial expression from the {COLORS[j-1]} mark.'
for j in range(1, count+1))
replacements = ', '.join(f'the {COLORS[j-1]} subject replaced by <Picture {j+1}>' for j in range(1, count+1))
retention = '\n'.join(
f'<Subject {j}>: attribute_transfer - keeps the position, pose, head orientation, gaze and facial expression of the {COLORS[j-1]} mark and takes the face, hair, body and whole outfit of <Picture {j+1}>.'
for j in range(1, count+1))
return (
'character_swap:\nsubject_definitions:\n'
f'<Picture 1> is the source of the edit. In it, {explanation}; the mark says who changes, not where the new look must end, and everything else in the guide is the original image.\n'
f'{definitions}\n'
f'summary: [image editing + reference generation] The target image is <Picture 1> with {replacements}, following each mark\'s pose and expression. '
'Every other person, the layout, the background, lighting, camera perspective and framing stay exactly as in <Picture 1>. '
'Keep held objects and foreground occluders in their original positions, shapes and colors; they are separate from the subjects being replaced. '
'Remove the normal-map colors, skeletons, overlays and outlines from the final image.\n'
f'retention_analysis:\n{retention}\n'
'Everyone else, held objects, foreground occluders, the layout and the background: fully_preserved.')
class BFSH3ImageCharacterSwapConditioning(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id='BFSH3ImageCharacterSwapConditioning',
display_name='BFS H3 Image Character Swap Conditioning',
category='BFS/MiniMax H3',
description='Native H3 Image character swap: the marked guide is Picture 1; red, blue and green subjects take their appearance from Pictures 2, 3 and 4. Produces native image reference latents and a still-image prompt.',
inputs=[
io.Clip.Input('clip'), io.Vae.Input('vae'),
io.Image.Input('guide_image', tooltip='Marked hybrid image from BFS Video Segment Picker; becomes Picture 1.'),
io.Autogrow.Input('references', template=io.Autogrow.TemplatePrefix(
input=io.Image.Input('reference', tooltip='Character appearance in color order: red, blue, green; becomes Picture 2, 3, 4.'),
prefix='reference_', min=1, max=3)),
io.Combo.Input('mode', options=MODES, default=MODES[0]),
io.Int.Input('width', default=0, min=0, max=16384, step=32, tooltip='0 uses the native H3 Image sizing based on the guide.'),
io.Int.Input('height', default=0, min=0, max=16384, step=32),
io.String.Input('prompt_override', default='', multiline=True, optional=True,
tooltip='Optional complete replacement prompt. Guide remains Picture 1; character references start at Picture 2.'),
],
outputs=[io.Conditioning.Output(display_name='positive'), io.Latent.Output(display_name='latent'),
io.String.Output(display_name='prompt')])
@classmethod
def execute(cls, clip, vae, guide_image, references, mode=MODES[0], width=0, height=0, prompt_override=''):
names = sorted(references, key=lambda name: int(name.rsplit('_', 1)[-1]))
pictures = [references[name] for name in names if references[name] is not None]
prompt = prompt_override.strip() or image_swap_prompt(len(pictures), mode)
images = {'image_1': guide_image}
images.update({f'image_{j}': picture for j, picture in enumerate(pictures, 2)})
positive, latent = MiniMaxH3ImageConditioning.execute(
clip=clip, vae=vae, prompt=prompt, width=width, height=height, images=images).args[:2]
return io.NodeOutput(positive, latent, prompt)
NODE_CLASS_MAPPINGS = {'BFSH3ImageCharacterSwapConditioning': BFSH3ImageCharacterSwapConditioning}
NODE_DISPLAY_NAME_MAPPINGS = {'BFSH3ImageCharacterSwapConditioning': 'BFS H3 Image Character Swap Conditioning'}
def picker_subjects(picker_report):
import json
report = json.loads(picker_report)
subjects = sorted(set(int(s) for s in report.get('active_subjects', report.get('subjects', []))))
if any(s not in (1, 2, 3) for s in subjects):
raise ValueError('Select at least one subject in the Picker, then use Apply fix. Red, blue and green use their matching reference slots.')
return subjects, report.get('mark_style', 'colored hatching + contour')
class BFSH3ImageAutoCharacterSwapConditioning(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(node_id='BFSH3ImageAutoCharacterSwapConditioning',
display_name='BFS H3 Image Character Swap • Auto Subjects', category='BFS/MiniMax H3',
description='Reads selected colors and mark style from the Picker report. Only selected colors enter the native image conditioning. No manual subject count. Color slots remain fixed even when red is not selected.',
inputs=[io.Clip.Input('clip'), io.Vae.Input('vae'), io.Image.Input('guide_image'),
io.String.Input('picker_report', force_input=True),
io.Image.Input('red_reference', optional=True, lazy=True),
io.Image.Input('blue_reference', optional=True, lazy=True),
io.Image.Input('green_reference', optional=True, lazy=True),
io.Int.Input('width', default=0, min=0, max=16384, step=32),
io.Int.Input('height', default=0, min=0, max=16384, step=32),
io.String.Input('prompt_override', default='', multiline=True, optional=True,
tooltip='Optional full replacement prompt. Blank uses the automatic prompt. Picture 1 remains the guide; selected references follow in red/blue/green order starting at Picture 2.')],
outputs=[io.Conditioning.Output(display_name='positive'), io.Latent.Output(display_name='latent'),
io.String.Output(display_name='prompt')])
@classmethod
def check_lazy_status(cls, picker_report, red_reference=None, blue_reference=None, green_reference=None, **kwargs):
ids, _ = picker_subjects(picker_report)
refs = (red_reference, blue_reference, green_reference)
return [f'{COLORS[s-1]}_reference' for s in ids if refs[s-1] is None]
@classmethod
def execute(cls, clip, vae, guide_image, picker_report, red_reference=None, blue_reference=None, green_reference=None, width=0, height=0, prompt_override=''):
ids, style = picker_subjects(picker_report)
if not ids:
from comfy_execution.graph import ExecutionBlocker
return io.NodeOutput(ExecutionBlocker(None), ExecutionBlocker(None),
'Select subjects in the Picker, then click Apply fix and queue again.')
refs = (red_reference, blue_reference, green_reference)
pictures = [refs[s-1] for s in ids]
if any(p is None for p in pictures):
raise ValueError('Load a character reference for each selected Picker color.')
if style == 'colored hatching + contour':
mode = style
elif style.startswith('openpose'):
mode = 'DWPose guide'
elif style == 'translucent overlay':
mode = 'translucent overlay guide'
elif style == 'colored outline':
mode = 'outline guide'
else:
mode = 'normal map guide'
prompt = image_swap_prompt(len(ids), mode)
# Picture numbering is compact, while color identity follows the Picker.
for i, color in enumerate(COLORS):
import re
prompt = re.sub(r'\b' + color + r'\b', f'__BFS_COLOR_{i}__', prompt)
for i, subject in enumerate(ids):
prompt = prompt.replace(f'__BFS_COLOR_{i}__', COLORS[subject-1])
if prompt_override and prompt_override.strip():
prompt = prompt_override
images = {'image_1': guide_image}
images.update({f'image_{i}': image for i, image in enumerate(pictures, 2)})
positive, latent = MiniMaxH3ImageConditioning.execute(
clip=clip, vae=vae, prompt=prompt, width=width, height=height, images=images).args[:2]
return io.NodeOutput(positive, latent, prompt)
NODE_CLASS_MAPPINGS['BFSH3ImageAutoCharacterSwapConditioning'] = BFSH3ImageAutoCharacterSwapConditioning
NODE_DISPLAY_NAME_MAPPINGS['BFSH3ImageAutoCharacterSwapConditioning'] = 'BFS H3 Image Character Swap • Auto Subjects'
+68
View File
@@ -0,0 +1,68 @@
"""Character-swap prompts for MiniMax H3 Image."""
from comfy_api.latest import io
from .bfs_h3_image_swap_conditioning import hatching_swap_prompt
COLORS = ('red', 'blue', 'green')
MARKS = {
'normal map': ('surface-normal map', 'normal-map marking'),
'DWPose': ('pose skeleton showing the body, hands and face', 'pose-skeleton marking'),
'colored hatching + contour': ('hatching lines and matching contour', 'hatching'),
}
class BFSH3ImageCharacterSwapPrompt(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id='BFSH3ImageCharacterSwapPrompt',
display_name='BFS H3 Image Character Swap Prompt',
category='BFS/MiniMax H3',
description='Generate an H3 Image character-swap prompt. Picture 1 is the marked source image. Red, blue and green subjects use Pictures 2, 3 and 4.',
inputs=[
io.Combo.Input('mode', options=list(MARKS), default='normal map'),
io.Int.Input('subjects', default=1, min=1, max=3, step=1,
tooltip='Number of marked subjects. References follow red, blue, green order.'),
],
outputs=[io.String.Output(display_name='prompt')],
)
@classmethod
def execute(cls, mode='normal map', subjects=1):
if not 1 <= subjects <= len(COLORS):
raise ValueError('Choose one to three marked subjects')
if mode == 'colored hatching + contour':
return io.NodeOutput(hatching_swap_prompt(subjects))
mark, removal = MARKS[mode]
definitions = []
retention = []
replacements = []
for index, color in enumerate(COLORS[:subjects], 1):
reference = index + 1
definitions.append(
f'<Subject {index}> is the subject marked by the {color} {mark} in <Picture 1>. '
f'Its identity, face, hair or fur, skin, body shape, proportions and complete outfit come from <Picture {reference}>. '
f'Its position, pose, head orientation, gaze and expression follow the {color}-marked subject in <Picture 1>.'
)
retention.append(
f'<Subject {index}>: attribute_transfer - preserves the {color}-marked subject\'s position, pose, head orientation, gaze and expression '
f'while taking its complete appearance from <Picture {reference}>.'
)
replacements.append(f'the {color}-marked subject with <Subject {index}> from <Picture {reference}>')
prompt = (
'character_swap:\nsubject_definitions:\n'
f'<Picture 1> is the source image to edit. Each subject to replace is identified by a colored {mark}. '
'The mark identifies the selected subject. The replacement follows the reference character\'s body shape and proportions.\n'
+ '\n'.join(definitions)
+ '\nsummary: [image editing + reference generation] Replace only '
+ ', '.join(replacements)
+ ' in <Picture 1>. Preserve every other person and animal, the background, lighting, layout, camera perspective and framing. '
'Preserve held objects and foreground occluders in their original positions, shapes and colors. '
f'Remove every {removal} from the final image.\nretention_analysis:\n'
+ '\n'.join(retention)
+ '\nAll other subjects, held objects, foreground occluders, the layout and the background: fully_preserved.'
)
return io.NodeOutput(prompt)
NODE_CLASS_MAPPINGS = {'BFSH3ImageCharacterSwapPrompt': BFSH3ImageCharacterSwapPrompt}
NODE_DISPLAY_NAME_MAPPINGS = {'BFSH3ImageCharacterSwapPrompt': 'BFS H3 Image Character Swap Prompt'}
+91
View File
@@ -0,0 +1,91 @@
"""Optional image references and a compact character-swap comparison."""
import os
import json
import numpy as np
import torch
import folder_paths
from comfy_api.latest import io
from nodes import LoadImage
from PIL import Image, ImageDraw
class BFSOptionalReferenceImage(io.ComfyNode):
@classmethod
def define_schema(cls):
files = folder_paths.filter_files_content_types(os.listdir(folder_paths.get_input_directory()), ['image'])
return io.Schema(node_id='BFSOptionalReferenceImage', display_name='BFS Optional Reference Image',
category='BFS/image', description='Load a character reference and resize it proportionally using the native Resize Image/Mask implementation. An unused [none] slot returns no image and skips resizing.',
inputs=[io.Combo.Input('image', options=['[none]'] + sorted(files), default='[none]', upload=io.UploadType.image),
io.Float.Input('megapixels', default=4.0, min=0.1, max=16.0, step=0.1,
tooltip='Editable target area in megapixels. 4 is only the default; width and height follow the original aspect ratio.'),
io.Combo.Input('scale_method', options=['nearest-exact', 'bilinear', 'area', 'bicubic', 'lanczos'], default='lanczos',
tooltip='Editable interpolation method used by native Resize Image/Mask.')],
outputs=[io.Image.Output()])
@classmethod
def fingerprint_inputs(cls, image='[none]', megapixels=4.0, scale_method='lanczos'):
if image == '[none]':
return image
return (LoadImage.IS_CHANGED(image), megapixels, scale_method)
@classmethod
def execute(cls, image='[none]', megapixels=4.0, scale_method='lanczos'):
if image == '[none]':
return io.NodeOutput(None)
from comfy_extras.nodes_post_processing import ResizeImageMaskNode, ResizeType
pixels = LoadImage().load_image(image)[0][:1]
pixels = ResizeImageMaskNode.execute(pixels, scale_method=scale_method,
resize_type={'resize_type': ResizeType.SCALE_TOTAL_PIXELS, 'megapixels': megapixels}).args[0]
filename, _ = folder_paths.annotated_filepath(image)
return io.NodeOutput(pixels, ui={'images': [{'filename': os.path.basename(filename),
'subfolder': os.path.dirname(filename), 'type': 'input'}]})
class BFSH3ImageSwapComparison(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(node_id='BFSH3ImageSwapComparison', display_name='BFS H3 Image Swap Comparison', category='BFS/MiniMax H3',
inputs=[io.Image.Input('original'), io.Image.Input('guide'), io.Image.Input('result'),
io.String.Input('picker_report', force_input=True),
io.Image.Input('red_reference', optional=True, lazy=True), io.Image.Input('blue_reference', optional=True, lazy=True),
io.Image.Input('green_reference', optional=True, lazy=True)], outputs=[io.Image.Output()])
@classmethod
def check_lazy_status(cls, picker_report, red_reference=None, blue_reference=None, green_reference=None, **kwargs):
report = json.loads(picker_report)
ids = report.get('active_subjects', report.get('subjects', []))
names = ('red_reference', 'blue_reference', 'green_reference')
refs = (red_reference, blue_reference, green_reference)
return [names[int(s)-1] for s in ids if int(s) in (1, 2, 3) and refs[int(s)-1] is None]
@classmethod
def execute(cls, original, guide, result, picker_report, red_reference=None, blue_reference=None, green_reference=None):
report = json.loads(picker_report)
ids = report.get('active_subjects', report.get('subjects', []))
refs = (red_reference, blue_reference, green_reference)
panels = [('Original', original), ('Guide / Picture 1', guide)]
for picture, sid in enumerate(sorted(set(map(int, ids))), 2):
if sid not in (1, 2, 3):
raise ValueError('Invalid Picker subject ID')
ref = refs[sid-1]
if ref is not None:
panels.append((f'{("Red", "Blue", "Green")[sid-1]} / Picture {picture}', ref))
panels.append(('Result', result))
rendered = []
for label, tensor in panels:
array = (tensor[0, ..., :3].detach().cpu().float().clamp(0, 1).numpy() * 255).round().astype(np.uint8)
im = Image.fromarray(array)
height = 720
im = im.resize((max(1, round(im.width * height / im.height)), height), Image.Resampling.LANCZOS)
canvas = Image.new('RGB', (im.width, height + 24), 'black')
canvas.paste(im, (0, 24)); ImageDraw.Draw(canvas).text((6, 5), label, fill='white')
rendered.append(canvas)
canvas = Image.new('RGB', (sum(im.width for im in rendered), 744), 'black')
x = 0
for im in rendered:
canvas.paste(im, (x, 0)); x += im.width
return io.NodeOutput(torch.from_numpy(np.asarray(canvas).copy()).float()[None] / 255)
NODE_CLASS_MAPPINGS = {'BFSOptionalReferenceImage': BFSOptionalReferenceImage, 'BFSH3ImageSwapComparison': BFSH3ImageSwapComparison}
NODE_DISPLAY_NAME_MAPPINGS = {'BFSOptionalReferenceImage': 'BFS Optional Reference Image', 'BFSH3ImageSwapComparison': 'BFS H3 Image Swap Comparison'}
+60
View File
@@ -0,0 +1,60 @@
"""Reversible scaling of H3 PDD output-head row blocks using native patches."""
import math
from comfy_api.latest import io
class BFSH3PDDSurgery(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id='BFSH3PDDSurgery', display_name='BFS H3 PDD Surgery',
category='BFS/MiniMax H3',
description='Experimental PDD head scaling. Base is the shared output head; offsets are learned differences, not standalone full heads. All strengths 1 preserve the original model. Zero offsets do not undo distillation. Connect after LoRA loaders.',
inputs=[io.Model.Input('model'),
io.Float.Input('base_strength', default=1.0, min=0.0, max=2.0, step=0.05),
io.Float.Input('offset_strength', default=1.0, min=0.0, max=2.0, step=0.05)] + [
io.Float.Input(f'offset_{i}', default=1.0, min=0.0, max=2.0, step=0.05,
tooltip='Multiplier for this offset block; unused blocks are ignored. Multiplied by offset_strength.')
for i in range(1, 8)],
outputs=[io.Model.Output(), io.String.Output(display_name='report')])
@classmethod
def execute(cls, model, base_strength=1.0, offset_strength=1.0, **kwargs):
final = model.get_model_object('diffusion_model.final_layer')
if not hasattr(final, 'video_dim') or not hasattr(final, 'video_out'):
raise ValueError('Connect a native MiniMax H3 model with PDD output heads')
heads = final.video_out.weight.shape[0] // final.video_dim
if not 2 <= heads <= 8:
raise ValueError(f'Expected 2–8 PDD heads; found {heads}')
offsets = [offset_strength * kwargs.get(f'offset_{i}', 1.0) for i in range(1, heads)]
factors = [base_strength] + offsets
if not all(math.isfinite(v) for v in factors):
raise ValueError('PDD strengths must be finite')
result = model.clone()
if any(v != 1.0 for v in factors):
# Scale row blocks through ModelPatcher, including preceding LoRA patches.
# No dense delta allocation or direct modification of shared model weights.
for stream in ('video_out', 'audio_out'):
head = getattr(final, stream, None)
if head is None:
continue
for param in ('weight', 'bias'):
value = getattr(head, param, None)
if value is None:
continue
if value.shape[0] % heads:
raise ValueError(f'{stream}.{param} rows are not divisible by the PDD head count')
rows = value.shape[0] // heads
for index, factor in enumerate(factors):
if factor == 1.0:
continue
key = (f'diffusion_model.final_layer.{stream}.{param}', (0, index * rows, rows))
accepted = result.add_patches({key: ('diff', (None,))}, strength_patch=0.0, strength_model=factor)
if key not in accepted:
raise ValueError('The model did not accept all PDD head patches')
report = f'{heads} PDD heads: shared base={base_strength:g}; offset multipliers={offsets}. Applies to image/video and audio when present. Sigmas unchanged.'
return io.NodeOutput(result, report)
NODE_CLASS_MAPPINGS = {'BFSH3PDDSurgery': BFSH3PDDSurgery}
NODE_DISPLAY_NAME_MAPPINGS = {'BFSH3PDDSurgery': 'BFS H3 PDD Surgery'}
+419
View File
@@ -0,0 +1,419 @@
"""BFS H3 Character Swap Conditioning: MiniMax H3 Character Swap where the guide says who is replaced.
Five ways to mark the subjects (`mode`):
- "keyframe pins": the source video as an aligned guide (Add Guide at frame 0) plus one Add Guide per edited keyframe
at its own frame index (frames of the result with the new people in place, e.g. from BFS Keyframe Planner).
- "normal map guide": the guide video with each subject to replace drawn as its normal map in gray detail, tinted with
the subject's color (1 red, 2 blue, 3 green = Picture 1..3).
- "DWPose guide": the guide video with each subject erased to gray and drawn as its DWPose skeleton (body, hands and
face, with the eyes and the mouth opening) in the subject's color.
- "translucent overlay guide": the source video with each subject's mask tinted in its color at the Picker's
mark_opacity, so the original pose and motion remain visible under the tint.
- "outline guide": the source video with a thin colored contour inside each subject's mask; the scene and held
objects remain visible.
The marked guides come from BFS Video Segment Picker (`hybrid` output). The Pictures are native references and the
prompt is written from the mode and the number of Pictures (or prompt_override).
"""
import json
import torch
GRAY = 128.0 / 255.0 # background of the keyframe_video preview
MAX_KEYFRAMES = 8
MAX_PICTURES = 3
PINS, NORMALS, POSE, OVERLAY, OUTLINE = ("keyframe pins", "normal map guide", "DWPose guide",
"translucent overlay guide", "outline guide")
MODES = [PINS, NORMALS, POSE, OVERLAY, OUTLINE]
SUBJECT_COLORS = ["red", "blue", "green"]
def pose_caption(k):
"""The DWPose guide prompt (k Pictures)."""
C = SUBJECT_COLORS
defs = '\n'.join(f'<Subject {j}> is the subject marked by the {C[j - 1]} pose skeleton in the guide video; its appearance comes '
f'from <Picture {j}>, its pose, motion, facial expression and mouth movement from the {C[j - 1]} skeleton.'
for j in range(1, k + 1))
ret = '\n'.join(f'<Subject {j}>: attribute_transfer - keeps the pose, motion, expression and lip movement of the {C[j - 1]} skeleton '
f'and takes the face, hair, body shape and whole outfit of <Picture {j}> in every frame.' for j in range(1, k + 1))
who = ', '.join(f'the {C[j - 1]} subject replaced by <Picture {j}>' for j in range(1, k + 1))
return ('character_swap:\nsubject_definitions:\nThe guide video is the source of the edit. In it, each subject to replace is erased to '
'plain gray and shown only as its pose skeleton (body, hands and face, with the eyes and the mouth opening) drawn in one '
'color; the mark gives the pose and the expression, not the body shape, and everything else in the guide is the real video.\n'
+ defs + f'\nsummary: [video editing + reference generation] The target video is the guide video with {who}, from the first '
'frame to the last, following each skeleton\'s pose, expression and mouth. Every other person, the layout, the backgrounds, '
'the camera and the motion stay exactly as in the guide video.\nretention_analysis:\n' + ret +
'\nEveryone else, the layout and the backgrounds: fully_preserved.')
def normals_caption(k):
"""The normal map guide prompt (k Pictures)."""
C = SUBJECT_COLORS
defs = '\n'.join(f'<Subject {j}> is the subject marked by the {C[j - 1]} normal map in the guide video; its appearance comes '
f'from <Picture {j}>, its pose and motion from the {C[j - 1]} mark.' for j in range(1, k + 1))
ret = '\n'.join(f'<Subject {j}>: attribute_transfer - keeps the pose and motion of the {C[j - 1]} mark and takes the face, '
f'hair, body and whole outfit of <Picture {j}> in every frame.' for j in range(1, k + 1))
who = ', '.join(f'the {C[j - 1]} subject replaced by <Picture {j}>' for j in range(1, k + 1))
return ('character_swap:\nsubject_definitions:\nThe guide video is the source of the edit. In it, each subject to replace is marked '
'with its surface-normal map colorized in one solid color (its 3D surface, without its real colors); the mark says who '
'changes, not where the new look must end, and everything else in the guide is the real video.\n' + defs +
f'\nsummary: [video editing + reference generation] The target video is the guide video with {who}, from the first frame '
'to the last, following each mark\'s pose and motion. Every other person, the layout, the backgrounds, the camera and the '
'motion stay exactly as in the guide video. Keep held objects and foreground occluders in their original '
'positions, shapes and colors; they are separate from the subject being replaced.\nretention_analysis:\n' + ret +
'\nEveryone else, held objects, foreground occluders, the layout and the backgrounds: fully_preserved.')
def overlay_caption(k):
"""Prompt for the Picker's translucent color overlay, where source appearance is still visible underneath."""
C = SUBJECT_COLORS
defs = '\n'.join(f'<Subject {j}> is the person under the translucent {C[j - 1]} overlay in the guide video. '
f'Replace that person with <Picture {j}> while keeping their position, pose, motion and timing.'
for j in range(1, k + 1))
ret = '\n'.join(f'<Subject {j}>: attribute_transfer - use the face, hair, body and full outfit of <Picture {j}>; '
f'use the {C[j - 1]} marked person only for position, pose, motion and timing.'
for j in range(1, k + 1))
return ('character_swap:\nsubject_definitions:\nThe guide is the original video with a translucent color overlay on each '
'person to replace. The overlay identifies the person; the original appearance visible beneath it is not the target '
'appearance. The tint must disappear in the result.\n' + defs +
'\nsummary: [video editing + reference generation] Replace every marked person throughout the video with their '
'matching Picture. Keep their actions, camera motion, all unmarked people and the background as in the guide. '
'Do not render the colored overlays.\nretention_analysis:\n' + ret +
'\nUnmarked people and background: fully_preserved.')
def outline_caption(k):
"""Prompt for a person contour that leaves the original scene and interactions unobscured."""
C = SUBJECT_COLORS
defs = '\n'.join(f'<Subject {j}> is the person inside the {C[j - 1]} outline in the guide video. '
f'Take their new appearance from <Picture {j}> while retaining their position, pose, motion and timing.'
for j in range(1, k + 1))
ret = '\n'.join(f'<Subject {j}>: attribute_transfer - use the face, hair, body and full outfit of <Picture {j}>; '
f'keep the movement and interactions of the {C[j - 1]} outlined person.'
for j in range(1, k + 1))
return ('character_swap:\nsubject_definitions:\nThe guide is the original video with a thin colored outline on each '
'person to replace. The outline is only an identity marker and must disappear in the result. The source person '
'inside the outline provides pose and motion, while their new appearance comes from the matching Picture.\n' + defs +
'\nsummary: [video editing + reference generation] Replace only the outlined people throughout the video. '
'Preserve the background, other people, and all held or interacting objects with their original colors, positions, '
'shapes and contact with the subjects. Preserve foreground occlusions and the camera motion. Do not render the '
'colored outlines.\nretention_analysis:\n' + ret +
'\nBackground, other people, held objects and occlusions: fully_preserved.')
def _tail(k):
subs = '\n'.join(f'<Subject {j}> is the person of the guide video who appears with the look of <Picture {j}> in the '
f'keyframes; the appearance comes from <Picture {j}>, the motion from the guide video.' for j in range(1, k + 1))
ret = '\n'.join(f'<Subject {j}>: attribute_transfer - keeps the motion of the guide video and takes the face, hair, body and '
f'whole outfit of <Picture {j}> in every frame.' for j in range(1, k + 1))
summary = (f'\nsummary: [video editing + keyframe completion + reference generation] The target video is the guide video with '
f'{k} {"people" if k > 1 else "person"} replaced: exactly the people that differ between the keyframes and the guide video, '
f'replaced by {", ".join(f"<Picture {j}>" for j in range(1, k + 1))} from the first frame to the last. Every other '
'person, the layout, the backgrounds, the camera and the motion stay exactly as in the guide video.\n'
'retention_analysis:\n' + ret + '\nEveryone else, the layout and the backgrounds: fully_preserved.')
return subs, summary
def pins_caption(k, times):
"""The keyframe pins prompt (k Pictures, keyframes at `times` seconds)."""
when = ', '.join(f'{t:.1f}s' for t in times); n = len(times)
subs, summary = _tail(k)
return ('character_swap:\nsubject_definitions:\nThe guide video is the source of the edit. '
f'{n} keyframe{"s" if n > 1 else ""} of the target video {"are" if n > 1 else "is"} pinned at {when}; at those times '
'the target video matches the keyframes exactly.\n' + subs + summary)
def _fit(image, h, w):
"""[1,H,W,C] -> [h,w,3], center crop to the guide's aspect, then resize."""
import comfy.utils
x = image[:1, ..., :3].movedim(-1, 1)
x = comfy.utils.common_upscale(x, w, h, "lanczos", "center")
return x.movedim(1, -1)[0].clamp(0, 1)
class BFSH3KeyframeConditioning:
@classmethod
def INPUT_TYPES(cls):
opt = {f"picture_{j}": ("IMAGE", {"tooltip": f"Subject {j} ({SUBJECT_COLORS[j - 1]} mark in the guide modes)."})
for j in range(2, MAX_PICTURES + 1)}
opt.update({
"model": ("MODEL", {"tooltip": "keyframe pins: connect to get it back with keyframe_until applied. Guide modes: passed through."}),
"keyframes": ("IMAGE", {"tooltip": "keyframe pins: edited frames, one per keyframe (BFS Keyframe Planner keyframes)."}),
"keyframe_spec": ("STRING", {"forceInput": True, "tooltip": "keyframe pins: BFS Keyframe Planner keyframe_spec (the frame of every keyframe)."}),
})
for i in range(1, MAX_KEYFRAMES + 1):
opt[f"keyframe_{i}"] = ("IMAGE", {"tooltip": f"keyframe pins: edited frame {i}, a full frame of the RESULT (swapped person in place)."})
opt[f"time_{i}"] = ("FLOAT", {"default": float(i - 1), "min": 0.0, "max": 600.0, "step": 0.05,
"tooltip": f"keyframe pins: seconds into the guide where keyframe {i} sits."})
return {"required": {
"clip": ("CLIP", {"tooltip": "MiniMax H3's text encoder (Qwen3-VL)."}),
"vae": ("VAE",),
"guide_video": ("IMAGE", {"tooltip": "keyframe pins: the prepared 24-fps source video. Guide modes: the marked guide "
"(BFS Video Segment Picker `hybrid`)."}),
"width": ("INT", {"forceInput": True}), "height": ("INT", {"forceInput": True}), "length": ("INT", {"forceInput": True}),
"picture_1": ("IMAGE", {"tooltip": "Subject 1 (red mark in the guide modes)."}),
"mode": (MODES, {"default": PINS, "tooltip": "How the subjects to replace are marked: edited keyframes pinned at their "
"frames, or a guide with each subject drawn as a colored normal map, "
"DWPose skeleton, translucent color overlay, or colored outline "
"(BFS Video Segment Picker)."}),
"keyframe_until": ("FLOAT", {"default": 0.7, "min": 0.0, "max": 1.0, "step": 0.01,
"tooltip": "keyframe pins only, on the returned model: the guides condition every step whose "
"sigma is at least this; below it only the Pictures remain. 0.5 = more faithful to "
"the source, 0 = guides in every step. The guide modes keep the guide on every step."}),
"keyframe_until_drops": (["keyframes only", "all guides"], {"default": "keyframes only",
"tooltip": "keyframe pins only: below keyframe_until drop only the keyframes (the source video "
"keeps guiding every step) or every guide."}),
"prompt_override": ("STRING", {"multiline": True, "default": "", "tooltip": "Non-empty: used instead of the generated prompt."}),
}, "optional": opt}
RETURN_TYPES = ("MODEL", "CONDITIONING", "LATENT", "STRING", "IMAGE", "IMAGE")
RETURN_NAMES = ("model", "positive", "latent", "prompt", "keyframe_video", "pictures")
OUTPUT_IS_LIST = (False, False, False, False, False, True)
OUTPUT_TOOLTIPS = ("", "", "", "The prompt used.", "keyframe pins: the keyframes at their frames on gray. Guide modes: the guide.",
"The Pictures in order (Picture 1..4, any sizes), e.g. for BFS Video Compare's reference.")
FUNCTION = "encode"
CATEGORY = "BFS/MiniMax H3"
DESCRIPTION = ("MiniMax H3 Character Swap conditioning. keyframe pins: the source video plus one Add Guide per edited "
"keyframe. Guide modes use the BFS Video Segment Picker hybrid output: normal map, DWPose, translucent "
"overlay or colored outline. Red, blue and green marks correspond to Pictures 1, 2 and 3. "
"The node writes the matching prompt automatically.")
@staticmethod
def _collect(kw, fps=24.0):
"""[(frame, image[1,H,W,C])] from the Planner batch + spec and/or the keyframe_N / time_N inputs."""
out = []
batch, spec = kw.get("keyframes"), kw.get("keyframe_spec")
if batch is not None:
frames = json.loads(spec).get("frames", []) if spec else []
if len(frames) != len(batch):
raise ValueError(f"{len(batch)} keyframes but keyframe_spec lists {len(frames)} frames: connect the Planner's keyframe_spec.")
out += [(int(f), batch[i:i + 1]) for i, f in enumerate(frames)]
for i in range(1, MAX_KEYFRAMES + 1):
if kw.get(f"keyframe_{i}") is not None:
out.append((round(float(kw.get(f"time_{i}", 0.0)) * fps), kw[f"keyframe_{i}"]))
return sorted(out, key=lambda x: x[0])
@staticmethod
def _free_gpu():
"""Unload TE, VAE, and cached preprocessor models (e.g. DWPose) from GPU so MiniMaxH3 has full VRAM."""
try:
import gc
import sys
import comfy.model_management as mm
mm.unload_all_models()
for k, mod in list(sys.modules.items()):
if "dwpose" not in k or mod is None:
continue
try:
dw = getattr(mod, "global_cached_dwpose", None)
if dw is not None:
dw.det = None
dw.pose = None
dw.det_filename = None
dw.pose_filename = None
except Exception: # noqa: BLE001
pass
gc.collect()
mm.soft_empty_cache(True)
except Exception as e: # noqa: BLE001
print(f"[BFS H3 keyframe] could not free GPU models: {e!r}", flush=True)
@staticmethod
def _patch_vram_estimate(model):
"""Ensure MiniMaxH3 accounts for guide_video/keyframes/refs in ComfyUI's VRAM estimate so it doesn't OOM."""
if model is None:
return None
inner = getattr(model, "model", None)
if inner is None:
return model
import math
import types
def _extra_conds_shapes(_self, **kwargs):
extra = 0
payload = kwargs.get("minimax_payload", None)
if hasattr(payload, "cond") and isinstance(payload.cond, dict):
payload = payload.cond
keyframes = kwargs.get("minimax_keyframes", None)
if keyframes is None and isinstance(payload, dict):
keyframes = payload.get("keyframes", None)
refs = kwargs.get("minimax_refs", None)
if refs is None and isinstance(payload, dict):
refs = payload.get("refs", None)
for item in (keyframes or []) + (refs or []):
if not isinstance(item, dict):
continue
lat = item.get("latent", None)
if lat is not None and hasattr(lat, "shape"):
extra += math.prod(lat.shape[1:])
aud = item.get("audio_latent", None)
if aud is not None and hasattr(aud, "shape"):
extra += math.prod(aud.shape[1:])
cross_attn = kwargs.get("cross_attn", kwargs.get("c_crossattn", None))
if hasattr(cross_attn, "cond"):
cross_attn = cross_attn.cond
if cross_attn is not None and hasattr(cross_attn, "shape") and len(cross_attn.shape) >= 2:
extra += int(cross_attn.shape[1]) * 96
return {"minimax_cond": [1, 1, extra]} if extra > 0 else {}
cond_keys = tuple(getattr(inner, "memory_usage_factor_conds", ()) or ())
if "minimax_cond" not in cond_keys:
inner.memory_usage_factor_conds = cond_keys + ("minimax_cond",)
inner.extra_conds_shapes = types.MethodType(_extra_conds_shapes, inner)
if getattr(inner, "memory_usage_factor", 0.0) < 0.135:
inner.memory_usage_factor = 0.135
dm = getattr(inner, "diffusion_model", None)
if dm is not None and not getattr(dm, "_bfs_low_peak_patched", False):
try:
import comfy.ldm.minimax.model as mm_mod
import comfy.model_management as cmm
import comfy.quant_ops
from comfy.ldm.modules.attention import AttentionTensorContainer, optimized_attention
def _attn_forward(self, x, rope_freqs=None, transformer_options={}):
s = x.shape[0]
dev = x.device
q, k, v = self.qkv_proj(x).split(self.heads * self.head_dim, dim=-1)
del x
v = v.view(s, self.heads, self.head_dim)
if rope_freqs is not None:
q = q.view(1, s, self.heads, self.head_dim)
k = k.view(1, s, self.heads, self.head_dim)
qw = cmm.cast_to(self.q_norm.weight, device=dev)
kw = cmm.cast_to(self.k_norm.weight, device=dev)
rot = rope_freqs.shape[-3] * 2
if cmm.in_training:
q, k = comfy.quant_ops.ck.rms_rope_split_half(
q, k, rope_freqs, qw, kw, epsilon=self.q_norm.eps, rot_dim=rot)
else:
comfy.quant_ops.ck.rms_rope_split_half_(
q, k, rope_freqs, qw, kw, epsilon=self.q_norm.eps, rot_dim=rot)
del qw, kw
q, k = q[0], k[0]
else:
q = self.q_norm(q.view(s, self.heads, self.head_dim))
k = self.k_norm(k.view(s, self.heads, self.head_dim))
q = AttentionTensorContainer(q.transpose(0, 1).unsqueeze(0))
k = AttentionTensorContainer(k.transpose(0, 1).unsqueeze(0))
v = AttentionTensorContainer(v.transpose(0, 1).unsqueeze(0))
out = optimized_attention(
q, k, v, self.heads, preferred_attention=self.comfy_attention,
mask=None, skip_reshape=True, skip_output_reshape=True,
transformer_options=transformer_options,
)
del q, k, v
out = out.transpose(1, 2).reshape(s, self.heads * self.head_dim)
return self.out_proj(out)
def _block_forward(self, x, t_emb, mod_segments, rope_freqs, transformer_options={}, attention=None):
attention = self.attn if attention is None else attention
shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = self.adaln_proj(t_emb)
attn_out = attention(
mm_mod._mod_scale_shift(self.norm1(x), shift_msa, scale_msa, mod_segments),
rope_freqs=rope_freqs, transformer_options=transformer_options,
)
del shift_msa, scale_msa
x = mm_mod._mod_gate(x, gate_msa, attn_out, mod_segments)
del gate_msa, attn_out
mlp_out = self.mlp(mm_mod._mod_scale_shift(self.norm2(x), shift_mlp, scale_mlp, mod_segments))
del shift_mlp, scale_mlp
return mm_mod._mod_gate(x, gate_mlp, mlp_out, mod_segments)
for blk in getattr(dm, "blocks", ()):
blk.forward = types.MethodType(_block_forward, blk)
if hasattr(blk, "attn"):
blk.attn.forward = types.MethodType(_attn_forward, blk.attn)
dm._bfs_low_peak_patched = True
except Exception as e: # noqa: BLE001
print(f"[BFS H3 keyframe] could not patch block peak VRAM: {e!r}", flush=True)
return model
def encode(self, clip, vae, guide_video, width, height, length, picture_1, mode=PINS, keyframe_until=0.7,
keyframe_until_drops="keyframes only", prompt_override="", model=None, **kw):
if mode not in MODES:
raise ValueError(f"Unknown mode {mode!r}: use one of {MODES}.")
from comfy_extras.nodes_minimax_h3 import MiniMaxH3AddGuide, MiniMaxH3ReferenceToVideo
pictures = [picture_1] + [kw[f"picture_{j}"] for j in range(2, MAX_PICTURES + 1) if kw.get(f"picture_{j}") is not None]
kfs = self._collect(kw)
if mode != PINS:
return self._guide(clip, vae, guide_video, width, height, length, pictures, prompt_override, model, kw, mode=mode)
if not kfs:
raise ValueError("No keyframes: connect the Planner's keyframes + keyframe_spec, or keyframe_1 / time_1.")
if len(guide_video) != length:
raise ValueError(f"guide has {len(guide_video)} frames, length is {length}: connect Prepare's length.")
h, w = guide_video.shape[1:3]
video = torch.full((length, h, w, 3), GRAY, dtype=guide_video.dtype)
placed, frames = [], []
for f, img in kfs:
f = min(length - 1, max(0, int(f)))
if f in placed:
print(f"[BFS H3 keyframe] two keyframes at frame {f}", flush=True)
frame = _fit(img, h, w).to(video.dtype)
video[f] = frame
placed.append(f); frames.append(frame)
times = [f / 24.0 for f in placed]
prompt = prompt_override.strip() or pins_caption(len(pictures), times)
print("[BFS H3 keyframe] prompt:\n" + prompt, flush=True)
try:
positive, latent = MiniMaxH3ReferenceToVideo.execute(
clip=clip, vae=vae, prompt=prompt, width=width, height=height, length=length, ref_image_size="match",
ref_images={f"ref_image_{i}": p for i, p in enumerate(pictures, 1)}).args[:2]
self._free_gpu()
positive = MiniMaxH3AddGuide.execute(positive=positive, latent=latent, frame_idx=0, vae=vae, image=guide_video).args[0]
for f, frame in zip(placed, frames):
positive = MiniMaxH3AddGuide.execute(positive=positive, latent=latent, frame_idx=f, vae=vae, image=frame[None]).args[0]
finally:
self._free_gpu()
print(f"[BFS H3 keyframe] {mode}: {len(placed)} keyframes at frames {placed}; {len(pictures)} Pictures", flush=True)
return (self._until(model, keyframe_until, keyframe_until_drops.startswith("keyframes")), positive, latent, prompt, video,
pictures)
def _guide(self, clip, vae, guide_video, width, height, length, pictures, prompt_override, model, kw, mode=NORMALS):
"""Marked guide (subjects drawn as color-coded marks) as the only aligned guide, every
step; the Pictures in color order; no keyframes."""
from comfy_extras.nodes_minimax_h3 import MiniMaxH3AddGuide, MiniMaxH3ReferenceToVideo
if len(guide_video) != length:
raise ValueError(f"guide has {len(guide_video)} frames, length is {length}: connect Prepare's length.")
captions = {NORMALS: normals_caption, POSE: pose_caption, OVERLAY: overlay_caption, OUTLINE: outline_caption}
prompt = prompt_override.strip() or captions[mode](len(pictures))
print("[BFS H3 keyframe] guide prompt:\n" + prompt, flush=True)
try:
positive, latent = MiniMaxH3ReferenceToVideo.execute(
clip=clip, vae=vae, prompt=prompt, width=width, height=height, length=length, ref_image_size="match",
ref_images={f"ref_image_{i}": p for i, p in enumerate(pictures, 1)}).args[:2]
self._free_gpu()
positive = MiniMaxH3AddGuide.execute(positive=positive, latent=latent, frame_idx=0, vae=vae, image=guide_video).args[0]
finally:
self._free_gpu()
# the guide carries the background: it stays in every step (keyframe_until does not apply)
return self._patch_vram_estimate(model), positive, latent, prompt, guide_video, pictures
@staticmethod
def _until(model, keyframe_until, keep_source=False):
"""The model with the guides dropped below sigma keyframe_until (as Viggle Remake's RemakeKeyframeUntil).
keep_source: drop only the keyframes; the source video (the first Add Guide, see encode) stays."""
if model is None:
return None
BFSH3KeyframeConditioning._patch_vram_estimate(model)
import comfy.patcher_extension
until = float(keyframe_until)
def release(executor, x, timestep, context, transformer_options, minimax_payload=None, **kwargs):
p = minimax_payload or {}
if p.get("keyframes") and float(timestep.flatten()[0]) / 1000 < until:
refs = p.get("refs") or []
kept = list(p["keyframes"][:1]) if keep_source else []
minimax_payload = dict(p, keyframes=kept or None, layout=None,
cond_video_latents=[k["latent"] for k in kept if k.get("latent") is not None] +
[r["latent"] for r in refs if "latent" in r],
cond_audio_latents=[k["audio_latent"] for k in kept if k.get("audio_latent") is not None] +
[r["audio_latent"] for r in refs if r.get("audio_latent") is not None])
return executor(x, timestep, context, transformer_options, minimax_payload=minimax_payload, **kwargs)
m = model.clone()
m.add_wrapper_with_key(comfy.patcher_extension.WrappersMP.DIFFUSION_MODEL, "bfs_h3_keyframe_until", release)
return m
NODE_CLASS_MAPPINGS = {"BFSH3KeyframeConditioning": BFSH3KeyframeConditioning}
NODE_DISPLAY_NAME_MAPPINGS = {"BFSH3KeyframeConditioning": "BFS H3 Character Swap Conditioning"}
+151
View File
@@ -0,0 +1,151 @@
"""BFS Keyframe Marks: the tracked person's mask at every keyframe, and the keyframe frames marked for an image edit.
Pick the person once (BFS Segment Picker on a guide frame), track it over the whole guide (Run SAM3 Video Track ->
SAM3 Track to Mask), then this node takes the mask at each keyframe of a BFS Keyframe Planner keyframe_spec and tints
that frame (the Colored Mark convention: magenta at 30 % marks the person an image edit replaces). A keyframe whose mask is empty (the track lost the person, e.g. after a camera cut) is reported, so
it can be marked again by hand.
"""
import json
import torch
import torch.nn.functional as F
def parse_color(text):
s = str(text or "").strip().lstrip("#")
if len(s) == 3:
s = "".join(ch * 2 for ch in s)
try:
return int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16)
except (ValueError, IndexError):
return 255, 0, 255
def grow(mask, pixels):
"""[N,H,W] mask dilated (pixels > 0) or eroded (pixels < 0) by a square of that radius."""
if not pixels:
return mask
r = abs(int(pixels)); k = 2 * r + 1
m = mask[:, None].float()
if pixels > 0:
return F.max_pool2d(m, k, 1, r)[:, 0]
return -F.max_pool2d(-m, k, 1, r)[:, 0]
class BFSKeyframeMarks:
DESCRIPTION = __doc__.split("\n\n")[0]
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"guide_video": ("IMAGE", {"tooltip": "The guide frames the track was made on (same frames as the Planner's guide)."}),
"masks": ("MASK", {"tooltip": "The person to replace: one mask per guide frame, or one per keyframe (Segment Picker batch_masks on the Planner's source_frames)."}),
"keyframe_spec": ("STRING", {"forceInput": True, "tooltip": "BFS Keyframe Planner keyframe_spec: which frames are keyframes."}),
"mark_color": ("STRING", {"default": "#FF00FF", "tooltip": "Tint of the person in marked_frames (hex)."}),
"mark_opacity": ("FLOAT", {"default": 0.3, "min": 0.0, "max": 1.0, "step": 0.05,
"tooltip": "How strongly the person is tinted (0.3 = the Colored Mark default)."}),
"grow_pixels": ("INT", {"default": 0, "min": -64, "max": 64,
"tooltip": "Grow (positive) or shrink (negative) each mask before tinting, e.g. to cover hair."}),
"threshold": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05, "tooltip": "Mask values above this count as the person."}),
}}
RETURN_TYPES = ("IMAGE", "MASK", "IMAGE", "STRING")
RETURN_NAMES = ("marked_frames", "masks", "source_frames", "report")
OUTPUT_TOOLTIPS = ("The keyframe frames with the person tinted, one per keyframe (in keyframe_spec order).",
"The person's mask at each keyframe.",
"The untouched keyframe frames.",
"JSON: per keyframe its frame, time, mask area and whether the mask is empty.")
FUNCTION = "mark"
CATEGORY = "BFS/MiniMax H3"
def mark(self, guide_video, masks, keyframe_spec, mark_color, mark_opacity, grow_pixels, threshold):
spec = json.loads(keyframe_spec or "{}")
frames = [int(f) for f in spec.get("frames", [])]
if not frames:
raise ValueError("keyframe_spec lists no keyframes: add them in BFS Keyframe Planner.")
n, h, w = guide_video.shape[:3]
if masks.ndim == 2:
masks = masks[None]
per_keyframe = masks.shape[0] == len(frames) and masks.shape[0] != n # masks made on the Planner's source_frames
if not per_keyframe and masks.shape[0] != n:
raise ValueError(f"{masks.shape[0]} masks: give one per guide frame ({n}) or one per keyframe ({len(frames)}).")
if masks.shape[1:] != (h, w):
masks = F.interpolate(masks[:, None].float(), size=(h, w), mode="bilinear", align_corners=False)[:, 0]
color = torch.tensor(parse_color(mark_color), dtype=guide_video.dtype) / 255.0
fps = float(spec.get("fps", 24.0))
src, out, kept, report = [], [], [], []
for i, f in enumerate(frames):
f = max(0, min(n - 1, f))
j = i if per_keyframe else f
m = grow((masks[j:j + 1] > threshold).float(), grow_pixels).clamp(0, 1)
img = guide_video[f:f + 1, ..., :3]
a = (m[0].to(img.dtype) * float(mark_opacity))[..., None]
out.append(img * (1 - a) + color * a)
src.append(img); kept.append(m)
area = float(m.mean())
report.append({"frame": f, "time": round(f / fps, 3), "area": round(area, 4), "empty": area < 1e-4})
lost = [r["frame"] for r in report if r["empty"]]
if len(lost) == len(report):
raise ValueError("No person selected: click the person to replace in the Video Segment Picker, then run again.")
if lost:
print(f"[BFS Keyframe Marks] no mask at keyframe frame(s) {lost}: mark the person there by hand", flush=True)
return torch.cat(out), torch.cat(kept), torch.cat(src), json.dumps({"keyframes": report, "lost": lost})
class BFSTrackFromFrame:
"""Track a person over the whole guide from the frame they were picked on (not only frame 0): SAM 3 tracks
forward from that frame and backward (the frames before it, reversed), and the two halves are joined. With
anchor_frame -1 the anchor is the first keyframe of keyframe_spec, so feed the Planner's source_frames to the
Segment Picker and its mask lines up."""
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"guide_video": ("IMAGE", {"tooltip": "The guide frames (the same the Planner gets)."}),
"sam3_model": ("MODEL", {"tooltip": "SAM 3.1 MODEL (Load Checkpoint with sam3.1_multiplex_fp16.safetensors)."}),
"mask": ("MASK", {"tooltip": "The person on the anchor frame (BFS Segment Picker mask)."}),
"anchor_frame": ("INT", {"default": -1, "min": -1, "max": 100000,
"tooltip": "Guide frame the mask was made on; -1 = the first keyframe of keyframe_spec."}),
}, "optional": {
"keyframe_spec": ("STRING", {"forceInput": True, "tooltip": "BFS Keyframe Planner keyframe_spec (for anchor_frame -1)."}),
}}
RETURN_TYPES = ("MASK", "INT")
RETURN_NAMES = ("masks", "anchor_frame")
OUTPUT_TOOLTIPS = ("One mask per guide frame.", "The anchor frame used.")
FUNCTION = "track"
CATEGORY = "BFS/MiniMax H3"
@staticmethod
def _run(frames, model, mask):
from comfy_extras.nodes_sam3 import SAM3_VideoTrack, SAM3_TrackToMask
data = SAM3_VideoTrack.execute(images=frames, model=model, initial_mask=mask, max_objects=1).args[0]
return SAM3_TrackToMask.execute(track_data=data, object_indices="").args[0]
def track(self, guide_video, sam3_model, mask, anchor_frame, keyframe_spec=None):
n, h, w = guide_video.shape[:3]
k = int(anchor_frame)
if k < 0:
frames = json.loads(keyframe_spec or "{}").get("frames", [])
if not frames:
raise ValueError("anchor_frame -1 needs a keyframe_spec with at least one keyframe.")
k = int(frames[0])
k = max(0, min(n - 1, k))
m = mask[:1] if mask.ndim == 3 else mask[None]
if m.shape[1:] != (h, w):
m = F.interpolate(m[:, None].float(), size=(h, w), mode="bilinear", align_corners=False)[:, 0]
m = (m > 0.5).float()
fwd = self._run(guide_video[k:], sam3_model, m) # frames k .. n-1
parts = [fwd]
if k > 0:
bwd = self._run(guide_video[:k + 1].flip(0), sam3_model, m) # frames k .. 0
parts = [bwd.flip(0)[:k], fwd]
out = torch.cat([p.to(guide_video.device, torch.float32) for p in parts])
if out.shape[1:] != (h, w):
out = F.interpolate(out[:, None], size=(h, w), mode="bilinear", align_corners=False)[:, 0]
print(f"[BFS Track From Frame] anchor frame {k}: tracked {k} frames back, {n - k} forward", flush=True)
return out, k
NODE_CLASS_MAPPINGS = {"BFSKeyframeMarks": BFSKeyframeMarks, "BFSTrackFromFrame": BFSTrackFromFrame}
NODE_DISPLAY_NAME_MAPPINGS = {"BFSKeyframeMarks": "BFS Keyframe Marks", "BFSTrackFromFrame": "BFS Track From Frame (SAM 3)"}
+148
View File
@@ -0,0 +1,148 @@
"""BFS Keyframe Planner: pick keyframe times on the guide video, drop in edited frames and reference pictures.
The node shows the guide on a small player with a timeline (frames come from its last run: queue it once to load
them). "+ Keyframe" marks the current frame; each keyframe card holds the source frame and, optionally, the edited
frame for that time (drop an image on the card or upload it). Reference pictures (up to 4) are dropped or uploaded in
the node too, or connected as inputs.
Two ways to use it:
- with edited frames: wire keyframes + keyframe_spec + pictures into BFS H3 Keyframe Swap Conditioning;
- without: use it to choose the times; source_frames are the original frames there, ready for an image edit
(e.g. Segment Picker + the Qwen 2.1 magenta body swap), and the edits come back into the conditioning node.
All state lives in the hidden `plan` STRING widget (JSON), saved with the workflow:
{"keyframes": [{"frame": 53, "edit": "bfs_keyframes/abc.png"}, {"frame": 96, "edit": null}],
"pictures": ["bfs_keyframes/ref1.png"]}
Image paths are relative to ComfyUI's input folder (the panel uploads there).
"""
from __future__ import annotations
import hashlib
import json
import os
import numpy as np
import torch
from PIL import Image
try:
import folder_paths
except Exception: # tests run without ComfyUI
folder_paths = None
MAX_PICTURES = 4
STRIP_MAX_FRAMES = 480 # the panel's filmstrip: at most this many frames (evenly spread over longer guides)
STRIP_WIDTH = 384
def parse_plan(text):
try:
p = json.loads(text or "{}") or {}
except Exception:
p = {}
kfs = []
for k in p.get("keyframes", []) if isinstance(p.get("keyframes"), list) else []:
try:
kfs.append({"frame": int(k["frame"]), "edit": k.get("edit") or None})
except Exception:
continue
pics = [x for x in p.get("pictures", []) if isinstance(x, str) and x] if isinstance(p.get("pictures"), list) else []
return {"keyframes": kfs, "pictures": pics[:MAX_PICTURES]}
def _input_path(rel):
base = folder_paths.get_input_directory() if folder_paths else "."
path = os.path.normpath(os.path.join(base, rel))
if not path.startswith(os.path.normpath(base) + os.sep):
raise ValueError(f"image path outside the input folder: {rel}")
if not os.path.isfile(path):
raise FileNotFoundError(f"keyframe planner image not found in input/: {rel}")
return path
def load_image(rel):
im = Image.open(_input_path(rel)).convert("RGB")
return torch.from_numpy(np.asarray(im, np.float32) / 255.0)[None]
def fit(image, h, w):
"""[1,H,W,C] -> [1,h,w,3]: center crop to the guide's aspect, then resize."""
import comfy.utils
x = image[:1, ..., :3].movedim(-1, 1)
return comfy.utils.common_upscale(x, w, h, "lanczos", "center").movedim(1, -1).clamp(0, 1)
def filmstrip(video, fps):
"""Small JPEGs of the guide in the temp folder, for the panel. Same video -> same files (hashed)."""
n = video.shape[0]
idx = np.unique(np.linspace(0, n - 1, min(n, STRIP_MAX_FRAMES)).round().astype(int))
h, w = video.shape[1:3]
sw = min(STRIP_WIDTH, w); sh = max(1, round(h * sw / w))
sample = video[:: max(1, n // 16), ::16, ::16, :3].float().cpu().numpy()
key = hashlib.sha1(np.ascontiguousarray(sample).tobytes() + f"{n}x{h}x{w}".encode()).hexdigest()[:16]
temp = folder_paths.get_temp_directory() if folder_paths else "."
sub = os.path.join("bfs_kfplan", key); out = os.path.join(temp, sub); os.makedirs(out, exist_ok=True)
files = []
for i in idx:
name = f"f{i:05d}.jpg"; path = os.path.join(out, name)
if not os.path.exists(path):
arr = (video[int(i), ..., :3].float().cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
Image.fromarray(arr).resize((sw, sh), Image.BILINEAR).save(path, quality=82)
files.append(name)
return {"subfolder": sub, "type": "temp", "files": files, "frames": [int(i) for i in idx],
"count": int(n), "fps": float(fps), "width": int(w), "height": int(h)}
class BFSKeyframePlanner:
DESCRIPTION = __doc__.split("\n\n")[0]
@classmethod
def INPUT_TYPES(cls):
opt = {f"picture_{j}": ("IMAGE", {"tooltip": f"Reference picture {j}; overrides the one dropped in the panel."})
for j in range(1, MAX_PICTURES + 1)}
return {
"required": {
"guide_video": ("IMAGE", {"tooltip": "The guide frames (e.g. BFS H3 Prepare Native Video)."}),
"fps": ("FLOAT", {"default": 24.0, "min": 1.0, "max": 120.0, "step": 0.5}),
"plan": ("STRING", {"default": "{}", "multiline": True, "tooltip": "Set by the panel: keyframe frames, edited frames and pictures."}),
},
"optional": opt,
"hidden": {"unique_id": "UNIQUE_ID"},
}
RETURN_TYPES = ("IMAGE", "IMAGE", "IMAGE", "STRING") + ("IMAGE",) * MAX_PICTURES
RETURN_NAMES = ("guide_video", "source_frames", "keyframes", "keyframe_spec") + tuple(f"picture_{j}" for j in range(1, MAX_PICTURES + 1))
OUTPUT_TOOLTIPS = ("The guide, unchanged.",
"The original guide frames at the keyframe times (one per keyframe), e.g. to edit them.",
"The edited frame of every keyframe, at the guide size; a keyframe without one gives its source frame.",
"JSON: frames, times (s), fps and which keyframes have an edited frame.") + \
tuple(f"Reference picture {j} (None when not set)." for j in range(1, MAX_PICTURES + 1))
FUNCTION = "plan_keyframes"
CATEGORY = "BFS/MiniMax H3"
OUTPUT_NODE = True
def plan_keyframes(self, guide_video, fps, plan, unique_id=None, **kw):
n, h, w = guide_video.shape[0], guide_video.shape[1], guide_video.shape[2]
state = parse_plan(plan)
ui = {"bfs_kfplan": [filmstrip(guide_video, fps)]}
kfs = sorted({max(0, min(n - 1, k["frame"])): k for k in state["keyframes"]}.items())
src, edit, spec = [], [], {"frames": [], "times": [], "edited": [], "fps": float(fps), "length": int(n)}
for f, k in kfs:
frame = guide_video[f:f + 1, ..., :3]
src.append(frame)
edit.append(fit(load_image(k["edit"]), h, w).to(frame) if k["edit"] else frame)
spec["frames"].append(int(f)); spec["times"].append(round(f / float(fps), 4)); spec["edited"].append(bool(k["edit"]))
if not kfs:
print("[BFS Keyframe Planner] no keyframes yet: add them in the panel (outputs hold the first frame)", flush=True)
src = edit = [guide_video[:1, ..., :3]]
pictures = []
for j in range(1, MAX_PICTURES + 1):
p = kw.get(f"picture_{j}")
if p is None and j <= len(state["pictures"]):
p = load_image(state["pictures"][j - 1])
pictures.append(p)
return {"ui": ui, "result": (guide_video, torch.cat(src), torch.cat(edit), json.dumps(spec), *pictures)}
NODE_CLASS_MAPPINGS = {"BFSKeyframePlanner": BFSKeyframePlanner}
NODE_DISPLAY_NAME_MAPPINGS = {"BFSKeyframePlanner": "BFS Keyframe Planner"}
+72
View File
@@ -0,0 +1,72 @@
"""Generic reversible model surgery through native ModelPatcher scaling."""
import fnmatch
import json
import math
import re
from comfy_api.latest import io
def plan_rules(state, rules):
if not isinstance(rules, list):
raise ValueError('Rules must be a JSON list')
planned, counts = [], []
for rule in rules:
if not rule.get('enabled', True):
counts.append(0)
continue
pattern = rule.get('pattern', '*')
regex = re.compile(pattern) if rule.get('regex', False) else None
scale = float(rule.get('scale', 1.0))
if not math.isfinite(scale):
raise ValueError('Scale must be finite')
count = 0
for key, value in state.items():
if not key.endswith(('.weight', '.bias')) or not hasattr(value, 'shape'):
continue
if not (regex.search(key) if regex else fnmatch.fnmatchcase(key, pattern)):
continue
offset = None
if 'rows' in rule:
start, stop = rule['rows']
if not isinstance(start, int) or not isinstance(stop, int) or not 0 <= start < stop <= value.shape[0]:
raise ValueError(f'Invalid row range for {key}; use [start, exclusive_stop]')
offset = (0, start, stop - start)
count += 1
if scale != 1:
planned.append((key, offset, scale))
counts.append(count)
return planned, counts
class BFSModelSurgery(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(node_id='BFSModelSurgery', display_name='BFS Model Surgery', category='BFS/model',
description='Inspect and scale arbitrary model weights or row ranges. Rules run in order and overlapping scales multiply. Uses native reversible patches without copying the model. Run once to populate the layer panel. Does not scale CLIP or VAE.',
inputs=[io.Model.Input('model'), io.String.Input('rules', default='[]', multiline=True,
tooltip='JSON: [{"pattern":"*final_layer*video_out.weight","scale":0.9}]. Glob or regex:true. Optional rows:[start,exclusive_stop].')],
outputs=[io.Model.Output(), io.String.Output(display_name='report')])
@classmethod
def execute(cls, model, rules='[]'):
parsed = json.loads(rules)
state = model.model_state_dict()
planned, counts = plan_rules(state, parsed)
result = model.clone()
for key, offset, scale in planned:
patch_key = (key, offset) if offset else key
# strength_patch=0 skips the diff entirely; strength_model scales the
# materialized weight, including preceding LoRAs. No full-size delta allocation.
patches = {patch_key: ('diff', (None,))}
accepted = result.add_patches(patches, strength_patch=0.0, strength_model=scale)
if patch_key not in accepted:
raise ValueError(f'Model rejected surgery patch: {key}')
inventory = [{'key': key, 'shape': list(value.shape)} for key, value in state.items()
if key.endswith(('.weight', '.bias')) and hasattr(value, 'shape')]
report = json.dumps({'parameters': len(inventory), 'rule_matches': counts,
'applied_scales': len(planned), 'overlap': 'multiply in rule order'})
return io.NodeOutput(result, report, ui={'surgery_layers': inventory, 'surgery_report': [report]})
NODE_CLASS_MAPPINGS = {'BFSModelSurgery': BFSModelSurgery}
NODE_DISPLAY_NAME_MAPPINGS = {'BFSModelSurgery': 'BFS Model Surgery'}
+18 -6
View File
@@ -163,8 +163,11 @@ def detect_text(sam: tuple, frame: torch.Tensor, text: str, threshold: float, ma
from comfy_extras.nodes_sam3 import SAM3_Detect
model, clip = sam
h, w = frame.shape[1:3]
masks, boxes = SAM3_Detect.execute(model=model, image=frame, conditioning=_text_cond(clip, text, max_detections),
threshold=float(threshold), refine_iterations=1, individual_masks=True).args
# ComfyUI can materialize SAM weights as inference tensors. Its detector must then
# run under inference_mode too; otherwise autograd asks those weights for a version counter.
with torch.inference_mode():
masks, boxes = SAM3_Detect.execute(model=model, image=frame, conditioning=_text_cond(clip, text, max_detections),
threshold=float(threshold), refine_iterations=1, individual_masks=True).args
masks = masks.cpu() > .5 if masks.numel() else torch.zeros(0, h, w, dtype=torch.bool)
boxes = boxes[0] if boxes else []
return [(m, {"score": round(float(b.get("score", 0)), 3)}) for m, b in zip(masks, boxes)]
@@ -176,8 +179,9 @@ def detect_points(sam: tuple, frame: torch.Tensor, points: list[dict], threshold
h, w = frame.shape[1:3]
pos = [{"x": p["x"] * w, "y": p["y"] * h} for p in points if p["label"]]
neg = [{"x": p["x"] * w, "y": p["y"] * h} for p in points if not p["label"]]
masks = SAM3_Detect.execute(model=model, image=frame, positive_coords=json.dumps(pos), negative_coords=json.dumps(neg),
threshold=float(threshold), refine_iterations=2).args[0]
with torch.inference_mode():
masks = SAM3_Detect.execute(model=model, image=frame, positive_coords=json.dumps(pos), negative_coords=json.dumps(neg),
threshold=float(threshold), refine_iterations=2).args[0]
return (masks[0].cpu() > .5) if masks.numel() else torch.zeros(h, w, dtype=torch.bool)
@@ -211,8 +215,16 @@ def auto_objects(sam: tuple, frame: torch.Tensor, concepts: list[str], threshold
found = []
for concept in concepts:
for m, info in detect_text(sam, frame, concept, threshold, max_per_concept):
if int(m.sum()) >= max(64, min_area * h * w):
found.append((m, {**info, "label": concept}))
area = int(m.sum())
if area < max(64, min_area * h * w):
continue
# Generic "objects" may segment the entire scene. Such a mask is
# unsafe as an auto subject: it paints the background into the guide.
if concept.strip().casefold() in {"object", "objects"} and area >= .25 * h * w:
_, _, bw, bh = box_of(m)
if bw >= .98 * w and bh >= .98 * h:
continue
found.append((m, {**info, "label": concept}))
found = dedupe(found)
found.sort(key=lambda f: box_of(f[0])[0]) # left to right, so "a1" reads naturally
return [{"id": f"a{k + 1}", "label": info["label"], "source": "auto", "score": info["score"], "mask": m}
+174 -461
View File
@@ -455,8 +455,8 @@ DEFAULT_PLAN = {
"global_refs": [], "global_ref": "", "global_ref2": "", "global_prompt": "", "global_color_guide_ref1": "#00ff00",
"global_color_guide_ref2": "#9b30ff", "picture_colors": [], "megapixels": 0.5, "multiple": 32,
"generation_size_mode": "megapixels", "custom_width": 1024, "custom_height": 1024,
"detector": "adaptive", "run": "recursive", "filters": {}, "skip_fill": "original",
"cast": {}, "cast_split": False, "cast_only": False, "mask_cfg": {}, "vlm_cfg": {}, "audio_mode": "auto", "ref_details": {},
"detector": "adaptive", "run": "recursive", "skip_fill": "original",
"mask_cfg": {}, "vlm_cfg": {}, "audio_mode": "auto", "ref_details": {},
"picture_details": {}, "picture_cfg": {}, "auto_prompt": {}, "auto_prompts": {}, "between_shots": "keep", "shot_cache": "output", "shot_storage": "lossless", "unload_before_join": True,
}
@@ -547,13 +547,6 @@ def resolve_plan(plan: dict, analysis: dict, path: str | None = None) -> list[di
cuts = detect_cuts(analysis["score"], analysis["raw"], float(plan["sensitivity"]), fps)
manual = plan.get("bounds") or None
mode = "manual" if manual else plan["mode"]
cast = cached_cast(path, analysis) if path else None # only once the people were analysed
if cast is not None and plan.get("cast_split") and not manual:
extra = []
bounds = [0] + cuts + [analysis["n"]]
for a, b in zip(bounds[:-1], bounds[1:]):
extra += person_change_points(cast, a, b, max(min_len, int(round(fps))))
cuts = sorted(set(cuts) | set(extra))
segs = plan_segments(hi, cuts, mode, max_len, min_len, int(plan.get("max_parts") or 0), 0, manual, start=lo)
meta = plan.get("segs") or []
for i, s in enumerate(segs):
@@ -575,34 +568,37 @@ def resolve_plan(plan: dict, analysis: dict, path: str | None = None) -> list[di
s["color_guide_color2"] = str(m.get("color_guide_color2") or "")
s["color_guide_points2"] = list(m.get("color_guide_points2") or [])
s["color_guide_key2"] = int(m.get("color_guide_key2") or 0)
s["color_guide_opacity"] = min(0.8, max(0.05, float(m.get("color_guide_opacity") or 0.35)))
style = str(m.get("color_guide_style") or COLOR_GUIDE_STYLES[0])
if style in COLOR_GUIDE_STYLES[1:]: # only set when not the default, so older stored shots keep their signature
s["color_guide_style"] = style
cap = 0.8 if style not in COLOR_GUIDE_STYLES[1:] else 1.0
s["color_guide_opacity"] = min(cap, max(0.05, float(m.get("color_guide_opacity") or 0.35)))
s["strength"] = min(1.0, max(0.05, float(m.get("strength") or 1.0)))
s["mask"] = m.get("mask") or {}
s["target"] = m.get("target") or ""
s["cut_before"] = bool(s.get("cut_before")) or (s["start"] in cuts)
s["gen_len"] = snap_up(s["end"] - s["start"], grid)
s["people"], s["main"] = [], -1
s["color_guide_color"] = s["color_guide_color"] or str(plan.get("global_color_guide_ref1") or "#00ff00")
s["color_guide_color2"] = s["color_guide_color2"] or str(plan.get("global_color_guide_ref2") or "#9b30ff")
s["color_guide_marks"] = color_marks(plan, m)
s["color_guide_color"] = s["color_guide_marks"][0]["color"]
if cast is not None:
sp = shot_people(cast, s["start"], s["end"])
s["people"], s["main"] = sp["people"], sp["main"]
included = [p for p in sp["people"] if not (plan.get("cast") or {}).get(str(p), {}).get("ignore")]
s["cast_skip"] = bool(plan.get("cast_only")) and not included
return segs
# ---------------------------------------------------------------------------- content filters
# ---------------------------------------------------------------------------- which shots run
def mark_runs(plan: dict, segs: list[dict]) -> list[dict]:
"""A shot runs when it is enabled. Plans before 1.122 could also skip one by hand (force = 'skip')."""
meta = plan.get("segs") or []
for i, s in enumerate(segs):
m = meta[i] if i < len(meta) and isinstance(meta[i], dict) else {}
s["run"] = bool(s["enabled"]) and m.get("force") != "skip"
return segs
# ---------------------------------------------------------------------------- YOLO models (pose mask guide)
DEFAULT_FILTERS = {
"person": False, "min_person_area": 0.0, "max_persons": 0, "face": False,
"skip_dark": False, "dark_level": 0.06, "skip_static": False, "static_level": 0.004,
"min_frames": 0, "samples": 6,
}
_DET: dict[str, Any] = {}
_STATS_CACHE: dict[tuple, dict] = {}
def _yolo(kind: str):
@@ -626,375 +622,6 @@ def _yolo(kind: str):
return model
def _detect(frames: list[np.ndarray]) -> list[dict]:
"""Per frame: person boxes (area fraction) and face count."""
import cv2
out = [{"persons": [], "faces": 0} for _ in frames]
pm, fm = _yolo("person"), _yolo("face")
if pm is not None:
for i, r in enumerate(pm(frames, verbose=False, conf=0.35, classes=[0])):
h, w = frames[i].shape[:2]
for b in r.boxes.xyxy.cpu().numpy() if r.boxes is not None else []:
out[i]["persons"].append(float((b[2] - b[0]) * (b[3] - b[1]) / (w * h)))
else:
hog = cv2.HOGDescriptor()
hog.setSVMDetector(cv2.HOGDescriptor_getDefaultPeopleDetector())
for i, fr in enumerate(frames):
h, w = fr.shape[:2]
rects, _ = hog.detectMultiScale(cv2.cvtColor(fr, cv2.COLOR_RGB2GRAY), winStride=(8, 8))
out[i]["persons"] = [float(rw * rh / (w * h)) for (_, _, rw, rh) in rects]
if fm is not None:
for i, r in enumerate(fm(frames, verbose=False, conf=0.4)):
out[i]["faces"] = int(len(r.boxes)) if r.boxes is not None else 0
else:
casc = cv2.CascadeClassifier(os.path.join(cv2.data.haarcascades, "haarcascade_frontalface_default.xml"))
for i, fr in enumerate(frames):
out[i]["faces"] = int(len(casc.detectMultiScale(cv2.cvtColor(fr, cv2.COLOR_RGB2GRAY), 1.1, 5)))
return out
def shot_stats(path: str, analysis: dict, seg: dict, samples: int = 6) -> dict:
"""Sampled content statistics for one shot (cached)."""
key = (path, os.path.getmtime(path), float(analysis["fps"]), seg["start"], seg["end"], samples)
if key in _STATS_CACHE:
return _STATS_CACHE[key]
src = _timeline(analysis["n_src"], analysis["fps_src"], float(analysis["fps"]))
k = max(1, min(samples, seg["end"] - seg["start"]))
pick = np.linspace(seg["start"], seg["end"] - 1, k).round().astype(int)
w = 640
h = max(32, int(round(w * analysis["height"] / max(1, analysis["width"]))))
frames = _read_frames(path, src[pick], (w, h))
det = _detect(frames)
raw = analysis.get("raw") or []
motion = float(np.mean(raw[seg["start"] + 1:seg["end"]])) if seg["end"] - seg["start"] > 1 and raw else 0.0
st = {
"persons": int(max(len(d["persons"]) for d in det)),
"person_area": round(float(max([max(d["persons"]) for d in det if d["persons"]] or [0.0])), 4),
"person_frames": int(sum(1 for d in det if d["persons"])),
"faces": int(max(d["faces"] for d in det)),
"brightness": round(float(np.mean([f.mean() / 255.0 for f in frames])), 4),
"motion": round(motion, 4), "sampled": int(k),
}
_STATS_CACHE[key] = st
return st
def skip_reason(stats: dict, length: int, f: dict) -> str:
"""Why a shot should be skipped under these filters ('' = keep)."""
if f.get("min_frames") and length < int(f["min_frames"]):
return f"shorter than {int(f['min_frames'])} frames"
if f.get("skip_dark") and stats["brightness"] < float(f.get("dark_level", 0.06)):
return "dark / fade"
if f.get("skip_static") and stats["motion"] < float(f.get("static_level", 0.004)):
return "static"
if f.get("person") and stats["persons"] == 0:
return "no person"
if f.get("person") and float(f.get("min_person_area") or 0) > 0 and stats["person_area"] < float(f["min_person_area"]):
return f"person smaller than {float(f['min_person_area']) * 100:.0f}% of the frame"
if int(f.get("max_persons") or 0) > 0 and stats["persons"] > int(f["max_persons"]):
return f"more than {int(f['max_persons'])} people"
if f.get("face") and stats["faces"] == 0:
return "no face"
return ""
def filters_active(f: dict) -> bool:
return any(f.get(k) for k in ("person", "face", "skip_dark", "skip_static")) or \
int(f.get("max_persons") or 0) > 0 or int(f.get("min_frames") or 0) > 0
def apply_filters(plan: dict, analysis: dict, path: str, segs: list[dict]) -> list[dict]:
"""Mark each shot with stats and an automatic skip reason; per-shot 'force' overrides it."""
f = dict(DEFAULT_FILTERS); f.update(plan.get("filters") or {})
meta = plan.get("segs") or []
need = filters_active(f)
for i, s in enumerate(segs):
force = (meta[i] or {}).get("force", "auto") if i < len(meta) and isinstance(meta[i], dict) else "auto"
s["force"] = force
s["stats"] = shot_stats(path, analysis, s, int(f.get("samples") or 6)) if need else None
s["skip_reason"] = skip_reason(s["stats"], s["end"] - s["start"], f) if need else ""
if not s["skip_reason"] and s.get("cast_skip"):
s["skip_reason"] = "no detected person"
s["run"] = s["enabled"] and (force == "run" or (force != "skip" and not s["skip_reason"]))
return segs
# ---------------------------------------------------------------------------- cast (people by face)
_FACE_APP: Any = None
_CAST_CACHE: dict[tuple, dict] = {}
def _face_app():
"""InsightFace detector + ArcFace recogniser (buffalo_l), or None when unavailable."""
global _FACE_APP
if _FACE_APP is not None:
return _FACE_APP or None
try:
import insightface
root = os.path.join(folder_paths.models_dir, "insightface")
# CPU only: light, no VRAM, and no CUDA/cuDNN loading (a missing cuDNN aborts the whole process)
app = insightface.app.FaceAnalysis(name="buffalo_l", root=root, allowed_modules=["detection", "recognition"],
providers=["CPUExecutionProvider"])
app.prepare(ctx_id=-1, det_size=(320, 320))
# small models: a few threads are faster than onnxruntime's default pool (one per core) and stay light
import onnxruntime as ort
so = ort.SessionOptions()
so.intra_op_num_threads = min(4, os.cpu_count() or 4)
so.inter_op_num_threads = 1
for model in app.models.values():
model.session = ort.InferenceSession(model.model_file, so, providers=["CPUExecutionProvider"])
_FACE_APP = app
except Exception as exc: # noqa: BLE001
print(f"[BFS Shot Planner] face analysis unavailable: {exc!r}")
_FACE_APP = False
return _FACE_APP or None
def _cast_key(path: str, analysis: dict, step_s: float = 0.5, threshold: float = 0.42, min_share: float = 0.01) -> tuple:
return (path, os.path.getmtime(path), float(analysis["fps"]), round(step_s, 3), round(threshold, 3), round(min_share, 4),
"insightface" if _face_app() is not None else "opencv" if _cv_face() is not None else "sam3")
APPEARANCE_THRESHOLD = 0.86 # cosine on the colour signature (only when DINOv2 cannot load)
SFACE_THRESHOLD = 0.36 # cosine on SFace features (OpenCV's same-identity threshold is 0.363)
_CV_FACE: dict = {}
CV_FACE_MODELS = {
"face_detection_yunet_2023mar.onnx":
"https://github.com/opencv/opencv_zoo/raw/main/models/face_detection_yunet/face_detection_yunet_2023mar.onnx",
"face_recognition_sface_2021dec.onnx":
"https://github.com/opencv/opencv_zoo/raw/main/models/face_recognition_sface/face_recognition_sface_2021dec.onnx",
}
def _cv_face():
"""OpenCV Zoo's YuNet (detection) + SFace (recognition): Apache-2.0, run by OpenCV itself (no extra package),
downloaded once into models/opencv_face (0.2 + 37 MB). None when it cannot load."""
if "m" not in _CV_FACE:
try:
import cv2
import urllib.request
d = os.path.join(folder_paths.models_dir, "opencv_face")
os.makedirs(d, exist_ok=True)
for name, url in CV_FACE_MODELS.items():
f = os.path.join(d, name)
if not os.path.exists(f) or os.path.getsize(f) < 100000:
print(f"[BFS Shot Planner] downloading {name} (OpenCV Zoo, Apache-2.0)", flush=True)
urllib.request.urlretrieve(url, f + ".part")
os.replace(f + ".part", f)
det = cv2.FaceDetectorYN.create(os.path.join(d, "face_detection_yunet_2023mar.onnx"), "", (320, 320), 0.6, 0.3, 50)
rec = cv2.FaceRecognizerSF.create(os.path.join(d, "face_recognition_sface_2021dec.onnx"), "")
_CV_FACE["m"] = (det, rec)
except Exception as exc: # noqa: BLE001
print(f"[BFS Shot Planner] OpenCV face models unavailable ({exc!r})", flush=True)
_CV_FACE["m"] = None
return _CV_FACE["m"]
def appearance_signature(frame: np.ndarray, box) -> np.ndarray:
"""A license-free identity signature for grouping people inside ONE video: colour histograms of the head (hair,
skin) and of the torso under it (clothes), plus a tiny grey thumbnail of the face. Weaker than ArcFace across
videos, enough to tell the people of a clip apart."""
import cv2
H, W = frame.shape[:2]
x0, y0, x1, y1 = [int(v) for v in box]
bw, bh = max(2, x1 - x0), max(2, y1 - y0)
head = frame[max(0, y0 - bh // 4):min(H, y1), max(0, x0 - bw // 6):min(W, x1 + bw // 6)]
torso = frame[min(H - 1, y1):min(H, y1 + 2 * bh), max(0, x0 - bw // 3):min(W, x1 + bw // 3)]
def hist(img):
if img.size == 0:
return np.zeros(12 * 4 * 4, np.float32)
hsv = cv2.cvtColor(img, cv2.COLOR_RGB2HSV)
h = cv2.calcHist([hsv], [0, 1, 2], None, [12, 4, 4], [0, 180, 0, 256, 0, 256]).ravel().astype(np.float32)
return np.sqrt(h / max(1.0, h.sum())) # Hellinger: cosine of sqrt-histograms
face = frame[max(0, y0):min(H, y1), max(0, x0):min(W, x1)]
thumb = cv2.resize(cv2.cvtColor(face, cv2.COLOR_RGB2GRAY), (8, 8), interpolation=cv2.INTER_AREA).astype(np.float32).ravel() \
if face.size else np.zeros(64, np.float32)
thumb = (thumb - thumb.mean()) / (thumb.std() + 1e-6) * 0.05
v = np.concatenate([hist(head), 1.3 * hist(torso), thumb])
return v / (np.linalg.norm(v) + 1e-8)
def _sam3_faces(frames: list, threshold: float = 0.4) -> list:
"""Faces per frame with SAM 3.1 (ComfyUI core): [[(x0, y0, x1, y1, score), ...], ...] in pixels."""
from comfy_extras.nodes_sam3 import SAM3_Detect
model, clip = _sam3()
cond = clip.encode_from_tokens_scheduled(clip.tokenize("face"))
out = []
for k in range(0, len(frames), 8):
_status("Finding faces (SAM 3.1)", k, len(frames))
batch = torch.from_numpy(np.stack(frames[k:k + 8]).astype(np.float32) / 255.0)
boxes = SAM3_Detect.execute(model=model, image=batch, conditioning=cond, threshold=threshold,
refine_iterations=0).args[1]
for fb in boxes:
out.append([(b["x"], b["y"], b["x"] + b["width"], b["y"] + b["height"], b.get("score", 1.0)) for b in (fb or [])])
return out
def cached_cast(path: str, analysis: dict) -> dict | None:
"""The cast of a video when it was already analysed with the default settings, else None (never computes)."""
try:
return _CAST_CACHE.get(_cast_key(path, analysis))
except OSError:
return None
def analyze_cast(path: str, analysis: dict, step_s: float = 0.5, threshold: float = 0.42,
min_share: float = 0.01, max_faces: int = 3) -> dict:
"""Detect faces every `step_s` seconds (CPU), keep the `max_faces` most probable ones per frame, group them
into people by ArcFace similarity.
Returns {"people": [{id, thumb, count, share}], "samples": [{f, faces: [{pid, area}]}]} where
`f` is a timeline frame. Person ids are stable for the same video and settings.
"""
import cv2
key = _cast_key(path, analysis, step_s, threshold, min_share)
if key in _CAST_CACHE:
return _CAST_CACHE[key]
# InsightFace (ArcFace) when installed; else OpenCV's YuNet + SFace (Apache-2.0, no extra package); else SAM 3.1
# faces grouped by colours (hair, skin, clothes)
app = _face_app()
cvf = _cv_face() if app is None else None
if app is None:
threshold = SFACE_THRESHOLD if cvf is not None else APPEARANCE_THRESHOLD
print("[BFS Shot Planner] Find people: insightface is not installed, using "
+ ("OpenCV YuNet + SFace faces" if cvf is not None else "SAM 3.1 faces + colours (hair, skin, clothes)"),
flush=True)
fps = float(analysis["fps"])
step = max(1, int(round(step_s * fps)))
frames_idx = np.arange(0, analysis["n"], step)
src = _timeline(analysis["n_src"], analysis["fps_src"], fps)
w = 960
h = max(32, int(round(w * analysis["height"] / max(1, analysis["width"]))))
frames = _read_frames(path, src[frames_idx], (w, h), stage="Reading frames for faces")
dets = [] # (sample index, embedding, area fraction, crop)
if app is None and cvf is not None:
det, rec = cvf
det.setInputSize((w, h))
for si, fr in enumerate(frames):
_status("Finding faces", si, len(frames))
bgr = cv2.cvtColor(fr, cv2.COLOR_RGB2BGR)
_, faces = det.detect(bgr)
cand = []
for row in (faces if faces is not None else []):
x, y, bw, bh, score = float(row[0]), float(row[1]), float(row[2]), float(row[3]), float(row[-1])
area = max(0.0, bw) * max(0.0, bh) / float(w * h)
if area >= 0.0015 and score >= 0.6:
cand.append((score, row, area))
for score, row, area in sorted(cand, key=lambda c: -c[0])[:max_faces]:
feat = rec.feature(rec.alignCrop(bgr, row)).ravel().astype(np.float32)
x0, y0, x1, y1 = int(row[0]), int(row[1]), int(row[0] + row[2]), int(row[1] + row[3])
pad = int(0.25 * max(x1 - x0, y1 - y0))
crop = fr[max(0, y0 - pad):min(h, y1 + pad), max(0, x0 - pad):min(w, x1 + pad)]
dets.append((si, feat / (np.linalg.norm(feat) + 1e-8), area, crop))
elif app is None:
for si, (fr, faces) in enumerate(zip(frames, _sam3_faces(frames, threshold=0.55))):
cand = []
for x0, y0, x1, y1, score in faces:
bw, bh = max(1.0, x1 - x0), max(1.0, y1 - y0)
area = bw * bh / float(w * h)
if area >= 0.0015 and 0.55 <= bw / bh <= 1.4: # a face-shaped box (logos and text are skipped)
cand.append((float(score), (x0, y0, x1, y1), area))
for score, box, area in sorted(cand, reverse=True)[:max_faces]:
x0, y0, x1, y1 = [int(v) for v in box]
pad = int(0.25 * max(x1 - x0, y1 - y0))
crop = fr[max(0, y0 - pad):min(h, y1 + pad), max(0, x0 - pad):min(w, x1 + pad)]
dets.append((si, appearance_signature(fr, box), area, crop))
frames_for_faces = frames if app is not None else []
from_insight = app is not None
if from_insight:
from insightface.app.common import Face
rec = app.models["recognition"]
for si, fr in enumerate(frames_for_faces):
_status("Finding faces", si, len(frames))
bgr = cv2.cvtColor(fr, cv2.COLOR_RGB2BGR)
boxes, kpss = app.det_model.detect(bgr, max_num=0, metric="default")
# only the most probable faces of the frame get recognised, so a crowd stays cheap and out of the cast
cand = []
for k in range(boxes.shape[0]):
x0, y0, x1, y1 = [int(v) for v in boxes[k, :4]]
area = max(0, x1 - x0) * max(0, y1 - y0) / float(w * h)
if area >= 0.0015 and boxes[k, 4] >= 0.5:
cand.append((float(boxes[k, 4]), k, area))
for score, k, area in sorted(cand, reverse=True)[:max_faces]:
f = Face(bbox=boxes[k, :4], kps=kpss[k] if kpss is not None else None, det_score=score)
rec.get(bgr, f)
x0, y0, x1, y1 = [int(v) for v in f.bbox]
pad = int(0.25 * max(x1 - x0, y1 - y0))
crop = fr[max(0, y0 - pad):min(h, y1 + pad), max(0, x0 - pad):min(w, x1 + pad)]
dets.append((si, f.normed_embedding.astype(np.float32), area, crop))
# greedy online clustering on cosine similarity, then merge close clusters
cents, members = [], []
for k, (_, e, _, _) in enumerate(dets):
if cents:
sims = np.array([c @ e / (np.linalg.norm(c) + 1e-8) for c in cents])
j = int(sims.argmax())
if sims[j] >= threshold:
members[j].append(k); cents[j] = cents[j] + e
continue
cents.append(e.copy()); members.append([k])
merged = True
while merged:
merged = False
for a in range(len(cents)):
for b in range(a + 1, len(cents)):
ca, cb = cents[a] / np.linalg.norm(cents[a]), cents[b] / np.linalg.norm(cents[b])
if ca @ cb >= threshold:
cents[a] = cents[a] + cents[b]; members[a] += members[b]
del cents[b]; del members[b]; merged = True
break
if merged:
break
total = max(1, len(frames))
groups = [m for m in members if len({dets[k][0] for k in m}) / total >= min_share]
groups.sort(key=lambda m: -len({dets[k][0] for k in m}))
people, owner = [], {}
for pid, m in enumerate(groups):
best = max(m, key=lambda k: dets[k][2])
crop = cv2.resize(dets[best][3], (96, 96), interpolation=cv2.INTER_AREA)
ok, buf = cv2.imencode(".jpg", cv2.cvtColor(crop, cv2.COLOR_RGB2BGR), [cv2.IMWRITE_JPEG_QUALITY, 80])
seen = sorted({dets[k][0] for k in m})
people.append({"id": pid, "thumb": "data:image/jpeg;base64," + base64.b64encode(buf.tobytes()).decode(),
"count": len(seen), "share": round(len(seen) / total, 4),
"first": int(frames_idx[seen[0]]), "last": int(frames_idx[seen[-1]])})
for k in m:
owner[k] = pid
samples = [{"f": int(f), "faces": []} for f in frames_idx]
for k, (si, _, area, _) in enumerate(dets):
if k in owner:
samples[si]["faces"].append({"pid": owner[k], "area": round(float(area), 5)})
out = {"people": people, "samples": samples, "step": step}
_CAST_CACHE[key] = out
return out
def shot_people(cast: dict, start: int, end: int) -> dict:
"""Who appears in [start, end): per person, sampled frames seen and summed face area; plus the main one."""
score: dict[int, list[float]] = {}
for smp in cast["samples"]:
if start <= smp["f"] < end:
for fc in smp["faces"]:
v = score.setdefault(fc["pid"], [0, 0.0]); v[0] += 1; v[1] += fc["area"]
main = max(score, key=lambda p: (score[p][0], score[p][1])) if score else -1
return {"people": sorted(score), "main": main}
def person_change_points(cast: dict, start: int, end: int, min_run: int) -> list[int]:
"""Frames inside [start, end) where the main (largest) face switches to another person for at least min_run."""
runs = []
for smp in cast["samples"]:
if start <= smp["f"] < end:
main = max(smp["faces"], key=lambda fc: fc["area"])["pid"] if smp["faces"] else -1
if main >= 0 and (not runs or runs[-1][1] != main):
runs.append([smp["f"], main])
pts = []
for (f0, p0), (f1, p1) in zip(runs, runs[1:]):
if f1 - (pts[-1] if pts else start) >= min_run and end - f1 >= min_run:
pts.append(f1)
return pts
# ---------------------------------------------------------------------------- shot masks (SAM 3) and crop / uncrop
SAM3_FILE = "sam3.1_multiplex_fp16.safetensors"
@@ -1231,6 +858,42 @@ def segment_frames(imgs: torch.Tensor, spec: dict) -> torch.Tensor:
return torch.cat([back[:-1], fwd], 0)
_POINT: dict[str, Any] = {} # the MolmoPoint model connected to a planner (its last run)
def _molmo_active(spec: dict) -> bool:
"""A text selection (no points, no mask video) is pointed by MolmoPoint while one is connected to the planner."""
m = _POINT.get("model")
return (m is not None and hasattr(m, "runtime") and bool(str(spec.get("text") or "").strip())
and not spec.get("points") and not spec.get("ext"))
def molmo_spec(imgs: torch.Tensor, spec: dict, fps: float) -> dict:
"""The selection's text pointed by MolmoPoint on the shot's frames [N,H,W,3]: the object it follows longest, as a
positive point on its frame nearest the middle of the shot, so SAM 3 tracks from there. Falls back to the text
when MolmoPoint finds nothing or fails."""
text = str(spec.get("text") or "").strip().rstrip(".")
noun = re.sub(r"^(the|a|an)\s+", "", text, flags=re.I)
try:
ans = _POINT["model"].runtime.track_subjects([f"Track the {noun}."], imgs, fps, max_fps=2.0)[0]
except Exception as e: # noqa: BLE001 - SAM 3 text still works
print(f"[BFS Shot] MolmoPoint failed ({e}); SAM 3 reads the text '{text}'", flush=True)
return spec
pts = ans.get("points") or []
if not pts:
print(f"[BFS Shot] MolmoPoint found no '{text}'; SAM 3 reads the text", flush=True)
return spec
objs: dict[int, list] = {}
for t, o, x, y in pts:
objs.setdefault(int(o), []).append((float(t), float(x), float(y)))
track = max(objs.values(), key=len)
mid = (imgs.shape[0] - 1) / 2.0
t, x, y = min(track, key=lambda p: abs(p[0] * fps - mid))
key = max(0, min(imgs.shape[0] - 1, int(round(t * fps))))
print(f"[BFS Shot] MolmoPoint: '{text}' -> {len(objs)} object(s), point ({x:.2f}, {y:.2f}) on frame {key}", flush=True)
return dict(spec, points=[{"x": x, "y": y, "label": 1}], key=key)
def _mask_key(path: str, analysis: dict, start: int, length: int, spec: dict) -> tuple:
ext = spec.get("ext")
if ext and "track" in ext:
@@ -1244,7 +907,8 @@ def _mask_key(path: str, analysis: dict, start: int, length: int, spec: dict) ->
else:
e = None
return (path, os.path.getmtime(path), float(analysis["fps"]), int(start), int(length),
json.dumps({k: spec[k] for k in ("text", "points", "key", "threshold", "max_objects")}, sort_keys=True), e)
json.dumps({k: spec[k] for k in ("text", "points", "key", "threshold", "max_objects")}, sort_keys=True), e) \
+ (("molmopoint",) if _molmo_active(spec) else ())
def shot_mask(path: str, analysis: dict, start: int, length: int, spec: dict) -> dict:
@@ -1267,6 +931,9 @@ def shot_mask(path: str, analysis: dict, start: int, length: int, spec: dict) ->
return out
frames = _read_frames(path, src[idx], (w, h), stage="Reading the shot")
imgs = torch.from_numpy(np.stack(frames).astype(np.float32) / 255.0)
if _molmo_active(spec):
_status("Pointing the selection with MolmoPoint", force=True)
spec = molmo_spec(imgs, spec, float(analysis["fps"]))
_status(f"Segmenting {len(frames)} frames with SAM 3", force=True)
what = f"{len(spec.get('points') or [])} points on frame {int(spec.get('key') or 0)}" if spec.get("points") \
else f"'{spec.get('text', '')}'"
@@ -1479,7 +1146,7 @@ def uncrop(result: torch.Tensor, shot: dict, edge_hardness: float = 0.0, match_c
def mask_preview(path: str, analysis: dict, start: int, length: int, spec: dict, count: int = 6,
color: str = "#ff283c", opacity: float = 0.55) -> dict:
color: str = "#ff283c", opacity: float = 0.55, style: str = "overlay") -> dict:
"""A few frames of the shot with the selected region tinted and the crop box, as data URLs."""
import cv2
r = shot_mask(path, analysis, start, length, spec)
@@ -1496,7 +1163,11 @@ def mask_preview(path: str, analysis: dict, start: int, length: int, spec: dict,
for i, fr in zip(picks, frames):
img = fr.copy()
m = masks[min(i, masks.shape[0] - 1)].numpy().astype(bool)
img[m] = (img[m].astype(np.float32) * (1 - alpha) + tint * alpha).astype(np.uint8)
if style == "hatching" and m.any(): # the hatching itself; 'normal map' needs normals, so it previews as a tint
frame = torch.from_numpy(img.astype(np.float32) / 255.0)[None]
img = (paint_mark(frame, torch.from_numpy(m)[None], color, opacity, "hatching")[0].numpy() * 255).astype(np.uint8)
else:
img[m] = (img[m].astype(np.float32) * (1 - alpha) + tint * alpha).astype(np.uint8)
if box:
x0, y0, x1, y1 = box_px(box, w, h)
cv2.rectangle(img, (x0, y0), (x1 - 1, y1 - 1), (255, 210, 90), 2)
@@ -2206,6 +1877,8 @@ def shot_mode(seg: dict) -> str:
def auto_prompt_for(instruction: str, names: list, seg: dict, template: str = "", style: str = "h3", prompt_trigger: str = "motion_transfer") -> str:
"""What a written prompt was made from (the panel computes the same string to show it is out of date)."""
marks = [[x["picture"], x["color"]] for x in seg.get("color_guide_marks") or []] if seg.get("color_guide") else []
if marks and seg.get("color_guide_style") in COLOR_GUIDE_STYLES[1:]:
marks.append(seg["color_guide_style"])
return json.dumps([instruction or "", names, marks, shot_mode(seg), (seg.get("mask") or {}).get("text") or "",
seg.get("target") or "", template or "", style or "h3", prompt_trigger, AUTO_PROMPT_REVISION], separators=(",", ":"), ensure_ascii=False)
@@ -2227,8 +1900,8 @@ def write_h3_prompt(clip, frames: list, times: list, pictures: list, picture_tex
notes = []
if seg.get("color_guide"):
marks = seg.get("color_guide_marks") or []
who = "; ".join(f"the subject marked in {_COLOR_GUIDE_NAMES.get(x['color'], x['color'])} follows the appearance from "
f"<Picture {x['picture']}>" for x in marks)
who = "; ".join(f"the subject {mark_phrase(_COLOR_GUIDE_NAMES.get(x['color'], x['color']), seg.get('color_guide_style'))} "
f"follows the appearance from <Picture {x['picture']}>" for x in marks)
notes.append("In the source frames, colored subject markings identify performers, not their appearance: "
f"{who}. Preserve these color-to-picture bindings throughout the performance. Describe markings "
"generically; facial highlights convey expression. The target uses the reference identities and "
@@ -2332,7 +2005,7 @@ def tidy_caption(text: str, seg: dict | None = None, trigger: str | None = None)
t = " ".join(drop_repeats(t).split())
t = re.sub(r"(?<![<\w])(Picture) (\d+)(?![\w>])", r"<\1 \2>", t)
if seg and seg.get("color_guide"):
t = color_guide_prompt(t, seg.get("color_guide_marks") or [])
t = color_guide_prompt(t, seg.get("color_guide_marks") or [], seg.get("color_guide_style"))
if trigger is not None:
t = re.sub(r"^(?:motion_transfer|body_swap|vfx_edit):\s*", "", t, flags=re.I)
if trigger:t = trigger + ": " + t
@@ -2455,7 +2128,7 @@ def tidy_h3_prompt(text: str, seg: dict | None = None, trigger: str = "", cuts:
# 'Picture 1' / 'Subject 2' / 'Video 1' written without their brackets
t = re.sub(r"(?<![<\w])(Picture|Subject|Video|Audio) (\d+)(?![\w>])", r"<\1 \2>", t)
if seg and seg.get("color_guide"):
t = color_guide_prompt(t, seg.get("color_guide_marks") or [])
t = color_guide_prompt(t, seg.get("color_guide_marks") or [], seg.get("color_guide_style"))
return t.strip()
@@ -3386,8 +3059,12 @@ class BFSShotPlanner:
"frame; a different count is spread over the video."}),
"vlm": ("CLIP", {"tooltip": "Optional vision-language model (CLIPLoader with qwen3vl_4b / qwen3vl_8b). "
"It looks at every shot and suggests what to segment, a description of the "
"shot (fills {shot} in the prompt) and whether to run it. Settings in the "
"shot (fills {shot} in the prompt). Settings in the "
"panel's VLM card."}),
"point_model": ("MOLMO_POINT_MODEL", {"tooltip": "Optional MolmoPoint-8B (Auto Download & Load MolmoPoint-8B). "
"When connected, a selection written as text ('the man on the left', 'the red car') is "
"pointed by MolmoPoint and SAM 3 tracks from those points, instead of SAM 3 reading the "
"text. The panel's previews use it after one run."}),
},
# the queued workflow: what renders the shots (LoRA, seed, sampler...) is part of a stored shot's signature
"hidden": {"graph": "PROMPT", "node_id": "UNIQUE_ID"},
@@ -3427,7 +3104,7 @@ class BFSShotPlanner:
pass
return plan
def plan_shots(self, plan, prompt=None, vlm=None, mask=None, graph=None, node_id=None, ref_image=None,
def plan_shots(self, plan, prompt=None, vlm=None, mask=None, graph=None, node_id=None, ref_image=None, point_model=None,
ref_image_2=None):
p = _load_plan(plan)
if ref_image is not None or ref_image_2 is not None:
@@ -3437,6 +3114,7 @@ class BFSShotPlanner:
"are ignored. Set the references in the panel (Prompts & refs tab, up to 100).", flush=True)
if vlm is not None:
_VLM["clip"] = vlm # the panel's Analyse button uses it too
_POINT["model"] = point_model # text selections are pointed by MolmoPoint while it is connected
vcfg = vlm_cfg(p.get("vlm_cfg"))
use_vlm = vlm is not None and vcfg["enabled"]
suggestions = []
@@ -3447,12 +3125,10 @@ class BFSShotPlanner:
_EXT_MASK[path] = mask.detach().cpu()
else:
_EXT_MASK.pop(path, None)
if p.get("cast") or p.get("cast_split") or p.get("cast_only"):
analyze_cast(path, a) # warms the cache so the plan uses the same people as the panel
all_segs = apply_filters(p, a, path, resolve_plan(p, a, path))
all_segs = mark_runs(p, resolve_plan(p, a, path))
segs = [s for s in all_segs if s["run"]]
if not segs:
raise ValueError("BFS Shot Planner: no shot is left to run (all disabled or filtered out).")
raise ValueError("BFS Shot Planner: no shot is left to run (all disabled).")
W, H = plan_generation_size(a["width"], a["height"], p, int(p["multiple"]))
src = _timeline(a["n_src"], a["fps_src"], fps)
audio = _read_audio(path) if p.get("audio_mode", "auto") != "silent" else None
@@ -3645,7 +3321,7 @@ class BFSShotPlanner:
tag=f"[BFS Shot Planner] shot {i + 1}", keep_missing=vlm is None)
shot_prompt = fill_target(template, s.get("target", "") or auto_target.get(i, ""))
if s.get("color_guide") and i not in written: # a written prompt already has the mapping
shot_prompt = color_guide_prompt(shot_prompt, s["color_guide_marks"])
shot_prompt = color_guide_prompt(shot_prompt, s["color_guide_marks"], s.get("color_guide_style"))
shots.append({
"index": i, "count": len(segs), "start": s["start"], "end": s["end"],
"length": s["end"] - s["start"], "gen_length": glen, "head": head, "fps": fps,
@@ -3656,6 +3332,7 @@ class BFSShotPlanner:
"color_guide": bool(s.get("color_guide")),
"color_guide_color": str(s.get("color_guide_color") or "#00ff00"),
"color_guide_opacity": float(s.get("color_guide_opacity") or 0.35),
"color_guide_style": s.get("color_guide_style") or COLOR_GUIDE_STYLES[0],
"color_guide_text2": str(s.get("color_guide_text2") or ""),
"color_guide_color2": str(s.get("color_guide_color2") or "#9b30ff"),
"color_guide_points2": list(s.get("color_guide_points2") or []),
@@ -3690,7 +3367,7 @@ class BFSShotPlanner:
"src": src.tolist(), "n": a["n"]}
if queue: # the panel's Combine assembles the stored shots on this timeline (original video where none ran)
torch.save(timeline, os.path.join(run_dir(rid), "timeline.pt"))
skipped = [(i, s.get("skip_reason") or ("disabled" if not s["enabled"] else "skip")) for i, s in enumerate(all_segs) if not s["run"]]
skipped = [(i, "disabled") for i, s in enumerate(all_segs) if not s["run"]]
lines = [f"{len(segs)} shots, {total} frames ({total / fps:.2f}s) at {fps:g} fps, {W}x{H}"
+ (" | every shot stored: assembling only" if assemble_only else
f" | queue loop: running shot {shots[0]['index'] + 1}/{len(segs)}" if queue else "")]
@@ -3934,15 +3611,15 @@ _LIMB_RGB = [(255, 0, 0), (255, 85, 0), (255, 170, 0), (255, 255, 0), (170, 255,
def pose_frames(frames: torch.Tensor, mask: torch.Tensor | None = None) -> torch.Tensor:
"""The skeleton (COCO-17, OpenPose colours) of the people inside the mask on black, [F,H,W,3]. Uses a YOLO pose
model from models/ultralytics (e.g. pose/yolov8m-pose.pt), the same place and package as the planner's filters."""
model from models/ultralytics (e.g. pose/yolov8m-pose.pt), the same place as the other ultralytics models."""
import cv2
model = _yolo("pose")
if model is None:
try:
import ultralytics # noqa: F401
except ImportError:
raise ValueError("mask_guide_look 'pose' needs the ultralytics package (the same one the Impact Pack and "
"the planner's filters use): pip install ultralytics") from None
raise ValueError("mask_guide_look 'pose' needs the ultralytics package (the same one the Impact Pack "
"uses): pip install ultralytics") from None
raise ValueError("mask_guide_look 'pose' needs a YOLO pose model in ComfyUI/models/ultralytics/pose/, e.g. "
"yolov8m-pose.pt from https://github.com/ultralytics/assets/releases/download/v8.2.0/"
"yolov8m-pose.pt (no restart needed after adding it)")
@@ -4027,6 +3704,59 @@ def color_guide(frames: torch.Tensor, mask: torch.Tensor, color: str = "#00ff00"
return (frames[..., :3].float() * (1 - alpha) + tint * alpha).clamp(0, 1)
# how a Colored Mark is drawn: translucent overlay (default), colored hatching + contour, or the subject's normal map
# tinted with its color (needs MoGe normals); the last two are BFS Video Segment Picker's mark styles
COLOR_GUIDE_STYLES = ("overlay", "hatching", "normal map")
def paint_mark(frames: torch.Tensor, mask: torch.Tensor, color: str = "#00ff00", opacity: float = 0.35,
style: str = "overlay", normals: torch.Tensor | None = None) -> torch.Tensor:
"""Draw one Colored Mark on the guide frames [N,H,W,3] in the given style. normals: MoGe normals of the same frames
(Render MoGe Geometry, normal_directx), only for 'normal map'."""
if style not in COLOR_GUIDE_STYLES[1:]:
return color_guide(frames, mask, color, opacity)
from .bfs_video_segment_picker import hatching_alpha, hybrid_tint, to_opencv_normals
try:
rgb = [int(color[i:i + 2], 16) for i in (1, 3, 5)]
except (TypeError, ValueError, IndexError):
raise ValueError(f"Colored Mark: invalid color {color!r}; expected a hex color such as #00ff00") from None
N, H, W = frames.shape[:3]
if mask.ndim == 2:
mask = mask[None]
m = torch.nn.functional.interpolate(mask[:, None].float(), size=(H, W), mode="nearest")[:, 0].cpu() > 0.5
m = m[torch.clamp(torch.arange(N), max=m.shape[0] - 1)]
op = min(1.0, max(0.05, float(opacity)))
out = frames[..., :3].float().clone()
if style == "hatching":
first = next((x for x in m if bool(x.any())), None)
ys, xs = torch.where(first) if first is not None else ([], [])
scale = max(64, min(int(xs.max() - xs.min() + 1), int(ys.max() - ys.min() + 1))) if len(xs) else 64
tint = torch.tensor(rgb, dtype=out.dtype, device=out.device) / 255.0
for i in range(N):
if bool(m[i].any()):
a = hatching_alpha(m[i], scale)[..., None].to(out.device, out.dtype) * op
out[i] = out[i] * (1 - a) + tint * a
return out.clamp(0, 1)
if normals is None:
raise ValueError("Colored Mark 'normal map' needs MoGe normals of the shot: run MoGe on BFS Shot Unpack's frames "
"(Render MoGe Geometry, normal_directx) and connect it to BFS Shot H3 Conditioning -> normals, "
"or choose the overlay or hatching style.")
if normals.shape[0] != N:
raise ValueError(f"Colored Mark 'normal map': {normals.shape[0]} normal maps for {N} guide frames; run MoGe on "
"the same frames (BFS Shot Unpack's frames).")
nm = normals[..., :3].float()
if nm.shape[1:3] != (H, W):
nm = torch.nn.functional.interpolate(nm.movedim(-1, 1), size=(H, W), mode="bilinear",
align_corners=False).movedim(1, -1)
nm = to_opencv_normals(nm.to(out.device), "normal_directx")
for i in range(N):
if bool(m[i].any()):
fill = hybrid_tint(nm[i], rgb)
a = m[i].to(out.device, out.dtype)[..., None] * op
out[i] = out[i] * (1 - a) + fill.to(out.dtype) * a
return out.clamp(0, 1)
# one mark color per picture, <Picture 1> first: the first seven are the colors the first Colored Mark sets used
MARK_PALETTE = ["#00ff00", "#9b30ff", "#ff2020", "#168cff", "#ffe600", "#ff00d4", "#00e5c0", "#ff8000", "#ffffff",
"#8b4513", "#ff9ec4", "#808000"]
@@ -4083,7 +3813,16 @@ def extra_mark_masks(shot: dict, log: str = "") -> list[tuple]:
return out
def color_guide_prompt(prompt: str, marks: list[dict]) -> str:
def mark_phrase(name: str, style: str | None = None) -> str:
"""How a Colored Mark of this color is drawn, in words ('marked in green' for the translucent overlay)."""
if style == "hatching":
return f"marked with {name} hatching lines and a {name} contour"
if style == "normal map":
return f"drawn as a {name} surface-normal map"
return f"marked in {name}"
def color_guide_prompt(prompt: str, marks: list[dict], style: str | None = None) -> str:
"""Say which picture each mark color becomes, inside the prompt's subject_definitions / detailed_description when
it has them (else at the end). Written for any model and LoRA: the person marked in a color becomes the character
of that color's picture."""
@@ -4095,7 +3834,7 @@ def color_guide_prompt(prompt: str, marks: list[dict]) -> str:
pairs.append((name, x["picture"]))
if not pairs:
return str(prompt or "")
who = "; ".join(f"the subject marked in {name} uses the character appearance from <Picture {k}>" for name, k in pairs)
who = "; ".join(f"the subject {mark_phrase(name, style)} uses the character appearance from <Picture {k}>" for name, k in pairs)
mapping = (f"Color bindings: {who}. Keep each binding fixed through position changes, occlusion and cuts, "
"transferring the guide performance and timing. Markings and facial highlights are guidance only; "
"render reference appearances with the requested visual changes, including scene or style changes, "
@@ -4322,7 +4061,7 @@ class BFSShotH3Conditioning:
"without colours or a face (default). colour: as it is (can make the model copy the old subject). "
"silhouette: only its flat shape. edges: only its outlines. pose (people): the skeleton of the "
"people in the mask (OpenPose colours), from a YOLO pose model in models/ultralytics (e.g. "
"pose/yolov8m-pose.pt; uses ultralytics like the planner's filters). The less it shows, the less "
"pose/yolov8m-pose.pt; uses ultralytics). The less it shows, the less "
"the model can copy the old subject; the more, the better it follows the pose."}),
"mask_ref_size": (list(MASK_REF_SIZES), {"default": "1/2", "tooltip":
"mask_guide '+ reference video' only: size of that reference video (fraction of the generation "
@@ -4336,6 +4075,9 @@ class BFSShotH3Conditioning:
"tooltip": "Native temporal mode only: spatial scale of the motion video before encoding; output size, frame count and identity references stay unchanged. Try 0.5 to reduce guide tokens/VRAM."}),
"native_temporal_backend": (["flash partitions (no compile)", "flex (original)", "comfy kitchen (experimental)"], {"default": "flash partitions (no compile)",
"tooltip": "Native temporal mode only. Flash partitions preserves the temporal attention policy without compilation; Flex is the original implementation."}),
"normals": ("IMAGE", {"tooltip": "Optional MoGe normals of the shot's frames (BFS Shot Unpack's frames -> "
"Run MoGe Inference -> Render MoGe Geometry, normal_directx). Only for shots "
"whose Colored Mark style is 'normal map'."}),
},
}
@@ -4349,7 +4091,8 @@ class BFSShotH3Conditioning:
def condition(self, shot, clip, vae, guide_mode, first_frame, ref_image_size,
audio_vae=None, with_audio=False, model=None, vlm=None, setting_ref="off", setting_mask=None, inpaint="off", mask_guide="off", mask_ref_size="1/2", mask_guide_look="grey blurred",
source_blur="off", source_blur_strength=1.0, source_blur_segmenter="SAM 3.1", source_blur_effect="blur", clip_video_fps=2.0, native_guide_scale=1.0, native_temporal_backend="flash partitions (no compile)"):
source_blur="off", source_blur_strength=1.0, source_blur_segmenter="SAM 3.1", source_blur_effect="blur", clip_video_fps=2.0, native_guide_scale=1.0, native_temporal_backend="flash partitions (no compile)",
normals=None):
from comfy_extras.nodes_minimax_h3 import MiniMaxH3AddGuide, MiniMaxH3ReferenceToVideo
# every picture of the shot, <Picture 1> first (chosen in the planner; use_ref_2 left in 1.113)
refs = {f"ref_image_{k}": r for k, r in enumerate(shot_refs(shot)[:MAX_REFS])}
@@ -4363,7 +4106,7 @@ class BFSShotH3Conditioning:
for x in marks:
if owner.setdefault(x["color"], x["picture"]) != x["picture"]:
raise ValueError(f"Colored Mark: <Picture {owner[x['color']]}> and <Picture {x['picture']}> have the "
f"same mark color ({x['color']}). Give one of them another color (Prompts & refs tab).")
f"same mark color ({x['color']}). Give one of them another color (Pictures & prompt tab).")
prev = chain_image(shot)
if prev is not None and shot.get("chain") == "reference":
refs[f"ref_image_{len(refs)}"] = prev # one more <Picture n>, after the shot's own
@@ -4426,16 +4169,17 @@ class BFSShotH3Conditioning:
if not shot.get("mask_src") and not shot.get("crop"):
raise ValueError(f"{gtag}: Colored Mark needs a person selection (SAM 3 text/points or a mask video)")
cmask = generation_mask(shot, log=gtag)
seen = color_guide(seen, cmask, shot.get("color_guide_color", "#00ff00"),
shot.get("color_guide_opacity", 0.35))
mstyle = shot.get("color_guide_style") or COLOR_GUIDE_STYLES[0]
seen = paint_mark(seen, cmask, shot.get("color_guide_color", "#00ff00"),
shot.get("color_guide_opacity", 0.35), mstyle, normals)
for mk, x in extra_mark_masks(shot, gtag):
cmask2 = torch.nn.functional.interpolate(mk[:, None].float(), size=shot["frames"].shape[1:3],
mode="nearest")[:, 0]
seen = color_guide(seen, cmask2, x["color"], shot.get("color_guide_opacity", 0.35))
seen = paint_mark(seen, cmask2, x["color"], shot.get("color_guide_opacity", 0.35), mstyle, normals)
print(f"{gtag}: Colored Mark <Picture {x['picture']}> ({len(x.get('points') or [])} points"
f"{', ' + repr(x['text']) if x.get('text') else ''}), {x['color']} at "
f"{float(shot.get('color_guide_opacity', 0.35)):.2f}, {mask_report(cmask2)}", flush=True)
print(f"{gtag}: Colored Mark {shot.get('color_guide_color', '#00ff00')} at "
print(f"{gtag}: Colored Mark ({mstyle}) {shot.get('color_guide_color', '#00ff00')} at "
f"{float(shot.get('color_guide_opacity', 0.35)):.2f} opacity, {mask_report(cmask)}", flush=True)
pg = mask_guide if mask_guide in MASK_GUIDES[1:] else None
if pg and (not use_mask or shot.get("crop")):
@@ -4617,6 +4361,9 @@ class BFSShotH3ConditioningSimple:
"tooltip": "Native temporal mode only: spatial scale of the motion video before encoding; output size, frame count and identity references stay unchanged. Try 0.5 to reduce guide tokens/VRAM."}),
"native_temporal_backend": (["flash partitions (no compile)", "flex (original)", "comfy kitchen (experimental)"], {"default": "flash partitions (no compile)",
"tooltip": "Native temporal mode only. Flash partitions preserves the temporal attention policy without compilation; Flex is the original implementation."}),
"normals": ("IMAGE", {"tooltip": "Optional MoGe normals of the shot's frames (BFS Shot Unpack's frames -> "
"Run MoGe Inference -> Render MoGe Geometry, normal_directx). Only for shots "
"whose Colored Mark style is 'normal map'."}),
},
}
@@ -4629,13 +4376,15 @@ class BFSShotH3ConditioningSimple:
def condition(self, shot, clip, vae, guide_mode, ref_image_size,
audio_vae=None, with_audio=False, vlm=None, inpaint=INPAINT_MODES[0],
source_blur="off", source_blur_strength=1.0, source_blur_segmenter="SAM 3.1", source_blur_effect="blur", clip_video_fps=2.0, model=None, native_guide_scale=1.0, native_temporal_backend="flash partitions (no compile)"):
source_blur="off", source_blur_strength=1.0, source_blur_segmenter="SAM 3.1", source_blur_effect="blur", clip_video_fps=2.0, model=None, native_guide_scale=1.0, native_temporal_backend="flash partitions (no compile)",
normals=None):
positive, latent, patched_model, prompt = BFSShotH3Conditioning().condition(
shot, clip, vae, guide_mode, "none", ref_image_size,
audio_vae=audio_vae, with_audio=with_audio, vlm=vlm, inpaint=inpaint,
source_blur=source_blur, source_blur_strength=source_blur_strength,
source_blur_segmenter=source_blur_segmenter, source_blur_effect=source_blur_effect,
clip_video_fps=clip_video_fps, model=model, native_guide_scale=native_guide_scale, native_temporal_backend=native_temporal_backend)
clip_video_fps=clip_video_fps, model=model, native_guide_scale=native_guide_scale, native_temporal_backend=native_temporal_backend,
normals=normals)
return positive, latent, prompt, patched_model
@@ -5764,7 +5513,8 @@ try:
async def _bfs_shot_files(request):
# images are only listed on request: an input folder can hold thousands of them
images = _list_input(IMAGE_EXTS) if request.query.get("images") in ("1", "true") else []
return web.json_response({"videos": _list_input(VIDEO_EXTS), "images": images})
return web.json_response({"videos": _list_input(VIDEO_EXTS), "images": images,
"point_model": _POINT.get("model") is not None})
@PromptServer.instance.routes.get("/bfs/shotloop/analyze")
async def _bfs_shot_analyze(request):
@@ -5785,11 +5535,6 @@ try:
def work():
a = analyze(_input_path(p["video"]), float(p["fps"]))
path = _input_path(p["video"])
if p.get("cast") or p.get("cast_split") or p.get("cast_only"):
try:
analyze_cast(path, a)
except Exception: # noqa: BLE001 - plan without people
pass
segs = resolve_plan(p, a, path)
cuts, used = find_cuts(path, a, p.get("detector", "adaptive"), float(p["sensitivity"]))
return a, segs, cuts, used
@@ -5799,28 +5544,6 @@ try:
return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500)
return web.json_response({"segs": segs, "cuts": cuts, "width": W, "height": H, "detector": used,
"max_len": snap_down(int(round(float(p["max_s"]) * float(p["fps"]))), p["grid"])})
@PromptServer.instance.routes.post("/bfs/shotloop/filters")
async def _bfs_shot_filters(request):
body = await request.json()
try:
p = _load_plan(json.dumps(body.get("plan", {})))
f = dict(DEFAULT_FILTERS); f.update(p.get("filters") or {})
path = _input_path(p["video"])
def work():
a = analyze(path, float(p["fps"]))
segs = resolve_plan(p, a, path)
for i, s in enumerate(segs): # stats always, so the panel can show them before any filter is on
_status("Detecting people and faces", i, len(segs))
s["stats"] = shot_stats(path, a, s, int(f.get("samples") or 6))
s["skip_reason"] = skip_reason(s["stats"], s["end"] - s["start"], f)
return segs
segs = await _off_loop(work)
except Exception as exc: # noqa: BLE001
return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500)
return web.json_response({"segs": [{"start": s["start"], "end": s["end"], "stats": s["stats"],
"skip_reason": s["skip_reason"]} for s in segs]})
@PromptServer.instance.routes.get("/bfs/shotloop/frame")
async def _bfs_shot_frame(request):
import cv2
@@ -5862,7 +5585,8 @@ try:
None, lambda: _like_a_node(lambda: mask_preview(path, a, seg["start"], seg["end"] - seg["start"], spec,
int(body.get("count", 6)),
preview_color,
seg.get("color_guide_opacity") if seg.get("color_guide") else 0.55)))
seg.get("color_guide_opacity") if seg.get("color_guide") else 0.55,
seg.get("color_guide_style") or "overlay" if seg.get("color_guide") else "overlay")))
except Exception as exc: # noqa: BLE001
return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500)
return web.json_response(out)
@@ -5987,17 +5711,6 @@ try:
return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500)
return web.json_response({"prompts": prompts})
@PromptServer.instance.routes.post("/bfs/shotloop/cast")
async def _bfs_shot_cast(request):
body = await request.json()
try:
p = _load_plan(json.dumps(body.get("plan", {})))
path = _input_path(p["video"])
c = await _off_loop(lambda: analyze_cast(path, analyze(path, float(p["fps"]))))
except Exception as exc: # noqa: BLE001
return web.json_response({"error": f"{type(exc).__name__}: {exc}"}, status=500)
return web.json_response({"people": c["people"], "step": c["step"], "samples": len(c["samples"])})
@PromptServer.instance.routes.get("/bfs/shotloop/vram")
async def _bfs_shot_vram(request):
try:
+13 -3
View File
@@ -140,6 +140,8 @@ class BFSVideoCompare:
"size) or a batch; up to 12."}),
"label_guide": ("STRING", {"default": "guide"}),
"label_target": ("STRING", {"default": "target"}),
"free_memory_after": ("BOOLEAN", {"default": True, "tooltip":
"After the workflow finishes, release cached node outputs and unloaded model weights from RAM. The next run reloads models."}),
},
}
@@ -157,10 +159,10 @@ class BFSVideoCompare:
"where they differ, pixel by pixel.")
def compare(self, guide, target, fps=24.0, audio=None, gain=2.0, size="guide", preview_height=720,
label_guide="guide", label_target="target", reference=None):
label_guide="guide", label_target="target", reference=None, free_memory_after=True):
one = lambda v: v[0] if isinstance(v, list) and v else v
fps, audio, gain, size, preview_height, label_guide, label_target = map(
one, (fps, audio, gain, size, preview_height, label_guide, label_target))
fps, audio, gain, size, preview_height, label_guide, label_target, free_memory_after = map(
one, (fps, audio, gain, size, preview_height, label_guide, label_target, free_memory_after))
guide, target = video_batches(guide, "guide"), video_batches(target, "target")
ng, nt = sum(x.shape[0] for x in guide), sum(x.shape[0] for x in target)
if len(guide) > 1 or len(target) > 1:
@@ -226,6 +228,14 @@ class BFSVideoCompare:
"refs": refs,
"stills": {k: {"filename": f, "subfolder": sub, "type": "temp"} for k, f in stills.items()},
"report": report, "notes": notes}]}
if free_memory_after:
try:
from server import PromptServer
except ImportError: # the comparison can also run outside ComfyUI
PromptServer = None
queue = getattr(getattr(PromptServer, "instance", None), "prompt_queue", None)
if queue is not None:
queue.set_flag("free_memory", True)
return {"ui": ui, "result": (diff_map, report, mean)}
File diff suppressed because it is too large Load Diff
+227
View File
@@ -0,0 +1,227 @@
"""BFS Workflow Loop: run any part of any workflow once per item, injecting the item (and per-iteration parameters)
into nodes you pick, and collect the results in order.
Connect the IMAGE the loop should produce to `result`, and the batch to iterate over to `items` (frames, keyframes,
marked frames...) and optionally `item_masks`. In the panel pick, among the nodes upstream of `result`:
- inject: a node whose output is replaced, on iteration i, by item i (e.g. a Load Image that stands for "the frame",
or a mask loader for "its mask"),
- params: widgets that take one value per iteration (a seed, a prompt line, a strength...), cycled if shorter.
On run the node does not compute `result` itself (lazy input): it expands one copy per iteration of the part of the graph
that depends on the injected nodes or the changed widgets; models and everything else are shared and run once.
State lives in the hidden `loop` STRING widget (JSON), saved with the workflow:
{"inject": [{"node": "6", "slot": 0, "source": "items"}],
"params": [{"node": "34", "widget": "noise_seed", "values": [1, 2, 3]}]}
"""
import json
import torch
import torch.nn.functional as F
from .bfs_xy_sweep import _cast
CATEGORY = "BFS/Loop"
MAX_ITEMS = 256
def parse_loop(text):
try:
s = json.loads(text or "{}") or {}
except Exception:
s = {}
inject = [{"node": str(i["node"]), "slot": int(i.get("slot", 0)), "source": i.get("source", "items")}
for i in s.get("inject", []) if isinstance(i, dict) and i.get("node") not in (None, "")]
params = [{"node": str(p["node"]), "widget": str(p["widget"]), "values": list(p["values"])}
for p in s.get("params", []) if isinstance(p, dict) and p.get("node") not in (None, "") and p.get("widget")
and isinstance(p.get("values"), list) and p["values"]]
return inject, params
class BFSWorkflowLoop:
DESCRIPTION = __doc__.split("\n\n")[0]
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"result": ("IMAGE", {"lazy": True, "tooltip": "What one iteration produces; everything upstream of it can be looped."}),
"loop": ("STRING", {"default": "{}", "multiline": True, "tooltip": "Set by the panel: injected nodes and per-iteration widgets."}),
}, "optional": {
"items": ("IMAGE", {"tooltip": "The batch to iterate over: item i is injected on iteration i."}),
"items_b": ("IMAGE", {"tooltip": "A second batch, same length (e.g. the marked frames next to the plain ones)."}),
"item_masks": ("MASK", {"tooltip": "One mask per item, injected where the panel says (source 'item_masks')."}),
"count": ("INT", {"default": 0, "min": 0, "max": MAX_ITEMS,
"tooltip": "Iterations when there are no items (params only); 0 = as many as the longest value list."}),
}, "hidden": {"dynprompt": "DYNPROMPT", "unique_id": "UNIQUE_ID"}}
RETURN_TYPES = ("IMAGE", "INT")
RETURN_NAMES = ("images", "count")
OUTPUT_TOOLTIPS = ("The result of every iteration, in order, as one batch (resized to the first one's size).", "Iterations run.")
FUNCTION = "run_loop"
CATEGORY = CATEGORY
def check_lazy_status(self, result=None, **kw):
return []
def run_loop(self, loop="{}", result=None, items=None, items_b=None, item_masks=None, count=0, dynprompt=None, unique_id=None):
inject, params = parse_loop(loop)
if not inject and not params:
raise ValueError("BFS Workflow Loop: pick a node to inject the items into, or a widget to change, in the panel.")
if dynprompt is None:
raise ValueError("BFS Workflow Loop needs ComfyUI's dynamic graph execution")
from comfy_execution.graph_utils import GraphBuilder, is_link
me = str(unique_id); mine = dynprompt.get_node(me)["inputs"]
link = mine.get("result")
if not is_link(link):
raise ValueError("Connect what one iteration produces to `result`.")
n = int(items.shape[0]) if items is not None else (int(count) or max(len(p["values"]) for p in params) if params else 0)
if any(i["source"] == "items" for i in inject) and items is None:
raise ValueError("The panel injects `items`, but nothing is connected to items.")
if any(i["source"] == "items_b" for i in inject):
if items_b is None:
raise ValueError("The panel injects `items_b`, but nothing is connected to items_b.")
if items is not None and items_b.shape[0] != n:
raise ValueError(f"{items_b.shape[0]} items_b for {n} items.")
n = n or int(items_b.shape[0])
if any(i["source"] == "item_masks" for i in inject):
if item_masks is None:
raise ValueError("The panel injects `item_masks`, but nothing is connected to item_masks.")
if items is not None and item_masks.shape[0] != n:
raise ValueError(f"{item_masks.shape[0]} masks for {n} items.")
if n < 1:
raise ValueError("Nothing to iterate over: connect items or give parameter values.")
if n > MAX_ITEMS:
raise ValueError(f"{n} iterations; at most {MAX_ITEMS}.")
root = str(link[0])
up, feeds, stack = set(), {}, [root]
while stack:
nid = stack.pop()
if nid in up:
continue
up.add(nid)
for v in dynprompt.get_node(nid).get("inputs", {}).values():
if is_link(v):
p = str(v[0]); feeds.setdefault(p, set()).add(nid); stack.append(p)
for i in inject:
if i["node"] not in up:
raise ValueError(f"Injected node {i['node']} is not upstream of `result` (pick it again in the panel).")
for p in params:
if p["node"] not in up:
raise ValueError(f"Node {p['node']} is not upstream of `result` (pick it again in the panel).")
if p["widget"] not in dynprompt.get_node(p["node"]).get("inputs", {}):
raise ValueError(f"Node {p['node']} has no widget '{p['widget']}' in this prompt.")
injected = {(i["node"], i["slot"]): i["source"] for i in inject}
# the body: everything that depends on an injected output or a changed widget and leads to `result`
seeds = [p["node"] for p in params] + [c for i in inject for c in feeds.get(i["node"], ()) if c in up]
body, stack = set(), list(seeds)
while stack:
nid = stack.pop()
if nid in body:
continue
body.add(nid)
stack.extend(c for c in feeds.get(nid, ()) if c in up)
body -= {i["node"] for i in inject if i["node"] not in {p["node"] for p in params}}
graph = GraphBuilder()
outs = []
for it in range(n):
picks = {}
for src in set(injected.values()):
pk = graph.node("BFSLoopPick", f"pick{it}_{src}")
if src == "item_masks":
pk.set_input("masks", mine["item_masks"])
else:
pk.set_input("images", mine[src])
pk.set_input("index", it)
picks[src] = pk
over = {(p["node"], p["widget"]): p["values"][it % len(p["values"])] for p in params}
clones = {nid: graph.node(dynprompt.get_node(nid)["class_type"], f"i{it}_{nid}") for nid in body}
for nid, node in clones.items():
node.set_override_display_id(dynprompt.get_display_node_id(nid))
for key, value in dynprompt.get_node(nid).get("inputs", {}).items():
if (nid, key) in over:
node.set_input(key, _cast(over[(nid, key)], value))
elif is_link(value) and (str(value[0]), int(value[1])) in injected:
src = injected[(str(value[0]), int(value[1]))]
node.set_input(key, picks[src].out(1 if src == "item_masks" else 0))
elif is_link(value) and str(value[0]) in body:
node.set_input(key, clones[str(value[0])].out(value[1]))
else:
node.set_input(key, value)
if root in clones:
outs.append(clones[root].out(link[1]))
elif (root, int(link[1])) in injected:
src = injected[(root, int(link[1]))]; outs.append(picks[src].out(1 if src == "item_masks" else 0))
else:
outs.append(link)
coll = graph.node("BFSLoopCollect", "collect")
for i, o in enumerate(outs):
coll.set_input(f"image_{i}", o)
coll.set_input("n", n)
return {"result": (coll.out(0), coll.out(1)), "expand": graph.finalize()}
class BFSLoopPick:
"""Internal: item `index` of a batch (image and/or mask)."""
@classmethod
def INPUT_TYPES(cls):
return {"required": {"index": ("INT", {"default": 0, "min": 0, "max": 100000})},
"optional": {"images": ("IMAGE",), "masks": ("MASK",)}}
RETURN_TYPES = ("IMAGE", "MASK")
FUNCTION = "pick"
CATEGORY = CATEGORY
def pick(self, index, images=None, masks=None):
img = images[index:index + 1] if images is not None else torch.zeros(1, 64, 64, 3)
if masks is not None:
m = masks if masks.ndim == 3 else masks[None]
msk = m[index:index + 1]
else:
msk = torch.zeros(1, img.shape[1], img.shape[2])
return img, msk
class BFSLoopCollect:
"""Internal: every iteration's result, in order, as one batch."""
@classmethod
def INPUT_TYPES(cls):
return {"required": {"n": ("INT", {"default": 1, "min": 1, "max": MAX_ITEMS})},
"optional": {f"image_{i}": ("IMAGE",) for i in range(MAX_ITEMS)}}
RETURN_TYPES = ("IMAGE", "INT")
FUNCTION = "collect"
CATEGORY = CATEGORY
def collect(self, n, **kw):
parts = [kw[f"image_{i}"] for i in range(n)]
h, w = parts[0].shape[1:3]
parts = [p if p.shape[1:3] == (h, w) else
F.interpolate(p.movedim(-1, 1), size=(h, w), mode="bilinear", align_corners=False).movedim(1, -1) for p in parts]
return torch.cat(parts), n
NODE_CLASS_MAPPINGS = {"BFSWorkflowLoop": BFSWorkflowLoop, "BFSLoopPick": BFSLoopPick, "BFSLoopCollect": BFSLoopCollect}
NODE_DISPLAY_NAME_MAPPINGS = {"BFSWorkflowLoop": "BFS Workflow Loop", "BFSLoopPick": "BFS Loop Pick (internal)",
"BFSLoopCollect": "BFS Loop Collect (internal)"}
class BFSLoopSlot:
"""A stand-in for "the current item" inside a BFS Workflow Loop body: pick it in the loop's Inject list and it gets
item i on iteration i. Outside a loop it gives a small gray image (no file to load, so validation never fails)."""
@classmethod
def INPUT_TYPES(cls):
return {"required": {"label": ("STRING", {"default": "item", "tooltip": "Only a name, to tell slots apart in the loop's list."})}}
RETURN_TYPES = ("IMAGE", "MASK")
RETURN_NAMES = ("image", "mask")
FUNCTION = "slot"
CATEGORY = CATEGORY
def slot(self, label="item"):
return torch.full((1, 64, 64, 3), 0.5), torch.zeros(1, 64, 64)
NODE_CLASS_MAPPINGS["BFSLoopSlot"] = BFSLoopSlot
NODE_DISPLAY_NAME_MAPPINGS["BFSLoopSlot"] = "BFS Loop Slot"
+241
View File
@@ -0,0 +1,241 @@
"""BFS XY Sweep: run any workflow over a grid of widget values and get one labeled grid back.
Connect the IMAGE you want to compare (a decoded video or an image) to `result`. In the panel, pick for X (and
optionally Y) any widget of any node upstream of that input (the panel lists them, like TinyTerra's advanced xyPlot)
and type the values. On run the node does not compute `result` itself (lazy input): it expands one copy of the part of
the graph that depends on the swept widgets per combination (everything else, models included, is shared and runs
once), then a collector builds the grid.
All state lives in the hidden `sweep` STRING widget (JSON), saved with the workflow:
{"x": {"node": "44", "widget": "lora_name", "values": ["a.safetensors", "b.safetensors"]},
"y": {"node": "101", "widget": "keyframe_until", "values": [0.5, 0.7, 0.9]}}
"""
import json
import os
import numpy as np
import torch
import torch.nn.functional as F
from PIL import Image, ImageDraw, ImageFont
try:
import folder_paths
except Exception: # tests run without ComfyUI
folder_paths = None
CATEGORY = "BFS/Loop"
MAX_CELLS = 64
def parse_sweep(text):
try:
s = json.loads(text or "{}") or {}
except Exception:
s = {}
axes = []
for key in ("x", "y"):
a = s.get(key) or {}
if a.get("node") not in (None, "") and a.get("widget") and isinstance(a.get("values"), list) and a["values"]:
axes.append({"node": str(a["node"]), "widget": str(a["widget"]), "values": list(a["values"]), "title": a.get("title", "")})
return axes
def _cast(value, like):
"""A typed value from the panel, cast to the type the widget had in the prompt."""
if isinstance(like, bool):
return value if isinstance(value, bool) else str(value).strip().lower() in ("1", "true", "yes", "on")
if isinstance(like, int) and not isinstance(like, bool):
return int(round(float(value)))
if isinstance(like, float):
return float(value)
return value if isinstance(value, str) else str(value)
def _short(v):
s = str(v)
if "/" in s or "\\" in s:
s = s.replace("\\", "/").rsplit("/", 1)[-1]
for ext in (".safetensors", ".ckpt", ".pt", ".png", ".jpg", ".mp4"):
if s.endswith(ext):
s = s[: -len(ext)]
return s if len(s) <= 40 else "…" + s[-39:]
def _font(size):
for name in ("DejaVuSans.ttf", "arial.ttf", "LiberationSans-Regular.ttf"):
try:
return ImageFont.truetype(name, size)
except Exception:
continue
return ImageFont.load_default()
class BFSXYSweep:
DESCRIPTION = __doc__.split("\n\n")[0]
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"result": ("IMAGE", {"lazy": True, "tooltip": "The image or decoded video to compare; the sweep runs everything upstream of it."}),
"frames_per_cell": ("INT", {"default": 4, "min": 1, "max": 16, "tooltip": "For videos: how many frames (evenly spread) each cell shows."}),
"cell_height": ("INT", {"default": 256, "min": 64, "max": 1024, "step": 16}),
"save_videos": ("BOOLEAN", {"default": True, "tooltip": "Save every multi-frame result as an mp4 in output/bfs_sweep/, named by its values."}),
"fps": ("FLOAT", {"default": 24.0, "min": 1.0, "max": 120.0}),
"sweep": ("STRING", {"default": "{}", "multiline": True, "tooltip": "Set by the panel: the swept widgets and their values."}),
}, "hidden": {"dynprompt": "DYNPROMPT", "unique_id": "UNIQUE_ID"}}
RETURN_TYPES = ("IMAGE", "IMAGE", "STRING")
RETURN_NAMES = ("grid", "results", "labels")
OUTPUT_TOOLTIPS = ("The labeled grid.", "Every result (the cells' frames), resized to one size.", "JSON: the values of every cell, in order.")
FUNCTION = "sweep_run"
CATEGORY = CATEGORY
OUTPUT_NODE = True
def check_lazy_status(self, result=None, **kw):
return [] # never evaluate `result` here: the expansion evaluates one copy per combination
def sweep_run(self, result=None, frames_per_cell=4, cell_height=256, save_videos=True, fps=24.0, sweep="{}",
dynprompt=None, unique_id=None):
axes = parse_sweep(sweep)
if not axes:
raise ValueError("BFS XY Sweep: choose at least the X widget and its values in the panel.")
if dynprompt is None:
raise ValueError("BFS XY Sweep needs ComfyUI's dynamic graph execution")
from comfy_execution.graph_utils import GraphBuilder, is_link
me = str(unique_id)
link = dynprompt.get_node(me)["inputs"].get("result")
if not is_link(link):
raise ValueError("Connect the image to compare to `result`.")
root = str(link[0])
# nodes upstream of `result` and who feeds whom inside that set
up, feeds, stack = set(), {}, [root]
while stack:
nid = stack.pop()
if nid in up:
continue
up.add(nid)
for v in dynprompt.get_node(nid).get("inputs", {}).values():
if is_link(v):
p = str(v[0]); feeds.setdefault(p, set()).add(nid); stack.append(p)
for a in axes:
if a["node"] not in up:
raise ValueError(f"Swept node {a['node']} is not upstream of `result` (pick it again in the panel).")
if a["widget"] not in dynprompt.get_node(a["node"]).get("inputs", {}):
raise ValueError(f"Node {a['node']} has no widget '{a['widget']}' in this prompt.")
# the body: what depends on a swept node and leads to `result` (cloned per cell); the rest is shared
body, stack = set(), [a["node"] for a in axes]
while stack:
nid = stack.pop()
if nid in body:
continue
body.add(nid)
stack.extend(c for c in feeds.get(nid, ()) if c in up)
xs = axes[0]["values"]; ys = axes[1]["values"] if len(axes) > 1 else [None]
cells = [(x, y) for y in ys for x in xs]
if len(cells) > MAX_CELLS:
raise ValueError(f"{len(cells)} combinations; at most {MAX_CELLS}.")
graph = GraphBuilder()
outs, labels = [], []
for ci, (x, y) in enumerate(cells):
over = {(axes[0]["node"], axes[0]["widget"]): x}
if y is not None:
over[(axes[1]["node"], axes[1]["widget"])] = y
clones = {n: graph.node(dynprompt.get_node(n)["class_type"], f"c{ci}_{n}") for n in body}
for nid, node in clones.items():
node.set_override_display_id(dynprompt.get_display_node_id(nid))
for key, value in dynprompt.get_node(nid).get("inputs", {}).items():
if (nid, key) in over:
node.set_input(key, _cast(over[(nid, key)], value))
elif is_link(value) and str(value[0]) in body:
node.set_input(key, clones[str(value[0])].out(value[1]))
else:
node.set_input(key, value)
outs.append(clones[root].out(link[1]) if root in clones else link)
labels.append({"x": x, "y": y})
meta = {"axes": [{k: a[k] for k in ("node", "widget", "title")} for a in axes], "cells": labels,
"xs": xs, "ys": ys if len(axes) > 1 else [], "frames_per_cell": frames_per_cell, "cell_height": cell_height,
"save_videos": save_videos, "fps": fps}
coll = graph.node("BFSXYSweepCollect", "collect")
coll.set_input("meta", json.dumps(meta))
for i, o in enumerate(outs):
coll.set_input(f"image_{i}", o)
return {"result": (coll.out(0), coll.out(1), coll.out(2)), "expand": graph.finalize()}
class BFSXYSweepCollect:
"""Internal: receives every cell's result from BFS XY Sweep and draws the grid."""
@classmethod
def INPUT_TYPES(cls):
return {"required": {"meta": ("STRING", {"default": "{}"})},
"optional": {f"image_{i}": ("IMAGE",) for i in range(MAX_CELLS)}}
RETURN_TYPES = ("IMAGE", "IMAGE", "STRING")
RETURN_NAMES = ("grid", "results", "labels")
FUNCTION = "collect"
CATEGORY = CATEGORY
OUTPUT_NODE = True
def collect(self, meta, **kw):
m = json.loads(meta)
cells = m["cells"]; k = int(m["frames_per_cell"]); ch = int(m["cell_height"])
imgs = [kw.get(f"image_{i}") for i in range(len(cells))]
strips, saved = [], []
for i, (img, lab) in enumerate(zip(imgs, cells)):
idx = np.unique(np.linspace(0, img.shape[0] - 1, min(k, img.shape[0])).round().astype(int))
fr = img[idx, ..., :3].float()
w = max(1, round(fr.shape[2] * ch / fr.shape[1]))
fr = F.interpolate(fr.movedim(-1, 1), size=(ch, w), mode="bilinear", align_corners=False).movedim(1, -1)
strips.append(torch.cat(list(fr), dim=1))
if m.get("save_videos") and img.shape[0] > 1 and folder_paths:
saved.append(self._save(img, lab, m, i))
sw = max(s.shape[1] for s in strips)
strips = [F.pad(s, (0, 0, 0, sw - s.shape[1])) for s in strips]
xs, ys = m["xs"], m["ys"] or [None]
ax = m["axes"]
xl = f"{ax[0]['title'] or ax[0]['node']}.{ax[0]['widget']}"
yl = f"{ax[1]['title'] or ax[1]['node']}.{ax[1]['widget']}" if len(ax) > 1 else ""
pad, top, left = 6, 34, (170 if len(ax) > 1 else 0)
H = top + len(ys) * (ch + pad); W = left + len(xs) * (sw + pad)
canvas = Image.new("RGB", (W, H), (24, 24, 24)); d = ImageDraw.Draw(canvas); f = _font(16)
for c, x in enumerate(xs):
d.text((left + c * (sw + pad) + 4, 8), f"{xl} = {_short(x)}", fill=(230, 230, 230), font=f)
for r, y in enumerate(ys):
if y is not None:
d.text((6, top + r * (ch + pad) + ch // 2 - 10), f"{yl} =\n{_short(y)}", fill=(230, 230, 230), font=f)
for c in range(len(xs)):
s = strips[r * len(xs) + c]
arr = (s.clamp(0, 1).cpu().numpy() * 255).astype(np.uint8)
canvas.paste(Image.fromarray(arr), (left + c * (sw + pad), top + r * (ch + pad)))
grid = torch.from_numpy(np.asarray(canvas, np.float32) / 255.0)[None]
results = torch.stack(strips)
info = {"cells": cells, "videos": saved}
ui = {}
if folder_paths:
out = os.path.join(folder_paths.get_output_directory(), "bfs_sweep"); os.makedirs(out, exist_ok=True)
name = f"grid_{len(os.listdir(out)):05d}.png"; canvas.save(os.path.join(out, name))
ui = {"images": [{"filename": name, "subfolder": "bfs_sweep", "type": "output"}]}
return {"ui": ui, "result": (grid, results, json.dumps(info))}
@staticmethod
def _save(img, lab, m, i):
import av
out = os.path.join(folder_paths.get_output_directory(), "bfs_sweep"); os.makedirs(out, exist_ok=True)
tag = "_".join(_short(v).replace(" ", "") for v in (lab["x"], lab["y"]) if v is not None)
tag = "".join(ch if ch.isalnum() or ch in "._-" else "-" for ch in tag)[:80]
path = os.path.join(out, f"{i:02d}_{tag}.mp4")
frames = (img[..., :3].clamp(0, 1).cpu().numpy() * 255).astype(np.uint8)
h, w = frames.shape[1] // 2 * 2, frames.shape[2] // 2 * 2
with av.open(path, "w") as c:
st = c.add_stream("libx264", rate=int(round(float(m.get("fps", 24)))))
st.width, st.height, st.pix_fmt = w, h, "yuv420p"; st.options = {"crf": "16"}
for fr in frames:
for p in st.encode(av.VideoFrame.from_ndarray(fr[:h, :w], format="rgb24")):
c.mux(p)
for p in st.encode():
c.mux(p)
return path
NODE_CLASS_MAPPINGS = {"BFSXYSweep": BFSXYSweep, "BFSXYSweepCollect": BFSXYSweepCollect}
NODE_DISPLAY_NAME_MAPPINGS = {"BFSXYSweep": "BFS XY Sweep", "BFSXYSweepCollect": "BFS XY Sweep Collect (internal)"}
+4
View File
@@ -420,3 +420,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
'BFSMiniMaxH3GuideTarget': 'MiniMax-H3 Guide Target — Image / Video (BFS)',
'BFSMiniMaxH3IdentityReference': 'MiniMax-H3 Identity Reference + RoPE (BFS)',
}
from .bfs_h3_image_guide import BFSH3ImageAddGuide
NODE_CLASS_MAPPINGS['BFSH3ImageAddGuide'] = BFSH3ImageAddGuide
NODE_DISPLAY_NAME_MAPPINGS['BFSH3ImageAddGuide'] = 'Add Guide for MiniMax H3 Image (BFS)'
+2 -2
View File
@@ -1,7 +1,7 @@
[project]
name = "bfsnodes"
description = "Custom ComfyUI nodes for reference-guided video: BFS Sampler (guide-driven IC-LoRAs, auto crop, mask inpainting, temporal chunking), LTX Multiple Controls / Identity Transfer / Edit Anything, Head-Swap and Anime2Real conditioning, MiniMax-H3 latent guides, RoPE layouts and loaders, LoRA Surgery, and Shot Planner / Shot Loop (tabbed panel): split long videos into shots at camera cuts, face-tracked cast, SAM 3 mask per shot with full frame, paste, mask, crop and crop + mask generation modes, optional translucent Colored Mark overlay for manually selected regions, optional Qwen3-VL to suggest settings, describe references ({details}) and the person to replace ({target}), continuity between shots, H3 conditioning with an optional duet and prompt writer, join with the original timing, audio and a side-by-side comparison. H3 Duet: pin a source clip beside the video, training-free."
version = "1.118.1"
description = "ComfyUI nodes for video workflows: SAM 3 segmentation and Molmo2 tracking; MiniMax H3 character swapping and sampling; shot planning and looping; LTX video controls and identity transfer; model loaders, LoRA tools, and image/video utilities."
version = "1.122.0"
license = {file = "LICENSE"}
dependencies = [
"torch",
+34
View File
@@ -0,0 +1,34 @@
import importlib.util
import sys
import os
import unittest
from pathlib import Path
import torch
if os.environ.get('COMFYUI_PATH'):
sys.path.insert(0, os.environ['COMFYUI_PATH'])
from comfy.ldm.minimax.model import PackedLayout
spec = importlib.util.spec_from_file_location('image_guide', Path(__file__).resolve().parents[1] / 'bfs_h3_image_guide.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
class ImageGuideTest(unittest.TestCase):
def test_only_guide_positions_are_aligned_and_inputs_stay_unchanged(self):
refs = [{'kind': 'image', 'latent_h': 8, 'latent_w': 8},
{'kind': 'image', 'latent_h': 8, 'latent_w': 8, module.MARKER: True}]
original = PackedLayout(4, 1, 8, 8, 0, refs=refs)
positions = original.position_ids.clone()
result = module.align_guide_layout(original, refs)
ref_segments = [(a,b) for a,b,k in result.segments if k == 'ref_img']
a,b = ref_segments[1]
ta,tb = next((a,b) for a,b,k in result.segments if k == 'video')
self.assertTrue(torch.equal(result.position_ids[a:b], result.position_ids[ta:tb]))
a,b = ref_segments[0]
self.assertTrue(torch.equal(result.position_ids[a:b], positions[a:b]))
self.assertTrue(torch.equal(original.position_ids, positions))
self.assertTrue(torch.equal(result.img_update, original.img_update))
def test_incompatible_guide_grid_is_rejected(self):
refs = [{'kind': 'image', 'latent_h': 4, 'latent_w': 4, module.MARKER: True}]
layout = PackedLayout(4, 1, 8, 8, 0, refs=refs)
with self.assertRaisesRegex(ValueError, 'match the target'):
module.align_guide_layout(layout, refs)
@@ -0,0 +1,113 @@
import importlib.util
import sys
import os
import unittest
from pathlib import Path
import torch
if os.environ.get('COMFYUI_PATH'):
sys.path.insert(0, os.environ['COMFYUI_PATH'])
spec = importlib.util.spec_from_file_location('image_swap', Path(__file__).resolve().parents[1] / 'bfs_h3_image_swap_conditioning.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
class Clip:
def tokenize(self, prompt, **kwargs):
self.prompt, self.images = prompt, kwargs['images']
return {'text': prompt}
def encode_from_tokens_scheduled(self, tokens):
return [[torch.zeros(1, 4, 8), {}]]
class Vae:
def encode(self, pixels):
return torch.zeros(1, 24, 1, pixels.shape[1] // 16, pixels.shape[2] // 16)
class ImageSwapTest(unittest.TestCase):
def test_modes_use_still_image_language_and_shifted_picture_numbers(self):
for mode in module.MODES:
text = module.image_swap_prompt(3, mode)
for word in ('video', 'motion', 'frame', 'timing'):
self.assertNotIn(word, text.lower())
for j, color in enumerate(module.COLORS, 1):
phrase = 'is identified by the' if mode == 'colored hatching + contour' else 'is the subject marked by the'
self.assertIn(f'<Subject {j}> {phrase} {color}', text)
self.assertIn(f'appearance comes from <Picture {j+1}>', text)
self.assertIn('foreground occluders', text)
def test_hatching_prompt_transfers_clothing_and_preserves_source_lighting(self):
for count in (1, 2, 3):
text = module.image_swap_prompt(count, 'colored hatching + contour')
self.assertIn('complete outfit', text)
self.assertIn('source lighting, shadows and exposure', text)
self.assertIn('Remove all colored lines and contours', text)
self.assertIn(f'<Picture {count+1}>', text)
self.assertNotIn(f'<Subject {count+1}>', text)
def test_native_conditioning_order_and_image_latent(self):
clip = Clip()
guide = torch.full((1, 32, 32, 3), .1)
red = torch.full_like(guide, .2)
blue = torch.full_like(guide, .3)
result = module.BFSH3ImageCharacterSwapConditioning.execute(
clip, Vae(), guide, {'reference_2': blue, 'reference_0': red}, width=128, height=96)
positive, latent, prompt = result.args
self.assertEqual(tuple(latent['samples'].shape), (1, 24, 1, 6, 8))
self.assertEqual(len(positive[0][1]['minimax_refs']), 3)
self.assertEqual([round(float(i.mean()), 1) for i in clip.images], [.1, .2, .3])
self.assertIn('red subject replaced by <Picture 2>', prompt)
self.assertIn('blue subject replaced by <Picture 3>', prompt)
def test_override_preserved(self):
clip = Clip()
image = torch.zeros(1, 32, 32, 3)
result = module.BFSH3ImageCharacterSwapConditioning.execute(
clip, Vae(), image, {'reference_0': image}, width=64, height=64, prompt_override='Custom edit')
self.assertEqual(result.args[2], 'Custom edit')
self.assertEqual(clip.prompt, 'Custom edit')
class AutoImageSwapTest(unittest.TestCase):
def test_blue_and_green_without_red_keep_color_reference_binding(self):
clip = Clip()
guide = torch.zeros(1, 32, 32, 3)
blue, green = torch.full_like(guide, .3), torch.full_like(guide, .4)
report = '{"active_subjects":[2,3],"mark_style":"colored hatching + contour"}'
output = module.BFSH3ImageAutoCharacterSwapConditioning.execute(
clip, Vae(), guide, report, blue_reference=blue, green_reference=green, width=128, height=128)
text = output.args[2]
self.assertIn('blue hatching', text)
self.assertIn('green hatching', text)
self.assertNotRegex(text, r'\bred hatching\b')
self.assertIn('Colored hatching', text)
self.assertIn('<Picture 3>', text)
self.assertNotIn('<Picture 4>', text)
self.assertEqual(len(clip.images), 3)
self.assertAlmostEqual(float(clip.images[1].mean()), .3, delta=.004)
self.assertAlmostEqual(float(clip.images[2].mean()), .4, delta=.004)
def test_empty_picker_selection_blocks_sampling_without_reference_requests(self):
report = '{"active_subjects":[],"mark_style":"colored hatching + contour"}'
self.assertEqual(module.BFSH3ImageAutoCharacterSwapConditioning.check_lazy_status(report), [])
output = module.BFSH3ImageAutoCharacterSwapConditioning.execute(Clip(), Vae(), torch.zeros(1, 32, 32, 3), report)
self.assertIn('Select subjects', output.args[2])
def test_prompt_override_is_used_verbatim_without_changing_selected_references(self):
clip = Clip()
guide = torch.zeros(1, 32, 32, 3)
blue = torch.full_like(guide, .3)
report = '{"active_subjects":[2],"mark_style":"colored hatching + contour"}'
custom = ' Edit <Picture 1> using <Picture 2>; preserve red background objects. '
output = module.BFSH3ImageAutoCharacterSwapConditioning.execute(
clip, Vae(), guide, report, blue_reference=blue, width=128, height=128, prompt_override=custom)
self.assertEqual(output.args[2], custom)
self.assertEqual(clip.prompt, custom)
self.assertEqual(len(clip.images), 2)
self.assertAlmostEqual(float(clip.images[1].mean()), .3, delta=.004)
automatic = module.BFSH3ImageAutoCharacterSwapConditioning.execute(
Clip(), Vae(), guide, report, blue_reference=blue, width=128, height=128, prompt_override=' ')
self.assertIn('blue hatching', automatic.args[2])
+41
View File
@@ -0,0 +1,41 @@
import importlib.util
import os
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
import torch
if os.environ.get('COMFYUI_PATH'):
sys.path.insert(0, os.environ['COMFYUI_PATH'])
path = Path(__file__).resolve().parents[1] / 'bfs_h3_image_swap_tools.py'
spec = importlib.util.spec_from_file_location('swap_tools', path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
class ReferenceToolsTest(unittest.TestCase):
def test_none_slot_skips_loading_and_resizing(self):
with patch.object(module.LoadImage, 'load_image', side_effect=AssertionError('An empty slot must not load')):
self.assertEqual(module.BFSOptionalReferenceImage.execute('[none]', 2.0, 'area').args, (None,))
def test_mp_and_interpolation_are_editable(self):
image = torch.ones(1, 96, 128, 3)
for mp, method in [(.1, 'area'), (.2, 'bilinear')]:
with patch.object(module.LoadImage, 'load_image', return_value=(image, None)):
output = module.BFSOptionalReferenceImage.execute('example.png', mp, method).args[0]
height, width = output.shape[1:3]
self.assertAlmostEqual(width / height, 128 / 96, delta=.005)
self.assertAlmostEqual(width * height / 1024**2, mp, delta=.001)
def test_comparison_requests_only_selected_colors(self):
self.assertEqual(module.BFSH3ImageSwapComparison.check_lazy_status('{"active_subjects":[2]}'), ['blue_reference'])
self.assertEqual(module.BFSH3ImageSwapComparison.check_lazy_status('{"active_subjects":[]}'), [])
def test_reference_resize_changes_cache_fingerprint(self):
with patch.object(module.LoadImage, 'IS_CHANGED', return_value='digest'):
one = module.BFSOptionalReferenceImage.fingerprint_inputs('example.png', 1.0, 'area')
two = module.BFSOptionalReferenceImage.fingerprint_inputs('example.png', 2.0, 'area')
other = module.BFSOptionalReferenceImage.fingerprint_inputs('example.png', 1.0, 'lanczos')
self.assertNotEqual(one, two)
self.assertNotEqual(one, other)
+72
View File
@@ -0,0 +1,72 @@
import importlib.util
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location("bfs_h3_swap_conditioning", ROOT / "bfs_h3_swap_conditioning.py")
SC = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(SC)
NODE = SC.BFSH3KeyframeConditioning
class InputsTest(unittest.TestCase):
def test_modes(self):
self.assertEqual(SC.MODES, ["keyframe pins", "normal map guide", "DWPose guide", "translucent overlay guide", "outline guide"])
def test_pictures_grouped_first(self):
t = NODE.INPUT_TYPES()
self.assertEqual(list(t["required"])[:7], ["clip", "vae", "guide_video", "width", "height", "length", "picture_1"])
self.assertEqual(list(t["optional"])[:5], ["picture_2", "picture_3", "model", "keyframes", "keyframe_spec"])
def test_outputs(self):
self.assertEqual(len(NODE.RETURN_TYPES), len(NODE.RETURN_NAMES))
self.assertEqual(len(NODE.OUTPUT_IS_LIST), len(NODE.RETURN_TYPES))
self.assertEqual(NODE.RETURN_NAMES[-1], "pictures")
self.assertTrue(NODE.OUTPUT_IS_LIST[-1])
def test_unknown_mode(self):
import torch
with self.assertRaises(ValueError):
NODE().encode(None, None, torch.zeros(1, 8, 8, 3), 8, 8, 1, torch.zeros(1, 8, 8, 3), mode="gray video")
class PromptTest(unittest.TestCase):
def test_guide_prompts_name_each_subject_once(self):
for fn, word in ((SC.normals_caption, "normal map"), (SC.pose_caption, "pose skeleton")):
for k in range(1, SC.MAX_PICTURES + 1):
p = fn(k)
self.assertTrue(p.startswith("character_swap:\nsubject_definitions:\n"))
for j in range(1, k + 1):
self.assertIn(f"<Subject {j}> is the subject marked by the {SC.SUBJECT_COLORS[j - 1]} {word}", p)
self.assertNotIn(f"<Picture {k + 1}>", p)
def test_pins_prompt(self):
p = SC.pins_caption(2, [0.0, 1.5])
self.assertIn("2 keyframes of the target video are pinned at 0.0s, 1.5s", p)
self.assertIn("<Picture 2>", p)
self.assertIn("1 keyframe of the target video is pinned at 2.0s", SC.pins_caption(1, [2.0]))
def test_overlay_prompt(self):
prompt = SC.overlay_caption(2)
self.assertIn("translucent red overlay", prompt)
self.assertIn("translucent blue overlay", prompt)
self.assertIn("original appearance visible beneath it is not the target appearance", prompt)
self.assertIn("The tint must disappear in the result", prompt)
self.assertNotIn("<Picture 3>", prompt)
def test_outline_prompt_preserves_props(self):
prompt = SC.outline_caption(2)
self.assertIn("red outline", prompt)
self.assertIn("blue outline", prompt)
self.assertIn("held or interacting objects", prompt)
self.assertIn("foreground occlusions", prompt)
self.assertNotIn("<Picture 3>", prompt)
def test_normal_map_prompt_preserves_occluders(self):
prompt = SC.normals_caption(1)
self.assertIn("held objects and foreground occluders", prompt)
self.assertIn("original positions, shapes and colors", prompt)
if __name__ == "__main__":
unittest.main()
+81
View File
@@ -0,0 +1,81 @@
import importlib.util
import os
from pathlib import Path
import sys
from types import SimpleNamespace
import unittest
import torch
if os.environ.get('COMFYUI_PATH'):
sys.path.insert(0, os.environ['COMFYUI_PATH'])
from comfy.lora import calculate_weight
def load(name):
path = Path(__file__).resolve().parents[1] / (name + '.py')
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
surgery = load('bfs_model_surgery')
pdd = load('bfs_h3_pdd_surgery')
class Model:
def __init__(self, state, final=None):
self.state, self.final, self.patches = state, final, {}
def model_state_dict(self):
return self.state
def clone(self):
result = Model(self.state, self.final)
result.patches = {key: list(value) for key, value in self.patches.items()}
return result
def get_model_object(self, name):
assert name == 'diffusion_model.final_layer'
return self.final
def add_patches(self, patches, strength_patch=1.0, strength_model=1.0):
for spec, patch in patches.items():
key, offset = spec if isinstance(spec, tuple) else (spec, None)
self.patches.setdefault(key, []).append((strength_patch, patch, strength_model, offset, None))
return list(patches)
class SurgeryTest(unittest.TestCase):
def test_overlapping_rules_scale_existing_patches_and_preserve_input(self):
key = 'diffusion_model.block.weight'
original = torch.ones(6, 3)
model = Model({key: original})
model.patches[key] = [(1.0, ('diff', (torch.ones_like(original),)), 1.0, None, None)]
result = surgery.BFSModelSurgery.execute(model, '[{"pattern":"*.weight","scale":0.5},{"pattern":"*.weight","rows":[0,2],"scale":0.5}]').args[0]
output = calculate_weight(result.patches[key], original.clone(), key)
expected = torch.ones_like(original); expected[:2] *= .5
torch.testing.assert_close(output, expected)
torch.testing.assert_close(original, torch.ones_like(original))
self.assertEqual(len(model.patches[key]), 1)
def test_invalid_row_range_fails_before_patching(self):
with self.assertRaises(ValueError):
surgery.plan_rules({'a.weight': torch.zeros(3, 2)}, [{'pattern': '*', 'rows': [0, 4]}])
def test_empty_rules_do_not_patch_model(self):
result = surgery.BFSModelSurgery.execute(Model({'a.weight': torch.ones(3, 2)})).args[0]
self.assertFalse(result.patches)
def test_pdd_offsets_preserve_shared_base_and_scale_bias(self):
weight, bias = torch.ones(6, 4), torch.ones(6)
final = SimpleNamespace(video_dim=2, video_out=SimpleNamespace(weight=weight, bias=bias), audio_out=None)
state = {'diffusion_model.final_layer.video_out.weight': weight, 'diffusion_model.final_layer.video_out.bias': bias}
model = Model(state, final)
result = pdd.BFSH3PDDSurgery.execute(model, offset_strength=.5, offset_1=.8).args[0]
for key, value in state.items():
actual = calculate_weight(result.patches[key], value.clone(), key)
expected = value.clone(); expected[2:4] *= .4; expected[4:] *= .5
torch.testing.assert_close(actual, expected)
self.assertFalse(model.patches)
self.assertFalse(pdd.BFSH3PDDSurgery.execute(model).args[0].patches)
+48
View File
@@ -1,7 +1,10 @@
import importlib.util
import json
import sys
import types
import unittest
from pathlib import Path
from unittest.mock import patch
import torch
@@ -9,6 +12,8 @@ ROOT = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location("bfs_segment_picker", ROOT / "bfs_segment_picker.py")
SP = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(SP)
ORIGINAL_DETECT_TEXT = SP.detect_text
ORIGINAL_DETECT_POINTS = SP.detect_points
def rect(h, w, x0, y0, x1, y1):
@@ -77,6 +82,49 @@ class GeometryTest(unittest.TestCase):
self.assertEqual(SP.parse_color("nope"), (255, 0, 255))
class InferenceModeTest(unittest.TestCase):
def test_text_and_point_sam_calls_run_in_inference_mode(self):
calls = []
class FakeDetect:
@staticmethod
def execute(**kwargs):
calls.append(torch.is_inference_mode_enabled())
return types.SimpleNamespace(args=(torch.ones(1, 4, 4), [[{"score": 0.9}]]))
parent = types.ModuleType("comfy_extras")
parent.__path__ = []
child = types.ModuleType("comfy_extras.nodes_sam3")
child.SAM3_Detect = FakeDetect
frame = torch.zeros(1, 4, 4, 3)
with patch.dict(sys.modules, {"comfy_extras": parent, "comfy_extras.nodes_sam3": child}), \
patch.object(SP, "_text_cond", return_value=[]):
self.assertEqual(len(ORIGINAL_DETECT_TEXT((None, None), frame, "person", 0.4, 1)), 1)
self.assertTrue(ORIGINAL_DETECT_POINTS((None, None), frame, [{"x": 0.5, "y": 0.5, "label": 1}], 0.4).any())
self.assertEqual(calls, [True, True])
class AutoObjectsTest(unittest.TestCase):
def test_rejects_scene_sized_generic_object_but_keeps_person(self):
h, w = 20, 40
scene = torch.zeros(h, w, dtype=torch.bool)
scene[::2, :] = True
scene[-1, :] = True
person = rect(h, w, 10, 2, 28, 18)
def detect_text(sam, frame, concept, threshold, max_detections):
mask = person if concept == "person" else scene
return [(mask, {"score": .9})]
frame = torch.zeros(1, h, w, 3)
with patch.object(SP, "detect_text", side_effect=detect_text):
objects = SP.auto_objects((None, None), frame, ["objects", "person"], .4, 8, 0.)
with patch.object(SP, "detect_text", return_value=[(scene, {"score": .9})]):
large_person = SP.auto_objects((None, None), frame, ["person"], .4, 8, 0.)
self.assertEqual([(o["id"], o["label"]) for o in objects], [("a1", "person")])
self.assertEqual(len(large_person), 1)
class SessionTest(unittest.TestCase):
"""The node with SAM replaced: what is segmented once, what is segmented again, what the outputs hold."""
+26 -46
View File
@@ -212,22 +212,34 @@ class JoinTest(unittest.TestCase):
self.assertGreater((comp - 200 / 255).abs().lt(0.05).float().mean().item(), 0.05) # original column shown
class FilterTest(unittest.TestCase):
STATS = {"persons": 1, "person_area": 0.2, "person_frames": 6, "faces": 1, "brightness": 0.4, "motion": 0.02, "sampled": 6}
class MarkStyleTest(unittest.TestCase):
MARKS = [{"picture": 1, "color": "#00ff00"}]
def test_no_filter_keeps_everything(self):
self.assertEqual(SL.skip_reason(self.STATS, 50, dict(SL.DEFAULT_FILTERS)), "")
self.assertFalse(SL.filters_active(dict(SL.DEFAULT_FILTERS)))
def test_prompt_names_the_style_and_keeps_the_overlay_wording(self):
self.assertIn("the subject marked in green uses", SL.color_guide_prompt("x", self.MARKS))
self.assertIn("green hatching lines", SL.color_guide_prompt("x", self.MARKS, "hatching"))
self.assertIn("green surface-normal map", SL.color_guide_prompt("x", self.MARKS, "normal map"))
def test_each_filter(self):
f = dict(SL.DEFAULT_FILTERS)
self.assertEqual(SL.skip_reason(dict(self.STATS, persons=0), 50, dict(f, person=True)), "no person")
self.assertIn("smaller", SL.skip_reason(dict(self.STATS, person_area=0.01), 50, dict(f, person=True, min_person_area=0.03)))
self.assertIn("more than", SL.skip_reason(dict(self.STATS, persons=5), 50, dict(f, max_persons=2)))
self.assertEqual(SL.skip_reason(dict(self.STATS, faces=0), 50, dict(f, face=True)), "no face")
self.assertEqual(SL.skip_reason(dict(self.STATS, brightness=0.02), 50, dict(f, skip_dark=True)), "dark / fade")
self.assertEqual(SL.skip_reason(dict(self.STATS, motion=0.0), 50, dict(f, skip_static=True)), "static")
self.assertIn("shorter", SL.skip_reason(self.STATS, 5, dict(f, min_frames=10)))
def test_overlay_fingerprint_is_unchanged(self):
seg = {"color_guide": True, "color_guide_marks": self.MARKS}
self.assertIn('[[1,"#00ff00"]]', SL.auto_prompt_for("r", [], seg))
self.assertNotEqual(SL.auto_prompt_for("r", [], seg), SL.auto_prompt_for("r", [], dict(seg, color_guide_style="hatching")))
def test_molmopoint_only_for_text_selections(self):
SL._POINT["model"] = type("M", (), {"runtime": object()})()
try:
self.assertTrue(SL._molmo_active({"text": "the man on the left"}))
self.assertFalse(SL._molmo_active({"text": "x", "points": [{"x": 0.1, "y": 0.1}]}))
finally:
SL._POINT.clear()
self.assertFalse(SL._molmo_active({"text": "the man on the left"}))
class RunTest(unittest.TestCase):
def test_enabled_shots_run_and_old_force_skip_is_kept(self):
segs = [{"enabled": True}, {"enabled": False}, {"enabled": True}]
plan = {"segs": [{}, {}, {"force": "skip"}]}
self.assertEqual([s["run"] for s in SL.mark_runs(plan, segs)], [True, False, False])
class TimelineJoinTest(unittest.TestCase):
@@ -280,38 +292,6 @@ class TimelineJoinTest(unittest.TestCase):
self.assertEqual(float(other["waveform"][0, 0, 0]), -1.0)
class CastTest(unittest.TestCase):
# person 0 on frames 0-47 (big face), person 1 on 48-95, both small on 96-119
CAST = {"step": 6, "people": [{"id": 0}, {"id": 1}], "samples":
[{"f": f, "faces": [{"pid": 0, "area": 0.1}]} for f in range(0, 48, 6)]
+ [{"f": f, "faces": [{"pid": 1, "area": 0.1}]} for f in range(48, 96, 6)]
+ [{"f": f, "faces": []} for f in range(96, 120, 6)]}
def test_shot_people_and_main(self):
self.assertEqual(SL.shot_people(self.CAST, 0, 60), {"people": [0, 1], "main": 0})
self.assertEqual(SL.shot_people(self.CAST, 96, 120), {"people": [], "main": -1})
def test_change_points_need_a_long_enough_run(self):
self.assertEqual(SL.person_change_points(self.CAST, 0, 120, 24), [48])
self.assertEqual(SL.person_change_points(self.CAST, 0, 60, 24), [])
def test_plan_splits_assigns_and_skips_by_person(self):
path = str(Path(_TMP) / "cast.mp4"); Path(path).write_bytes(b"x")
a = {"fps": 24.0, "n": 120, "score": [0.0] * 120, "raw": [0.0] * 120}
SL._CAST_CACHE[SL._cast_key(path, a)] = self.CAST
orig = SL.find_cuts
SL.find_cuts = lambda *args, **kw: ([], "test")
try:
plan = dict(SL.DEFAULT_PLAN, max_s=10, min_s=0.5, cast={"0": {"ref": "a.png"}}, cast_split=True, cast_only=True)
segs = SL.apply_filters(plan, a, path, SL.resolve_plan(plan, a, path))
finally:
SL.find_cuts = orig
self.assertEqual([(s["start"], s["end"]) for s in segs], [(0, 48), (48, 120)])
self.assertEqual(segs[0]["ref"], "a.png")
self.assertTrue(segs[0]["run"])
self.assertEqual(segs[1]["skip_reason"], "no linked person")
class ComparisonTest(unittest.TestCase):
def test_join_returns_a_labelled_side_by_side(self):
shots = [dict(_shot(0, 0, 20, gen=22), frames=torch.zeros(22, 32, 48, 3), ref=torch.ones(1, 40, 30, 3),
+132
View File
@@ -0,0 +1,132 @@
import importlib
import sys
import types
import unittest
from pathlib import Path
from unittest.mock import patch
import torch
ROOT = Path(__file__).resolve().parents[1]
pkg = types.ModuleType("bfsnodes")
pkg.__path__ = [str(ROOT)]
sys.modules["bfsnodes"] = pkg
VSP = importlib.import_module("bfsnodes.bfs_video_segment_picker")
class MaskQualityTest(unittest.TestCase):
def test_rendered_edge_is_soft_and_stays_inside_selection(self):
mask = torch.zeros(20, 20, dtype=torch.bool)
mask[3:17, 3:17] = True
alpha = VSP.feather_inside_mask(mask, 4)
self.assertEqual(float(alpha[2, 10]), 0.0)
self.assertGreater(float(alpha[10, 10]), 0.95)
self.assertGreater(float(alpha[3, 10]), 0.0)
self.assertLess(float(alpha[3, 10]), 0.5)
def test_anchor_windows_cover_each_frame_once_and_keep_earlier_ties(self):
windows = list(VSP.anchor_windows([0, 53, 69, 125], 0, 209))
assigned = {i: a for a, lo, hi in windows for i in range(lo, hi)}
self.assertEqual(len(assigned), 209)
self.assertEqual(assigned[26], 0)
self.assertEqual(assigned[27], 53)
self.assertEqual(assigned[61], 53)
self.assertEqual(assigned[62], 69)
def test_fill_empty_tracks_only_gaps_and_preserves_existing_masks(self):
frames = torch.zeros(8, 4, 4, 3)
masks = torch.zeros(8, 4, 4, dtype=torch.bool)
masks[[0, 3, 7], 1, 1] = True
report = [{"frame": i, "source": "empty"} for i in range(8)]
calls = []
class FakeTracker:
def track(self, video, model, mask, anchor_frame):
calls.append((len(video), anchor_frame))
out = torch.zeros(len(video), 4, 4)
out[:, 0, 0] = 1
return out, anchor_frame
with patch.object(VSP, "BFSTrackFromFrame", FakeTracker):
filled = VSP.fill_empty_mask_runs(frames, None, masks, report, [(0, 8)], active_frame=3)
self.assertEqual(filled, 5)
self.assertEqual(calls, [(3, 2), (4, 0)])
self.assertTrue(bool(masks[3, 1, 1]))
self.assertFalse(bool(masks[3, 0, 0]))
self.assertTrue(bool(masks[5, 0, 0]))
def test_fill_scope_does_not_request_molmo_when_masks_are_cached(self):
selection = '{"frames":{"1":{"1":{"selected":["a1"]}}}}'
prompt = {"7": {"inputs": {"molmo_clip": ["6", 0]}}}
with patch.object(VSP, "video_key", return_value="video"), \
patch.object(VSP, "_LAST_TRACK_CACHE", {("video", 1): (None, None, None)}):
need = VSP.BFSVideoSegmentPicker().check_lazy_status(
selection=selection, mark_style="flat color", track=True, image=torch.zeros(2, 4, 4, 3),
molmo_clip=None, propagation_method="Molmo2 (classic)",
propagate_scope="fill only empty masks", unique_id="7", prompt=prompt)
self.assertEqual(need, [])
class AutoUnmarkingTest(unittest.TestCase):
def test_find_connected_picture_count(self):
# 1. 2 pictures connected downstream
mock_prompt = {
"100": {
"class_type": "BFSH3KeyframeConditioning",
"inputs": {
"guide_video": ["10", 4],
"picture_1": ["20", 0],
"picture_2": ["21", 0],
"picture_3": None,
}
},
"10": {
"class_type": "BFSVideoSegmentPicker",
"inputs": {}
}
}
self.assertEqual(VSP.find_connected_picture_count(mock_prompt, "10"), 2)
# 2. 3 pictures connected downstream
mock_prompt["100"]["inputs"]["picture_3"] = ["22", 0]
self.assertEqual(VSP.find_connected_picture_count(mock_prompt, "10"), 3)
# 3. Only 1 picture connected
mock_prompt["100"]["inputs"]["picture_2"] = None
mock_prompt["100"]["inputs"]["picture_3"] = None
self.assertEqual(VSP.find_connected_picture_count(mock_prompt, "10"), 1)
# 4. No downstream node connected
mock_prompt["100"]["inputs"]["guide_video"] = ["99", 0]
self.assertIsNone(VSP.find_connected_picture_count(mock_prompt, "10"))
# 5. Empty prompt or None
self.assertIsNone(VSP.find_connected_picture_count(None, "10"))
self.assertIsNone(VSP.find_connected_picture_count({}, "10"))
def test_input_types_controls(self):
t = VSP.BFSVideoSegmentPicker.INPUT_TYPES()
opt = t["optional"]
self.assertIn("max_subjects", opt)
self.assertEqual(opt["max_subjects"][0], ["auto", "1", "2", "3"])
self.assertEqual(opt["max_subjects"][1]["default"], "auto")
self.assertIn("molmo_descriptions", opt)
self.assertEqual(opt["molmo_descriptions"][0], "STRING")
self.assertFalse(opt["molmo_descriptions"][1].get("multiline"))
self.assertEqual(opt["protect_mask"][0], "MASK")
self.assertIn(VSP.PROTECTED_NORMAL_STYLE, opt["mark_style"][0])
def test_locked_objects_survive_selection_round_trip(self):
import json
state = {"frames": {"7": {"1": {"selected": ["a1"]},
"lock": {"selected": ["lock_p1"], "added": [
{"id": "lock_p1", "points": [{"x": 0.4, "y": 0.5, "label": 1}]}]}}}}
frames = VSP.parse_frames(json.dumps(state))
self.assertEqual(frames["7"]["1"]["selected"], ["a1"])
self.assertEqual(frames["7"]["lock"]["selected"], ["lock_p1"])
if __name__ == "__main__":
unittest.main()
+302
View File
@@ -0,0 +1,302 @@
/**
* BFS Keyframe Planner: the panel of the node. A small player of the guide (frames from the node's last run), a timeline
* with the keyframes on it, one card per keyframe (source frame + an optional edited frame, dropped or uploaded) and up
* to 4 reference pictures.
*
* All state lives in the hidden `plan` STRING widget (JSON: keyframe frames, edited frame and picture paths in input/),
* so a workflow saves, reloads and shares the plan. Uploaded images go to input/bfs_keyframes/.
*/
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { wheelToCanvas } from "./bfs_wheel.js";
import { createApp, h, ref, reactive, computed, watch, onBeforeUnmount } from "./vendor/vue.esm-browser.prod.mjs";
const DEFAULT_W = 560, DEFAULT_H = 640, MAX_PICTURES = 4, SUBFOLDER = "bfs_keyframes";
const tempUrl = (d, f) => api.apiURL(`/view?filename=${encodeURIComponent(f)}&subfolder=${encodeURIComponent(d.subfolder)}&type=${d.type || "temp"}`);
const inputUrl = p => {
const i = p.lastIndexOf("/");
return api.apiURL(`/view?filename=${encodeURIComponent(p.slice(i + 1))}&subfolder=${encodeURIComponent(i < 0 ? "" : p.slice(0, i))}&type=input`);
};
function parsePlan(text) {
let s = {};
try { s = JSON.parse(text || "{}") || {}; } catch { s = {}; }
const keyframes = (Array.isArray(s.keyframes) ? s.keyframes : []).filter(k => Number.isFinite(+k.frame))
.map(k => ({ frame: Math.round(+k.frame), edit: k.edit || null }));
const pictures = (Array.isArray(s.pictures) ? s.pictures : []).slice(0, MAX_PICTURES).map(p => p || null);
while (pictures.length < MAX_PICTURES) pictures.push(null);
return { keyframes, pictures };
}
async function upload(file) {
const body = new FormData();
body.append("image", file);
body.append("subfolder", SUBFOLDER);
body.append("type", "input");
const r = await api.fetchApi("/upload/image", { method: "POST", body });
if (r.status !== 200) throw new Error(`upload failed (${r.status})`);
const j = await r.json();
return j.subfolder ? `${j.subfolder}/${j.name}` : j.name;
}
function pickFile() {
return new Promise(resolve => {
const inp = document.createElement("input");
inp.type = "file"; inp.accept = "image/*";
inp.onchange = () => resolve(inp.files?.[0] || null);
inp.click();
});
}
let styled = false;
function styles() {
if (styled) return;
styled = true;
const css = document.createElement("style");
css.textContent = `
.bkp{position:absolute;inset:0;display:flex;flex-direction:column;gap:6px;padding:6px;box-sizing:border-box;font:12px system-ui,sans-serif;color:#ddd}
.bkp button{background:#333;color:#ddd;border:1px solid #555;border-radius:4px;padding:3px 8px;cursor:pointer;font:inherit;line-height:1.2}
.bkp button:hover:not(:disabled){background:#444}
.bkp button:disabled{opacity:.4;cursor:default}
.bkp button.pri{background:#2d5a8a;border-color:#3d6fa5;color:#fff}
.bkp button.danger:hover:not(:disabled){background:#6a2a2a}
.bkp .stage{flex:1;min-height:150px;position:relative;display:flex;align-items:center;justify-content:center;background:#121212;border-radius:6px;overflow:hidden}
.bkp .stage img{max-width:100%;max-height:100%;object-fit:contain;user-select:none;-webkit-user-drag:none}
.bkp .stage.onkf{box-shadow:inset 0 0 0 2px #e0a030}
.bkp .badge{position:absolute;left:6px;top:6px;background:rgba(0,0,0,.7);padding:2px 7px;border-radius:10px;font-variant-numeric:tabular-nums}
.bkp .badge.kf{left:auto;right:6px;background:#e0a030;color:#111;font-weight:600}
.bkp .empty{color:#888;text-align:center;padding:16px;line-height:1.7}
.bkp .ctl{display:flex;gap:4px;align-items:center}
.bkp .ctl .sp{flex:1}
.bkp .time{font-variant-numeric:tabular-nums;color:#bbb;min-width:110px;text-align:center}
.bkp .tl{position:relative;height:26px;background:#1d1d1d;border:1px solid #333;border-radius:5px;cursor:pointer;touch-action:none}
.bkp .tl .fill{position:absolute;left:0;top:0;bottom:0;background:#2a3a4d;border-radius:5px 0 0 5px;pointer-events:none}
.bkp .tl .head{position:absolute;top:-2px;bottom:-2px;width:2px;margin-left:-1px;background:#fff;pointer-events:none}
.bkp .tl .mk{position:absolute;top:3px;width:10px;height:18px;margin-left:-5px;border-radius:3px;background:#e0a030;border:1px solid #111;cursor:pointer}
.bkp .tl .mk.done{background:#3fbf6a}
.bkp .tl .mk.cur{outline:2px solid #fff}
.bkp .sec{display:flex;align-items:center;gap:6px;color:#999;font-size:11px;text-transform:uppercase;letter-spacing:.05em}
.bkp .sec .n{color:#ccc}
.bkp .row{display:flex;gap:6px;overflow-x:auto;padding-bottom:3px;min-height:96px}
.bkp .card{flex:0 0 auto;display:flex;flex-direction:column;gap:3px;background:#1d1d1d;border:1px solid #383838;border-radius:6px;padding:4px}
.bkp .card.cur{border-color:#e0a030}
.bkp .card .pair{display:flex;gap:3px}
.bkp .thumb{width:72px;height:54px;border-radius:4px;background:#111 center/cover no-repeat;position:relative;cursor:pointer;flex:0 0 auto}
.bkp .thumb .lbl{position:absolute;left:2px;bottom:2px;font-size:9px;background:rgba(0,0,0,.7);padding:0 3px;border-radius:2px}
.bkp .drop{white-space:pre-line;width:72px;height:54px;border:1px dashed #555;border-radius:4px;display:flex;align-items:center;justify-content:center;text-align:center;color:#888;font-size:10px;cursor:pointer;line-height:1.2;flex:0 0 auto}
.bkp .drop:hover,.bkp .drop.over{border-color:#3fbf6a;color:#cfc}
.bkp .card .foot{display:flex;align-items:center;gap:4px;font-size:11px;font-variant-numeric:tabular-nums}
.bkp .card .foot .sp{flex:1}
.bkp .x{color:#999;cursor:pointer;padding:0 3px;border-radius:3px}.bkp .x:hover{color:#f66;background:#2a1a1a}
.bkp .pics{display:flex;gap:6px}
.bkp .pic{position:relative}
.bkp .pic .thumb{width:60px;height:60px}
.bkp .pic .drop{width:60px;height:60px}
.bkp .pic .x{position:absolute;right:1px;top:1px;background:rgba(0,0,0,.7)}
.bkp .pic .tag{font-size:10px;color:#888;text-align:center}
.bkp .hint{color:#777;font-size:11px}.bkp .err{color:#f77;font-size:11px}
`;
document.head.appendChild(css);
}
function Panel(io) {
const plan = reactive(parsePlan(io.get()));
const strip = ref(null); // {subfolder, type, files, frames, count, fps, width, height} from the last run
const pos = ref(0); // index into strip.frames
const playing = ref(false);
const err = ref("");
const over = ref(""); // drop target being hovered
let timer = null;
const save = () => io.set(JSON.stringify({
keyframes: [...plan.keyframes].sort((a, b) => a.frame - b.frame),
pictures: plan.pictures.map(p => p || null),
}));
watch(plan, save, { deep: true });
const count = computed(() => strip.value?.count || 0);
const fps = computed(() => strip.value?.fps || io.fps() || 24);
const frame = computed(() => strip.value ? strip.value.frames[pos.value] : 0);
const sorted = computed(() => [...plan.keyframes].sort((a, b) => a.frame - b.frame));
const curKf = computed(() => plan.keyframes.find(k => k.frame === frame.value) || null);
const nearest = f => { // strip index of the frame nearest to f
const fr = strip.value?.frames || [];
let best = 0;
for (let i = 1; i < fr.length; i++) if (Math.abs(fr[i] - f) < Math.abs(fr[best] - f)) best = i;
return best;
};
const frameUrl = f => strip.value ? tempUrl(strip.value, strip.value.files[nearest(f)]) : "";
const fmt = f => `${(f / fps.value).toFixed(2)}s`;
const stop = () => { playing.value = false; if (timer) { clearInterval(timer); timer = null; } };
const play = () => {
if (!strip.value) return;
if (playing.value) return stop();
playing.value = true;
const fr = strip.value.frames;
const step = fr.length > 1 ? (fr[fr.length - 1] - fr[0]) / (fr.length - 1) : 1;
timer = setInterval(() => { pos.value = (pos.value + 1) % fr.length; }, 1000 * step / fps.value);
};
const seekIdx = i => { if (strip.value) pos.value = Math.max(0, Math.min(strip.value.frames.length - 1, i)); };
const seekFrame = f => seekIdx(nearest(f));
onBeforeUnmount(stop);
const addHere = () => {
if (!strip.value || curKf.value) return;
plan.keyframes.push({ frame: frame.value, edit: null });
};
const removeKf = k => { plan.keyframes.splice(plan.keyframes.indexOf(k), 1); };
const jumpKf = d => {
const list = sorted.value;
if (!list.length) return;
const f = frame.value;
const t = d > 0 ? list.find(k => k.frame > f) || list[0] : [...list].reverse().find(k => k.frame < f) || list[list.length - 1];
seekFrame(t.frame);
};
const withFile = async (file, apply) => {
if (!file) return;
err.value = "";
try { apply(await upload(file)); } catch (e) { err.value = String(e.message || e); }
};
const dropProps = (key, apply) => ({
class: ["drop", over.value === key ? "over" : ""],
onDragover: e => { e.preventDefault(); e.stopPropagation(); over.value = key; },
onDragleave: () => { if (over.value === key) over.value = ""; },
onDrop: e => { e.preventDefault(); e.stopPropagation(); over.value = ""; withFile(e.dataTransfer?.files?.[0], apply); },
onClick: async e => { e.stopPropagation(); withFile(await pickFile(), apply); },
});
// timeline: click / drag to seek
const tlDown = e => {
if (!strip.value) return;
const el = e.currentTarget, r = el.getBoundingClientRect();
const go = ev => {
const x = Math.max(0, Math.min(1, (ev.clientX - r.left) / r.width));
seekFrame(Math.round(x * Math.max(0, count.value - 1)));
};
stop(); go(e);
el.setPointerCapture?.(e.pointerId);
const move = ev => go(ev);
const up = () => { el.removeEventListener("pointermove", move); el.removeEventListener("pointerup", up); };
el.addEventListener("pointermove", move); el.addEventListener("pointerup", up);
};
const pct = f => `${count.value > 1 ? (100 * f / (count.value - 1)) : 0}%`;
io.expose({
executed: d => {
if (!d) return;
const keep = frame.value;
strip.value = d;
seekFrame(keep);
for (const f of d.files) { const im = new Image(); im.src = tempUrl(d, f); } // preload for smooth playback
},
reload: () => { Object.assign(plan, parsePlan(io.get())); },
});
return () => {
const s = strip.value;
const cards = sorted.value.map((k, i) => h("div", { class: ["card", k.frame === frame.value ? "cur" : ""], key: k.frame }, [
h("div", { class: "pair" }, [
h("div", { class: "thumb", title: "source frame: click to go there", style: s ? { backgroundImage: `url("${frameUrl(k.frame)}")` } : {},
onClick: () => { stop(); seekFrame(k.frame); } }, [h("span", { class: "lbl" }, "source")]),
k.edit
? h("div", { class: "thumb", title: "edited frame: click to replace", style: { backgroundImage: `url("${inputUrl(k.edit)}")` },
onClick: async () => withFile(await pickFile(), p => { k.edit = p; }) }, [h("span", { class: "lbl" }, "edited")])
: h("div", { ...dropProps(`kf${k.frame}`, p => { k.edit = p; }), title: "drop or click: the edited frame for this time (optional)" },
"drop edited\nframe"),
]),
h("div", { class: "foot" }, [
h("span", null, `#${i + 1} · ${fmt(k.frame)}`), h("span", { class: "sp" }),
k.edit ? h("span", { class: "x", title: "clear the edited frame", onClick: () => { k.edit = null; } }, "⟲") : null,
h("span", { class: "x", title: "remove this keyframe", onClick: () => removeKf(k) }, "✕"),
]),
]));
const linked = j => !!io.node.inputs?.find(x => x.name === `picture_${j}`)?.link;
const pics = plan.pictures.map((p, j) => h("div", { class: "pic", key: j }, [
linked(j + 1)
? h("div", { class: "drop", title: "this picture comes from the connected input" }, "input")
: p
? h("div", { class: "thumb", style: { backgroundImage: `url("${inputUrl(p)}")` }, title: "click to replace",
onClick: async () => withFile(await pickFile(), q => { plan.pictures[j] = q; }) })
: h("div", { ...dropProps(`pic${j}`, q => { plan.pictures[j] = q; }), title: "drop or click: a reference picture" }, "+ picture"),
p && !linked(j + 1) ? h("span", { class: "x", title: "remove", onClick: () => { plan.pictures[j] = null; } }, "✕") : null,
h("div", { class: "tag" }, `Picture ${j + 1}`),
]));
return h("div", { class: "bkp" }, [
h("div", { class: ["stage", curKf.value ? "onkf" : ""] }, s
? [h("img", { src: tempUrl(s, s.files[pos.value]), draggable: false }),
h("span", { class: "badge" }, `${fmt(frame.value)} · frame ${frame.value}/${count.value - 1}`),
curKf.value ? h("span", { class: "badge kf" }, curKf.value.edit ? "keyframe ✓" : "keyframe") : null]
: [h("div", { class: "empty" }, ["Queue this node once to load the guide frames.", h("br"),
"Then scrub, press + Keyframe, and drop the edited frames on the cards."])]),
h("div", { class: "ctl" }, [
h("button", { disabled: !s, title: "previous keyframe", onClick: () => { stop(); jumpKf(-1); } }, "⏮"),
h("button", { disabled: !s, title: "previous frame", onClick: () => { stop(); seekIdx(pos.value - 1); } }, "◀"),
h("button", { disabled: !s, title: playing.value ? "pause" : "play", onClick: play }, playing.value ? "⏸" : "▶"),
h("button", { disabled: !s, title: "next frame", onClick: () => { stop(); seekIdx(pos.value + 1); } }, "▶|"),
h("button", { disabled: !s, title: "next keyframe", onClick: () => { stop(); jumpKf(1); } }, "⏭"),
h("span", { class: "time" }, s ? `${fmt(frame.value)} / ${fmt(count.value - 1)}` : "--"),
h("span", { class: "sp" }),
curKf.value
? h("button", { class: "danger", onClick: () => removeKf(curKf.value) }, "- Keyframe")
: h("button", { class: "pri", disabled: !s, onClick: addHere, title: "mark the current frame as a keyframe" }, "+ Keyframe"),
]),
h("div", { class: "tl", onPointerdown: tlDown }, s ? [
h("div", { class: "fill", style: { width: pct(frame.value) } }),
...sorted.value.map(k => h("div", { class: ["mk", k.edit ? "done" : "", k.frame === frame.value ? "cur" : ""], key: k.frame,
style: { left: pct(k.frame) }, title: `${fmt(k.frame)}${k.edit ? " (edited)" : " (no edited frame yet)"}`,
onPointerdown: e => { e.stopPropagation(); stop(); seekFrame(k.frame); } })),
h("div", { class: "head", style: { left: pct(frame.value) } }),
] : []),
h("div", { class: "sec" }, ["Keyframes ", h("span", { class: "n" }, String(plan.keyframes.length)),
plan.keyframes.length ? h("span", null, ` · ${plan.keyframes.filter(k => k.edit).length} edited`) : null]),
h("div", { class: "row" }, cards.length ? cards : [h("div", { class: "hint" }, "No keyframes yet.")]),
h("div", { class: "sec" }, "References"),
h("div", { class: "pics" }, pics),
err.value ? h("div", { class: "err" }, err.value) : h("div", { class: "hint" }, "Outputs follow the plan on the next run. Without edited frames, source_frames are ready to edit."),
]);
};
}
app.registerExtension({
name: "BFSNodes.KeyframePlanner",
async nodeCreated(node) {
if (node.comfyClass !== "BFSKeyframePlanner") return;
styles();
const widget = node.widgets?.find(w => w.name === "plan");
if (widget) { // the JSON stays a widget (saved with the workflow) but is never shown
widget.type = "hidden"; widget.hidden = true; widget.computeSize = () => [0, -4];
widget.options = { ...(widget.options || {}), hidden: true, serialize: true };
widget.serializeValue = () => widget.value;
}
const fpsW = node.widgets?.find(w => w.name === "fps");
const host = document.createElement("div");
host.style.cssText = "position:relative;width:100%;height:100%;min-width:440px;min-height:520px";
node.addDOMWidget("bfs_keyframe_planner", "div", host, { serialize: false, hideOnZoom: false, hideInPanel: true });
wheelToCanvas(host);
const handle = {};
createApp({
setup: () => Panel({
node,
get: () => widget?.value ?? "{}",
set: v => { if (widget) widget.value = v; node.graph?.setDirtyCanvas(true); },
fps: () => Number(fpsW?.value) || 24,
expose: o => Object.assign(handle, o),
}),
}).mount(host);
const onExecuted = node.onExecuted;
node.onExecuted = function (output) {
onExecuted?.apply(this, arguments);
handle.executed?.(output?.bfs_kfplan?.[0]);
};
const onConfigure = node.onConfigure;
node.onConfigure = function () {
const r = onConfigure?.apply(this, arguments);
setTimeout(() => handle.reload?.(), 0);
return r;
};
node.size = [Math.max(node.size[0], DEFAULT_W), Math.max(node.size[1], DEFAULT_H)];
},
});
+72
View File
@@ -0,0 +1,72 @@
import { app } from '../../scripts/app.js';
app.registerExtension({
name: 'BFS.ModelSurgery',
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== 'BFSModelSurgery') return;
const original = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
original?.apply(this, arguments);
const node = this;
const root = document.createElement('div');
root.style.cssText = 'padding:8px;box-sizing:border-box;background:#202024;color:#ddd;overflow:auto;height:100%;font:12px sans-serif';
const note = document.createElement('p');
note.textContent = 'Run once to inspect the connected MODEL. Connect after LoRAs. Overlapping rules multiply.';
const search = document.createElement('input');
search.placeholder = 'Filter layers / glob for new rule';
search.style.width = '95%';
const scale = document.createElement('input');
scale.type = 'number'; scale.value = '1'; scale.step = '0.05'; scale.style.width = '65px';
const add = document.createElement('button'); add.textContent = 'Add pattern rule';
const summary = document.createElement('div');
const list = document.createElement('div');
const rulesView = document.createElement('div');
root.append(note, search, scale, add, summary, rulesView, list);
let layers = [];
const rulesWidget = () => node.widgets?.find(w => w.name === 'rules');
const read = () => { const v = JSON.parse(rulesWidget()?.value || '[]'); if (!Array.isArray(v)) throw Error('Rules must be a list'); return v; };
const write = rules => { const w = rulesWidget(); w.value = JSON.stringify(rules, null, 2); w.callback?.(w.value); node.graph?.setDirtyCanvas(true, true); renderRules(); };
const run = fn => { try { fn(); } catch (e) { summary.textContent = e.message; } };
function renderRules() {
rulesView.replaceChildren();
run(() => read().forEach((rule, index) => {
const row = document.createElement('div'); row.style.cssText = 'padding:4px 0;border-bottom:1px solid #444';
const enabled = document.createElement('input'); enabled.type = 'checkbox'; enabled.checked = rule.enabled !== false;
enabled.onchange = () => run(() => { const rules = read(); rules[index].enabled = enabled.checked; write(rules); });
const label = document.createElement('span'); label.textContent = `${rule.pattern}${rule.rows ? ' rows '+rule.rows.join(':') : ''} × `;
const value = document.createElement('input'); value.type = 'number'; value.step = '0.05'; value.value = rule.scale ?? 1; value.style.width = '65px';
value.onchange = () => run(() => { const rules = read(); rules[index].scale = Number(value.value); write(rules); });
const remove = document.createElement('button'); remove.textContent = '×';
remove.onclick = () => run(() => { const rules = read(); rules.splice(index, 1); write(rules); });
row.append(enabled, label, value, remove); rulesView.append(row);
}));
}
function renderLayers() {
list.replaceChildren();
const query = search.value.toLowerCase().replaceAll('*', '');
const matches = layers.filter(v => v.key.toLowerCase().includes(query));
summary.textContent = `${matches.length} matching parameters; displaying up to 150.`;
for (const layer of matches.slice(0, 150)) {
const row = document.createElement('div'); row.style.cssText = 'padding:4px;border-bottom:1px solid #333;overflow-wrap:anywhere';
const name = document.createElement('span'); name.textContent = `${layer.key} [${layer.shape.join(' × ')}] `;
const button = document.createElement('button'); button.textContent = 'Add';
button.onclick = () => run(() => write([...read(), { pattern: layer.key, scale: Number(scale.value) }]));
row.append(name, button); list.append(row);
}
}
search.oninput = renderLayers;
add.onclick = () => run(() => write([...read(), {pattern: search.value || '*', scale: Number(scale.value)}]));
this.addDOMWidget('model_surgery_panel', 'custom', root, {serialize: false, getMinHeight: () => 250});
this.size[0] = Math.max(this.size[0], 540);
const onExecuted = this.onExecuted;
this.onExecuted = function (message) {
onExecuted?.apply(this, arguments);
if (message.surgery_layers) layers = message.surgery_layers;
renderRules(); renderLayers();
};
const onConfigure = this.onConfigure;
this.onConfigure = function () { onConfigure?.apply(this, arguments); renderRules(); };
renderRules();
};
}
});
+17 -12
View File
@@ -29,7 +29,7 @@ function parseState(text) {
}
const STYLE_ID = "bfs-segpick-style";
function styles() {
export function styles() {
if (document.getElementById(STYLE_ID)) return;
const css = document.createElement("style");
css.id = STYLE_ID;
@@ -101,7 +101,9 @@ function loadMask(o, color) {
});
}
function Panel(io) {
export function Panel(io, options = {}) { // exported: BFS Video Segment Picker shows one per frame
const showEraser = options.showEraser !== false;
const showRemove = options.showRemove !== false;
const state = reactive(parseState(io.get()));
const data = ref(null); // {session, width, height, image, objects} from the last run
const loaded = new Map(); // object id -> loadMask result (not reactive: canvases and big arrays)
@@ -118,6 +120,7 @@ function Panel(io) {
const fit = reactive({ w: 0, h: 0 });
const save = () => io.set(JSON.stringify({ selected: state.selected, added: state.added, next: state.next }));
const entryId = kind => `${options.entryPrefix || ""}${kind}${state.next}`;
const objects = computed(() => data.value?.objects || []);
const colorOf = id => {
const i = Math.max(0, objects.value.findIndex(o => o.id === id));
@@ -137,7 +140,7 @@ function Panel(io) {
await Promise.all(objs.map(async (o, i) => {
const key = `${o.id}|${o.mask.filename}`;
if (loaded.get(o.id)?.key === key) return;
try { loaded.set(o.id, { key, ...(await loadMask(o, o.remove ? CUT : PALETTE[i % PALETTE.length])) }); } catch { /* shown as missing */ }
try { loaded.set(o.id, { key, ...(await loadMask(o, o.remove ? CUT : (options.maskColor || PALETTE[i % PALETTE.length]))) }); } catch { /* shown as missing */ }
}));
ready.value++;
}
@@ -221,7 +224,9 @@ function Panel(io) {
return;
}
const o = objectAt(p);
if (o) toggle(o.id);
if (options.autoPointOnClick && (!o || !o.entry)) {
add({ id: entryId("p"), points: [{ x: +p.x.toFixed(4), y: +p.y.toFixed(4), label: 1 }] });
} else if (o) toggle(o.id);
}
function onMove(e) {
if (data.value && brushing()) {
@@ -246,7 +251,7 @@ function Panel(io) {
if (!r.ok || j.error) throw new Error(j.error || r.statusText);
if (!j.objects.length) { error.value = entry.text ? `Nothing found for “${entry.text}”: try other words or a lower threshold.` : "The points gave an empty mask."; return; }
state.added = [...state.added.filter(a => a.id !== entry.id), entry];
state.next = Math.max(state.next, parseInt(entry.id.slice(1)) + 1);
state.next = Math.max(state.next, Number(entry.id.match(/(\d+)$/)?.[1] || 0) + 1);
data.value = { ...data.value, objects: [...data.value.objects.filter(o => o.entry !== entry.id), ...j.objects] };
state.selected = [...state.selected, ...j.objects.map(o => o.id).filter(id => !isSel(id))]; // what you add is what you want
save();
@@ -255,16 +260,16 @@ function Panel(io) {
error.value = String(err.message || err);
} finally { busy.value = ""; }
}
const addText = () => { const t = text.value.trim(); if (t) { add({ id: `t${state.next}`, text: t }); text.value = ""; } };
const addText = () => { const t = text.value.trim(); if (t) { add({ id: entryId("t"), text: t }); text.value = ""; } };
const addPoints = async () => {
if (!points.value.some(p => p.label)) return;
await add(cutting() ? { id: `r${state.next}`, remove: true, points: points.value } : { id: `p${state.next}`, points: points.value });
await add(cutting() ? { id: entryId("r"), remove: true, points: points.value } : { id: entryId("p"), points: points.value });
if (!error.value) setMode("select");
};
const addBrush = async () => {
if (!strokes.value.length) return;
const stroke = { image: data.value.image_key, strokes: strokes.value };
await add(cutting() ? { id: `e${state.next}`, remove: true, ...stroke } : { id: `b${state.next}`, ...stroke });
await add(cutting() ? { id: entryId("e"), remove: true, ...stroke } : { id: entryId("b"), ...stroke });
if (!error.value) setMode("select");
};
function removeEntry(entryId) {
@@ -327,10 +332,10 @@ function Panel(io) {
title: "Place points on something the automatic pass missed" }, "📍 Points"),
h("button", { class: mode.value === "brush" ? "on" : "", disabled: !d, onClick: () => setMode("brush"),
title: "Paint extra area into the mark by hand (e.g. hair SAM left out)" }, "🖌 Brush"),
h("button", { class: mode.value === "remove" ? "on" : "", disabled: !d, onClick: () => setMode("remove"),
title: "Place points on something to cut out of the mark (SAM segments it)" }, "➖ Remove"),
h("button", { class: mode.value === "eraser" ? "on" : "", disabled: !d, onClick: () => setMode("eraser"),
title: "Paint what to cut out of the mark by hand" }, "🧽 Eraser"),
showRemove ? h("button", { class: mode.value === "remove" ? "on" : "", disabled: !d, onClick: () => setMode("remove"),
title: "Place points on something to cut out of the mark (SAM segments it)" }, "➖ Remove") : null,
showEraser ? h("button", { class: mode.value === "eraser" ? "on" : "", disabled: !d, onClick: () => setMode("eraser"),
title: "Paint an area to subtract manually from the selected mask" }, "🧽 Eraser") : null,
h("span", { class: "hint" }, d ? `${objs.length} objects · ${state.selected.length} selected` : ""),
h("span", { style: "flex:1" }),
h("button", { disabled: !objs.length, onClick: () => { state.selected = objs.map(o => o.id); save(); } }, "All"),
+29 -72
View File
@@ -21,7 +21,6 @@ const historyLen = n => (n = Number(n) || 22) <= 5 ? 5 : 5 + Math.ceil((n - 5) /
import { timelineCard } from "./shotloop/view_timeline.js";
import { videoTab } from "./shotloop/view_video.js";
import { shotsTab } from "./shotloop/view_shots.js";
import { peopleTab } from "./shotloop/view_people.js";
import { promptsTab } from "./shotloop/view_prompts.js";
import { runTab } from "./shotloop/view_run.js";
import { busyBar, pointsModal, tourModal } from "./shotloop/view_modal.js";
@@ -37,11 +36,12 @@ const store = { // per-browser UI conveniences (never the plan itself)
set: (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* private mode */ } },
};
const DEFAULT_W = 1180, MIN_W = 1040, MIN_H = 1000; // node size: wide enough for the four-column settings
const TABS = [["video", "🎞 Video"], ["shots", "🎬 Shots"], ["people", "👥 People / Filters / Masks"], ["prompts", "📝 Prompts & refs"], ["run", "▶ Run"]];
// the flow, left to right: the video, the pictures and the prompt, each shot, then the run
const TABS = [["video", "🎞 Video"], ["prompts", "📝 Pictures & prompt"], ["shots", "🎬 Shots"], ["run", "▶ Run"]];
function Panel(io) {
const plan = reactive({ ...DEFAULTS });
const files = reactive({ videos: [], images: [] });
const files = reactive({ videos: [], images: [], pointModel: false }); // pointModel: MolmoPoint on the planner
const an = ref(null); // analysis from the server
const cuts = ref([]);
const detectorUsed = ref("");
@@ -65,15 +65,13 @@ function Panel(io) {
try { const r = await api.fetchApi("/bfs/shotloop/vram"); const j = await r.json(); if (!j.error) Object.assign(vram, j); } catch { /* optional */ }
}
const tlEl = ref(null);
const stats = ref([]); // per-shot content stats from /bfs/shotloop/filters
const people = ref([]); // cast from /bfs/shotloop/cast: [{id, thumb, share, first, last}]
const maskPrev = ref({}); // "start-end" -> {frames, box, coverage, empty} from /bfs/shotloop/mask
const showMasks = ref(true); // overlay the mask preview on the shot cards
const vlmSug = ref({}); // "start-end" -> {segment, shot, people, recommend, reason} from the VLM
const modal = reactive({ open: false, idx: -1, key: 0, points: [], text: "", region: 1, groups: {}, prev: null, busy: "" });
const segPeople = ref({}); // "start-end" -> {people, main} from /bfs/shotloop/plan
const targetCrop = ref({}); // "start-end" -> data URL of the subject the description was made from
const tab = ref(store.get("bfs.shotloop.tab", "shots"));
const savedTab = store.get("bfs.shotloop.tab", "shots");
const tab = ref(TABS.some(([k]) => k === savedTab) ? savedTab : "shots");
watch(tab, v => store.set("bfs.shotloop.tab", v));
const tour = ref(0); // opened only by the user's Tour button; never shown automatically
// what "Copy to other shots" copies (remembered per browser)
@@ -97,8 +95,6 @@ function Panel(io) {
let p = {};
try { p = JSON.parse(io.getPlan() || "{}"); } catch { p = {}; }
Object.assign(plan, DEFAULTS, p);
plan.filters = { ...DEFAULTS.filters, ...(p.filters || {}) };
plan.cast = { ...(p.cast || {}) };
plan.mask_cfg = { ...MASK_DEFAULTS, ...(p.mask_cfg || {}) };
plan.vlm_cfg = { ...VLM_DEFAULTS, ...(p.vlm_cfg || {}) };
plan.ref_details = { ...(p.ref_details || {}) };
@@ -118,8 +114,10 @@ function Panel(io) {
m.color_guide_regions = markRegions(m);
["color_guide_text2", "color_guide_points2", "color_guide_key2", "color_guide_color", "color_guide_color2"].forEach(k => delete m[k]);
});
Object.values(plan.cast).forEach(person => { if (person && typeof person === "object") { delete person.ref; delete person.ref2; delete person.guide_color; } });
plan.segs.forEach(meta => { if (meta) { delete meta.color_guide_person; delete meta.color_guide_person2; } });
// the shot filters and the face cast are gone (1.122): a shot skipped by hand is a disabled shot now
plan.segs.forEach(m => { if (m) { if (m.force === "skip") m.enabled = false; delete m.force; } });
["filters", "cast", "cast_split", "cast_only"].forEach(k => delete plan[k]);
};
// any change of the plan (a new video, a prompt, a setting...) is a new loop run on the server: show its progress
let progTimer = 0;
@@ -155,8 +153,8 @@ function Panel(io) {
const m = plan.segs[out.length] || {};
out.push({ start: b[i], end: b[i + 1], len: b[i + 1] - b[i], gen: snapUp(b[i + 1] - b[i], plan.grid),
cut: cs.has(b[i]), enabled: m.enabled !== false, refs: refList(m.refs, m.ref, m.ref2), prompt: m.prompt || "",
force: m.force || "auto", chain: m.chain || "head", chainFrame: m.chain_frame || "first", chainHistory: historyLen(m.chain_history),
color_guide: !!m.color_guide, color_guide_picture: +m.color_guide_picture || 1,
chain: m.chain || "head", chainFrame: m.chain_frame || "first", chainHistory: historyLen(m.chain_history),
color_guide: !!m.color_guide, color_guide_picture: +m.color_guide_picture || 1, color_guide_style: m.color_guide_style || "overlay",
color_guide_opacity: m.color_guide_opacity ?? 0.35, regions: markRegions(m),
crop: !!m.crop, inpaint: !!m.inpaint, paste: !!m.paste, pasteText: m.paste_text || "", strength: m.strength ?? 1, mask: m.mask || {}, target: m.target || "",
extMask: !!plan.mask_video || !!(plan.track?.points?.length), trackMask: !!(plan.track?.points?.length) });
@@ -172,22 +170,7 @@ function Panel(io) {
const b = [lo, ...plan.bounds.filter(x => x > lo && x < hi), hi].sort((a, c) => a - c);
return b.reduce((k, x, i) => k + (i && x > b[i - 1] ? 1 : 0), 0);
});
const whoIn = s => segPeople.value[segKey(s)] || null;
const castOf = id => plan.cast[String(id)] || {};
const included = id => !!personOf(id) && !castOf(id).ignore;
const personOf = id => people.value.find(p => p.id === id) || null;
const statFor = s => stats.value.find(x => x.start === s.start && x.end === s.end) || null;
const filtersOn = computed(() => { const f = plan.filters; return !!(f.person || f.face || f.skip_dark || f.skip_static || f.max_persons > 0 || f.min_frames > 0); });
const skipWhy = s => {
if (!s.enabled) return "disabled";
if (s.force === "run") return "";
if (s.force === "skip") return "skipped by hand";
const why = filtersOn.value ? (statFor(s)?.skip_reason || "") : "";
if (why) return why;
const who = whoIn(s);
if (plan.cast_only && who && !who.people.some(p => included(p))) return "no detected person";
return "";
};
const skipWhy = s => (s.enabled ? "" : "disabled");
const active = computed(() => segs.value.filter(s => !skipWhy(s)));
const pxPerFrame = computed(() => {
const w = (tlEl.value?.clientWidth || 600) - 2;
@@ -260,7 +243,7 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
if (/\{target\}/.test(p) && !s.target) add("warn", "the prompt uses {target} but the shot has no target description");
if (/\{details/.test(p) && !detailsOf(s, p) && !plan.vlm_cfg?.enabled) add("info", "the prompt uses {details} but its pictures have no description yet");
if (/\{details2?\}/.test(p) && (plan.vlm_cfg?.describe_preset || "short") !== "short")
add("warn", "{details} sits inside a sentence of the prompt: use the 'short' describe preset (Prompts & refs), the other presets write a paragraph");
add("warn", "{details} sits inside a sentence of the prompt: use the 'short' describe preset (Pictures & prompt → Describe references), the other presets write a paragraph");
if (!p) add("info", "no prompt (fine when the node's prompt input is connected)");
if (!refOf(s, "ref")) add("info", "no reference picture");
if (vram.max_tokens && shotTokens(s, i) > 1.3 * vram.max_tokens)
@@ -273,7 +256,7 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
const checksFor = i => checks.value.filter(c => c.shot === i);
async function refreshFiles() {
try { const r = await api.fetchApi("/bfs/shotloop/files"); const j = await r.json(); files.videos = j.videos || []; } catch (e) { /* ignore */ }
try { const r = await api.fetchApi("/bfs/shotloop/files"); const j = await r.json(); files.videos = j.videos || []; files.pointModel = !!j.point_model; } catch (e) { /* ignore */ }
}
async function analyze() {
an.value = null; error.value = "";
@@ -285,8 +268,6 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
if (j.error) throw new Error(j.error);
an.value = j;
await autoSplit(plan.bounds.length === 0);
people.value = []; segPeople.value = {};
if (Object.keys(plan.cast).length || plan.cast_split || plan.cast_only) await findPeople(false); // restore the cast of a saved plan
} catch (e) { error.value = String(e.message || e); }
busy.value = "";
}
@@ -301,45 +282,19 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
const j = await r.json();
if (j.error) throw new Error(j.error);
cuts.value = j.cuts || []; detectorUsed.value = j.detector || ""; size.w = j.width; size.h = j.height;
segPeople.value = Object.fromEntries(j.segs.filter(s => s.main !== undefined && (s.people.length || people.value.length))
.map(s => [`${s.start}-${s.end}`, { people: s.people, main: s.main }]));
if (apply) {
plan.bounds = j.segs.slice(1).map(s => s.start);
const keep = plan.segs; plan.segs = j.segs.map((_, i) => ({ enabled: true, refs: refList(keep[i]?.refs), prompt: keep[i]?.prompt || "",
chain: keep[i]?.chain || "head", chain_frame: keep[i]?.chain_frame || "first", chain_history: historyLen(keep[i]?.chain_history),
crop: !!keep[i]?.crop, inpaint: !!keep[i]?.inpaint, paste: !!keep[i]?.paste, paste_text: keep[i]?.paste_text || "", strength: keep[i]?.strength ?? 1, mask: keep[i]?.mask || {}, target: keep[i]?.target || "",
color_guide: !!keep[i]?.color_guide, color_guide_picture: keep[i]?.color_guide_picture || 1, color_guide_opacity: keep[i]?.color_guide_opacity ?? 0.35,
...(keep[i]?.color_guide_style ? { color_guide_style: keep[i].color_guide_style } : {}),
color_guide_regions: (keep[i]?.color_guide_regions || []).map(r => ({ ...r })) }));
sel.value = 0; save();
}
} catch (e) { error.value = String(e.message || e); }
busy.value = "";
}
async function analyzeContent() {
if (!plan.video) return;
busy.value = "Detecting people and faces…"; error.value = "";
try {
const r = await api.fetchApi("/bfs/shotloop/filters", { method: "POST", body: JSON.stringify({ plan: { ...plan } }) });
const j = await r.json(); if (j.error) throw new Error(j.error);
stats.value = j.segs.map(x => ({ ...x.stats, start: x.start, end: x.end, skip_reason: x.skip_reason }));
} catch (e) { error.value = String(e.message || e); }
busy.value = "";
}
async function findPeople(resplit = true) {
if (!plan.video) return;
busy.value = "Finding people by face…"; error.value = "";
try {
const r = await api.fetchApi("/bfs/shotloop/cast", { method: "POST", body: JSON.stringify({ plan: { ...plan } }) });
const j = await r.json(); if (j.error) throw new Error(j.error);
people.value = j.people || [];
} catch (e) { error.value = String(e.message || e); busy.value = ""; return; }
busy.value = "";
await autoSplit(resplit && !!plan.cast_split);
}
const setCast = (id, k, v) => {
plan.cast = { ...plan.cast, [String(id)]: { ...castOf(id), [k]: v } };
save();
};
async function describeTarget(i) {
const s = segs.value[i]; if (!s) return;
busy.value = "Selecting the person (SAM 3) and describing them (VLM)…"; busySince.value = Date.now(); error.value = "";
@@ -413,6 +368,11 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
const setMarkRegion = (i, j, field, v) => {
const m = meta(i); m.color_guide_regions = markRegions(m).map((r, q) => (q === j ? { ...r, [field]: v } : r)); save();
};
// how the marks are drawn; each style starts at its usual opacity (the overlay is translucent, the others solid)
const setMarkStyle = (i, style) => {
Object.assign(meta(i), { color_guide_style: style, color_guide_opacity: style === "overlay" ? 0.35 : 1 }); save();
const s = segs.value[i]; if (s && maskPrev.value[segKey(s)]) previewMask(i);
};
const removeMarkRegion = i => j => { const m = meta(i); m.color_guide_regions = markRegions(m).filter((_, q) => q !== j); save(); };
const setPictureColor = (k, color) => {
const list = [...(plan.picture_colors || [])];
@@ -479,7 +439,7 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
if (fields.target) m.target = src.target;
if (fields.color_guide) {
m.color_guide = src.color_guide; m.color_guide_picture = src.color_guide_picture;
m.color_guide_opacity = src.color_guide_opacity;
m.color_guide_opacity = src.color_guide_opacity; m.color_guide_style = src.color_guide_style;
m.color_guide_regions = src.regions.map(r => ({ ...r, points: [...(r.points || [])] }));
}
if (fields.chain && i > 0) { m.chain = src.chain; m.chain_frame = src.chainFrame; m.chain_history = src.chainHistory; }
@@ -526,6 +486,7 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
const autoFor = s => {
const marks = s.color_guide ? [[s.color_guide_picture, pictureColor(plan, s.color_guide_picture)],
...s.regions.filter(r => String(r.text || "").trim() || (r.points || []).length).map(r => [r.picture, pictureColor(plan, r.picture)])] : [];
if (marks.length && s.color_guide_style !== "overlay") marks.push(s.color_guide_style); // same as auto_prompt_for
return JSON.stringify([s.prompt || plan.global_prompt || "", s.names, marks, modeOf(s), s.mask?.text || "", s.target || "",
plan.auto_prompt?.template || "", plan.auto_prompt?.style || "h3", plan.auto_prompt?.trigger ?? "motion_transfer", "motion-trigger-bindings-v7"]);
};
@@ -552,19 +513,15 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
const applySug = (i, what) => {
const s = segs.value[i], g = s && vlmSug.value[segKey(s)]; if (!g) return;
if (what === "segment" && g.segment) setMask(i, "text", g.segment);
if (what === "skip") setMeta(i, "force", g.recommend === "skip" ? "skip" : "auto");
};
const applyAllSug = () => {
segs.value.forEach((s, i) => {
const g = vlmSug.value[segKey(s)]; if (!g) return;
const m = meta(i);
if (g.segment && !(m.mask?.text || (m.mask?.points || []).length)) m.mask = { ...(m.mask || {}), text: g.segment };
if (g.recommend === "skip" && (m.force || "auto") === "auto") m.force = "skip";
});
save();
};
const setCastOpt = (k, v) => { plan[k] = v; save(); if (k === "cast_split") autoSplit(true); };
const setFilter = (k, v) => { plan.filters = { ...plan.filters, [k]: v }; save(); if (stats.value.length) analyzeContent(); };
const setPlan = (k, v, after) => { plan[k] = v; save(); after && after(); };
// the range changed: split again inside it (the per-shot settings stay with the shot at the same position)
// start / end in timeline frames (end 0 = the end of the video); the old seconds fields are cleared
@@ -878,14 +835,14 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
// everything the views need
const c = {
api, plan, files, an, cuts, detectorUsed, size, busy, error, status, busySince, now, upPct, sel, zoom, hover, prog, tlEl,
stats, people, maskPrev, showMasks, vlmSug, modal, segPeople, targetCrop, tab, copyOpts, COPY_FIELDS, vid, play, recent, clearRecent, clearAllRefs,
n, range, setRange, maxLen, segs, allSegCount, active, pxPerFrame, tlWidth, filtersOn, usedRefs, checks, tour, memory, vram, refreshVram, cache, cacheOf, redo, combine,
MODES, modeOf, modeName, viewUrl, save, meta, setMeta, setMode, setPlan, whoIn, castOf, included, personOf, statFor, skipWhy, refOf, promptOf, checksFor,
refreshFiles, analyze, autoSplit, analyzeContent, findPeople, setCast, describeTarget, previewMask, setMask, setMaskCfg,
maskPrev, showMasks, vlmSug, modal, targetCrop, tab, copyOpts, COPY_FIELDS, vid, play, recent, clearRecent, clearAllRefs,
n, range, setRange, maxLen, segs, allSegCount, active, pxPerFrame, tlWidth, usedRefs, checks, tour, memory, vram, refreshVram, cache, cacheOf, redo, combine,
MODES, modeOf, modeName, viewUrl, save, meta, setMeta, setMode, setPlan, skipWhy, refOf, promptOf, checksFor,
refreshFiles, analyze, autoSplit, describeTarget, previewMask, setMask, setMaskCfg,
openPoints, switchPointRegion, savePoints, savePointsAll, saveTrack, clearTrack, pointsTo, clearMask, copyFrom, analyseVLM, setVlmCfg,
applySug, applyAllSug, setCastOpt, setGlobalMaskVideo, setFilter, pickFile, progress, setVideo, splitAt, mergeNext, removeCut, select, drag,
applySug, applyAllSug, setGlobalMaskVideo, pickFile, progress, setVideo, splitAt, mergeNext, removeCut, select, drag,
frameAt, thumbFor, playShot, playAll, stop, seek, setRef, putRefs, uploadRef, dropRefs, pictures, describePictures, setPictureDetail, setPictureCfg,
setMarkPicture, addMarkRegion, setMarkRegion, removeMarkRegion, setPictureColor,
setMarkPicture, addMarkRegion, setMarkRegion, removeMarkRegion, setPictureColor, setMarkStyle,
autoFor, writtenOf, setAuto, setWritten, writePrompts,
};
@@ -911,12 +868,12 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla
h("button", { title: t("Shot Planner tour"), onClick: () => { tour.value = 1; tab.value = "video"; } }, t("🧭 Tour")),
h("button", { title: "Open the full guide (every setting explained, with examples)", onClick: () => showGuide(guideUrl) }, "📖 Guide"),
]);
const counts = { shots: S.length || null, people: people.value.length || null };
const counts = { shots: S.length || null };
const tabs = h("div", { class: "tabs" }, TABS.map(([k, label]) => h("button", {
class: ["tab", tab.value === k && "on"], disabled: k !== "video" && !an.value, onClick: () => { tab.value = k; } }, [
label, counts[k] ? h("span", { class: "n" }, counts[k]) : null, k === "run" && warns ? h("span", { class: "dot", title: `${warns} warning(s)` }) : null,
])));
const views = { video: videoTab, shots: shotsTab, people: peopleTab, prompts: promptsTab, run: runTab };
const views = { video: videoTab, prompts: promptsTab, shots: shotsTab, run: runTab };
const cur = an.value || tab.value === "video" ? tab.value : "video";
return h("div", { class: "bsl root", ref: rootEl, tabindex: 0, onKeydown: onKey }, [
header, tabs,
+5
View File
@@ -510,6 +510,11 @@ app.registerExtension({
const r = created?.apply(this, arguments);
styles();
const host = el("div", { style: "width:100%;height:100%;min-height:320px" });
// Keep clicks/drags inside the interactive player from reaching LiteGraph. Otherwise a
// pointer event on a video/control can be interpreted as a canvas gesture and the node
// may recalculate its DOM-widget height, making the compare panel collapse after use.
for (const type of ["pointerdown", "pointerup", "pointermove", "click", "dblclick", "contextmenu"])
host.addEventListener(type, e => e.stopPropagation());
this._bfsCompare = buildPlayer(host);
wheelToCanvas(host); // the wheel zooms the canvas over the player too
this.addDOMWidget("bfs_video_compare", "div", host, { serialize: false, hideOnZoom: false, getMinHeight: () => 320 });
+385
View File
@@ -0,0 +1,385 @@
/**
* BFS Video Segment Picker: a strip with every frame of the batch on top; click a frame to show it in the image
* Segment Picker's canvas (the same Panel, one selection per frame). The strip marks each frame: ✓ picked by hand,
* ↔ tracked from a picked frame, ⚠ lost or nothing. State: the hidden `selection` widget, {"frames": {"i": selection}}.
*/
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { wheelToCanvas } from "./bfs_wheel.js";
import { Panel, styles as pickerStyles } from "./bfs_segment_picker.js";
import { createApp, h, ref, reactive } from "./vendor/vue.esm-browser.prod.mjs";
const fileUrl = f => api.apiURL(`/view?filename=${encodeURIComponent(f.filename)}&subfolder=${encodeURIComponent(f.subfolder || "")}&type=${f.type || "temp"}`);
const SUBJECTS = [["1", "#eb2828", "red"], ["2", "#236eeb", "blue"], ["3", "#3ccd3c", "green"]];
function parse(text) { // {frame: {subject: selection}}; a bare selection (older state) is subject 1
let s = {};
try { s = JSON.parse(text || "{}") || {}; } catch { s = {}; }
const f = (s.frames && typeof s.frames === "object") ? s.frames : {};
const out = {};
for (const [k, v] of Object.entries(f)) {
if (v && (v.selected || v.added)) out[k] = { "1": v };
else if (v && typeof v === "object") {
out[k] = Object.fromEntries(Object.entries(v).filter(([sk]) => sk === "lock" || (+sk >= 1 && +sk <= SUBJECTS.length)));
} else out[k] = {};
}
return out;
}
let styled = false;
function styles() {
if (styled) return;
styled = true;
const css = document.createElement("style");
css.textContent = `
.bvsp{--card:#1b1d21;--line:#2e3138;--muted:#8b919c;--text:#e3e6eb;--accent:#3b82f6;--ok:#22c55e;--trk:#60a5fa;--bad:#f43f5e;
position:absolute;inset:0;display:flex;flex-direction:column;gap:6px;padding:6px;box-sizing:border-box;font:12px system-ui,sans-serif;color:var(--text);overflow:hidden}
.bvsp .card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:6px 8px;display:flex;flex-direction:column;gap:6px;flex:0 0 auto}
.bvsp .row{display:flex;align-items:center;gap:8px;min-width:0}
.bvsp .sp{flex:1}
.bvsp .muted{color:var(--muted);font-size:11px}
.bvsp .btn{background:#2a2d34;border:1px solid #3a3e47;color:var(--text);border-radius:6px;padding:4px 10px;cursor:pointer;font:inherit;white-space:nowrap}
.bvsp .btn:hover{background:#343843}
.bvsp .btn.pri{background:var(--accent);border-color:#4b8ff7;color:#fff;font-weight:600}
.bvsp .btn.pri:hover{background:#4b8ff7}
.bvsp .chk{display:flex;align-items:center;gap:4px;color:var(--muted);font-size:11px;cursor:pointer;user-select:none}
.bvsp .subj{display:flex;gap:4px}
.bvsp .subj .t{display:flex;align-items:center;gap:5px;border:1px solid var(--line);background:#23262c;border-radius:999px;padding:3px 10px 3px 6px;cursor:pointer;color:var(--muted);font-weight:600}
.bvsp .subj .t .clr{display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border-radius:50%;margin-left:4px;font-size:10px;line-height:1;background:rgba(255,255,255,.15);color:#fff;cursor:pointer}
.bvsp .subj .t .clr:hover{background:#f43f5e;color:#fff}
.bvsp .subj .t i{width:10px;height:10px;border-radius:50%;display:block}
.bvsp .subj .t.on{color:#fff;border-color:currentColor;background:#2b2f37}
.bvsp .subj .t.lock.on{color:#fbbf24;background:#44351b}
.bvsp .stat{display:flex;align-items:center;gap:8px;font-size:11px;color:#cfd6e0}
.bvsp .stat .pb{flex:0 0 110px;height:5px;background:#2a2d34;border-radius:3px;overflow:hidden}
.bvsp .stat .pb i{display:block;height:100%}
.bvsp .stat .txt{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bvsp .stat .tog{cursor:pointer;color:var(--trk);user-select:none}
.bvsp .log{max-height:90px;overflow:auto;background:#141518;border:1px solid var(--line);border-radius:6px;padding:4px 6px;font:11px ui-monospace,monospace;color:#aab}
.bvsp .counts{display:flex;gap:10px;font-size:11px;color:var(--muted)}
.bvsp .counts b{font-weight:700}
.bvsp .strip{display:flex;gap:5px;overflow-x:auto;padding-bottom:2px}
.bvsp .fr{position:relative;flex:0 0 auto;width:96px;height:58px;border-radius:6px;background:#111 center/cover no-repeat;cursor:pointer;border:2px solid transparent;outline:1px solid var(--line)}
.bvsp .fr.on{border-color:#f5b041}
.bvsp .fr .b{position:absolute;left:3px;bottom:3px;font-size:10px;background:rgba(0,0,0,.7);padding:0 4px;border-radius:3px}
.bvsp .fr .s{position:absolute;right:3px;top:3px;font-size:11px;font-weight:700;padding:0 4px;border-radius:3px;background:rgba(0,0,0,.7)}
.bvsp .fr .s.picked{color:var(--ok)}.bvsp .fr .s.tracked{color:var(--trk)}.bvsp .fr .s.lost{color:var(--bad)}
.bvsp .fr .dots{position:absolute;left:3px;top:3px;display:flex;gap:2px}
.bvsp .fr .dots i{width:8px;height:8px;border-radius:50%;border:1px solid #000;display:block}
.bvsp .tl{display:flex;gap:10px;align-items:center}
.bvsp .tl img{height:72px;border-radius:6px;border:1px solid var(--line);flex:0 0 auto}
.bvsp .tl .scrub{flex:1;display:flex;flex-direction:column;gap:6px;min-width:140px}
.bvsp .tl .track{position:relative;height:18px}
.bvsp .tl .track .bg{position:absolute;left:0;right:0;top:7px;height:4px;border-radius:2px}
.bvsp .tl .track input{position:absolute;inset:0;width:100%;margin:0;background:transparent;-webkit-appearance:none;appearance:none}
.bvsp .tl .track input::-webkit-slider-runnable-track{background:transparent;height:18px}
.bvsp .tl .track input::-moz-range-track{background:transparent}
.bvsp .tl .track input::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:14px;margin-top:2px;border-radius:50%;background:#fff;border:2px solid var(--accent);cursor:pointer}
.bvsp .tl .track input::-moz-range-thumb{width:12px;height:12px;border-radius:50%;background:#fff;border:2px solid var(--accent);cursor:pointer}
.bvsp .tl .fn{font-variant-numeric:tabular-nums;color:var(--text);font-weight:600}
.bvsp .chips{display:flex;flex-wrap:wrap;gap:4px;align-items:center}
.bvsp .chips .c{display:flex;align-items:center;gap:4px;border:1px solid #6b2a37;background:#2e1a1f;color:#fecdd3;border-radius:999px;padding:1px 8px;cursor:pointer;font-size:11px}
.bvsp .chips .c:hover{background:#45222b}
.bvsp .chips .c i{width:8px;height:8px;border-radius:50%;display:block}
.bvsp .chips .okk{color:var(--ok);font-size:11px}
.bvsp .host{position:absolute;inset:0}
.bvsp .empty{color:var(--muted);text-align:center;padding:12px}
`;
document.head.appendChild(css);
}
// A run the panel starts for picking ("Segment again", "+ frame") only segments the strip: the selection it sends is marked
// segment_only, so the node skips tracking and Molmo2. "Propagate" and a normal workflow run track as usual.
const propagating = new Set();
let wrapped = false;
function wrapQueue() {
if (wrapped) return;
wrapped = true;
const orig = api.queuePrompt.bind(api);
api.queuePrompt = async function (number, p, opts) {
const targets = opts?.partialExecutionTargets || [];
for (const id of targets) {
const out = p?.output?.[String(id)];
if (out?.class_type !== "BFSVideoSegmentPicker" || propagating.has(String(id))) continue;
try {
const sel = JSON.parse(out.inputs.selection || "{}");
sel.segment_only = true;
out.inputs.selection = JSON.stringify(sel);
} catch { /* leave it */ }
}
return orig(number, p, opts);
};
}
app.registerExtension({
name: "BFSNodes.VideoSegmentPicker",
async nodeCreated(node) {
if (node.comfyClass !== "BFSVideoSegmentPicker") return;
pickerStyles(); styles();
const widget = node.widgets?.find(w => w.name === "selection");
if (widget) {
widget.type = "hidden"; widget.hidden = true; widget.computeSize = () => [0, -4];
widget.options = { ...(widget.options || {}), hidden: true, serialize: true };
widget.serializeValue = () => widget.value;
}
const frames = reactive({ v: parse(widget?.value) });
let focusFrame = 0;
const scopeWidget = node.widgets?.find(w => w.name === "propagate_scope");
const validScopes = ["all frames", "only this frame", "from active frame onward", "from first selected frame onward", "fill only empty masks"];
const scope = ref(validScopes.includes(scopeWidget?.value) ? scopeWidget.value : "fill only empty masks");
if (scopeWidget) scopeWidget.value = scope.value;
const save = () => { if (widget) widget.value = JSON.stringify({ frames: frames.v, active_frame: focusFrame }); node.graph?.setDirtyCanvas(true); };
const hasPicks = k => Object.values(frames.v).some(f => f?.[k]?.selected?.length);
const clearSubject = k => {
let changed = false;
for (const f of Object.keys(frames.v)) {
if (frames.v[f]?.[k]) {
delete frames.v[f][k];
changed = true;
}
}
if (changed) {
scope.value = "all frames";
if (scopeWidget) scopeWidget.value = "all frames";
save();
mountPanel();
if (propagatedOnce()) propagate();
}
};
const run = reactive({ data: null }); // {frames: [panel data per frame], report: [...]}
const active = ref(0);
const subject = ref("1"); // which subject the clicks select (its color = Picture number)
const root = document.createElement("div");
root.style.cssText = "position:relative;width:100%;height:100%;min-width:440px;min-height:420px";
node.addDOMWidget("bfs_video_segment_picker", "div", root, { serialize: false, hideOnZoom: false, hideInPanel: true });
wheelToCanvas(root);
// the image picker's Panel for the active frame, remounted when the frame changes
let panelApp = null, handle = {};
const panelHost = document.createElement("div");
panelHost.className = "host";
const mountPanel = () => {
panelApp?.unmount(); panelHost.innerHTML = ""; handle = {};
const i = run.data?.indices?.[active.value] ?? active.value; // the real frame index of the strip entry
focusFrame = i;
const sj = subject.value;
const el = document.createElement("div"); el.style.cssText = "position:absolute;inset:0"; panelHost.appendChild(el);
panelApp = createApp({ setup: () => Panel({
node,
get: () => JSON.stringify(frames.v[String(i)]?.[sj] || {}),
set: v => {
focusFrame = i;
const before = JSON.stringify(frames.v[String(i)]?.[sj]?.selected || []);
try { frames.v[String(i)] = { ...(frames.v[String(i)] || {}), [sj]: JSON.parse(v) }; } catch { /* keep */ }
save();
if (JSON.stringify(frames.v[String(i)]?.[sj]?.selected || []) !== before) autoPropagate();
},
expose: o => Object.assign(handle, o),
}, { showEraser: false, showRemove: false, autoPointOnClick: sj === "lock", entryPrefix: sj === "lock" ? "lock_" : "",
maskColor: sj === "lock" ? [251, 191, 36] : null }) });
panelApp.mount(el);
const d = run.data?.frames?.[active.value];
if (d) setTimeout(() => handle.executed?.(d), 0);
};
const pickedSubjects = i => Object.entries(frames.v[String(run.data?.indices?.[i] ?? i)] || {}).filter(([, v]) => (v?.selected || []).length).map(([k]) => k);
const status = i => {
const own = (frames.v[String(run.data?.indices?.[i] ?? i)]?.[subject.value]?.selected || []).length > 0;
const r = subject.value === "lock"
? (run.data?.protected_frames?.[i] ? "tracked protected" : "")
: (run.data?.report?.[i]?.subjects?.[subject.value] || run.data?.report?.[i]?.source || "");
if (own) return ["picked", "✓"];
if (r.startsWith("tracked")) return ["tracked", "↔"];
if (r.startsWith("lost")) return ["lost", "⚠"];
return ["", ""];
};
const busy = ref("");
const live = reactive({ text: "", progress: null, elapsed: 0, log: [], open: false, running: false });
api.addEventListener("bfs_vsegpick_status", e => {
const d = e.detail || {};
if (String(d.node) !== String(node.id)) return;
live.text = d.text; live.elapsed = d.elapsed || 0; live.running = (d.progress ?? 0) < 1;
if (d.progress !== null && d.progress !== undefined) live.progress = d.progress;
if (d.log) { live.log.push(`[${(d.elapsed || 0).toFixed(1)}s] ${d.text}`); if (live.log.length > 60) live.log.shift(); }
});
wrapQueue();
const segmentOnly = async () => { // a picking run: the new frame is segmented, nothing is tracked
busy.value = "segmenting…";
try { const p = await app.graphToPrompt(); await api.queuePrompt(0, p, { partialExecutionTargets: [String(node.id)] }); }
catch (e) { busy.value = String(e.message || e); }
};
const propagate = async () => {
busy.value = "propagating…";
try {
const p = await app.graphToPrompt();
propagating.add(String(node.id));
try { await api.queuePrompt(0, p, { partialExecutionTargets: [String(node.id)] }); } finally { propagating.delete(String(node.id)); }
} catch (e) { busy.value = String(e.message || e); }
};
// after the first Propagate, a pick that changes a frame's selection propagates by itself (1.5 s after the last
// click): only the shots whose picks changed are tracked again, the rest comes from the cache
const auto = ref(localStorage.getItem("bfs_vsegpick_auto") === "1");
let autoTimer = null;
const propagatedOnce = () => (run.data?.report || []).some(r => /tracked|lost|Molmo/.test(JSON.stringify(r)));
const autoPropagate = () => {
if (!auto.value || !propagatedOnce()) return;
clearTimeout(autoTimer);
busy.value = "auto-propagate in 1.5 s…";
autoTimer = setTimeout(propagate, 1500);
};
const addFrame = f => { // put a frame in the strip (an empty pick) and run, so it can be picked on
const n = run.data?.count || 0;
f = Math.max(0, Math.min(n - 1, Math.round(+f)));
if (!Number.isFinite(f)) return;
focusFrame = f;
if (!frames.v[String(f)]) { frames.v[String(f)] = {}; save(); }
else save();
segmentOnly();
};
const SUBJ = { "1": "red", "2": "blue", "3": "green" };
const tlPos = ref(0); // index into run.data.timeline (whole-video scrubber)
const counts = () => {
const r = run.data?.report || []; const n = run.data?.frames?.length || 0;
let picked = 0, tracked = 0, lost = 0;
for (let i = 0; i < n; i++) { const [c] = status(i); if (c === "picked") picked++; else if (c === "tracked") tracked++; else if (c === "lost") lost++; }
return { picked, tracked, lost, n };
};
const tlAt = () => run.data.timeline[Math.min(tlPos.value, run.data.timeline.length - 1)];
const lostTrack = () => { // the scrubber's track: lost stretches in red over the video's length
const n = Math.max(1, (run.data?.count || 1) - 1);
const runs = [...(run.data?.lost || [])];
if (!runs.length) return "#3a3e47";
const stops = ["#3a3e47 0%"];
for (const r of runs.sort((a, b) => a.start - b.start)) {
const a = (100 * r.start / n).toFixed(2), b = (100 * Math.max(r.end, r.start + 0.5) / n).toFixed(2);
stops.push(`#3a3e47 ${a}%`, `#f43f5e ${a}%`, `#f43f5e ${b}%`, `#3a3e47 ${b}%`);
}
return `linear-gradient(90deg, ${stops.join(", ")}, #3a3e47 100%)`;
};
createApp({
setup: () => () => h("div", { class: "bvsp" }, [
h("div", { class: "card" }, [
h("div", { class: "row" }, [
h("div", { class: "subj" }, [...SUBJECTS.map(([k, col, name]) => h("span", {
class: ["t", subject.value === k ? "on" : ""], style: subject.value === k ? { color: col } : null,
title: `Subject ${k} (${name}) = Picture ${k}: clicks select this subject`,
onClick: () => { if (subject.value !== k) { subject.value = k; mountPanel(); } } },
[
h("i", { style: { background: col } }),
h("span", { style: "color:#fff" }, `${k} · ${name}`),
hasPicks(k) ? h("span", {
class: "clr",
title: `Clear all picks for Subject ${k} (${name})`,
onClick: (e) => { e.stopPropagation(); clearSubject(k); }
}, "✕") : null,
])), h("span", {
class: ["t", "lock", subject.value === "lock" ? "on" : ""],
title: "Everything outside the selected subject is already protected. Use this padlock only for an object inside that subject's mask; click it to segment with SAM 3.",
onClick: () => { if (subject.value !== "lock") { subject.value = "lock"; mountPanel(); } },
}, [
h("span", null, "🔒"),
hasPicks("lock") ? h("span", {
class: "clr", title: "Clear all protected-object picks",
onClick: e => { e.stopPropagation(); clearSubject("lock"); },
}, "✕") : null,
])]),
h("span", { class: "sp" }),
h("select", { value: scope.value, title: "Choose how a new frame selection changes the propagated masks",
style: "max-width:190px;background:#23262c;color:#e3e6eb;border:1px solid #3a3e47;border-radius:6px;padding:4px;font:inherit",
onChange: e => { scope.value = e.target.value; if (scopeWidget) scopeWidget.value = e.target.value; node.graph?.setDirtyCanvas(true); } },
["from active frame onward", "only this frame", "fill only empty masks", "all frames", "from first selected frame onward"].map(v => h("option", { value: v },
v === "from active frame onward" ? "From this frame onward" : v === "only this frame" ? "Only this frame"
: v === "fill only empty masks" ? "Fill only empty masks" : v === "all frames" ? "Recompute all frames" : "From earliest selected frame"))),
h("label", { class: "chk", title: "After a selection changes, apply the selected fix mode automatically after 1.5 seconds" }, [
h("input", { type: "checkbox", checked: auto.value, onChange: e => { auto.value = e.target.checked;
try { localStorage.setItem("bfs_vsegpick_auto", e.target.checked ? "1" : "0"); } catch { /* ignore */ } } }), "auto"]),
h("button", { class: "btn pri", onClick: propagate, title: "Apply the selected fix mode to the current selection" }, "↔ Apply fix"),
]),
live.text || busy.value ? h("div", { class: "stat" }, [
h("span", { class: "pb" }, [h("i", { style: { width: `${Math.round(100 * (live.progress ?? 0))}%`,
background: live.running ? "#3b82f6" : "#22c55e" } })]),
h("span", { class: "txt", title: live.text }, busy.value && !live.running ? busy.value
: `${live.running ? "⏳" : "✓"} ${live.text} · ${live.elapsed}s`),
h("span", { class: "tog", onClick: () => { live.open = !live.open; } }, live.open ? "▾ log" : "▸ log"),
]) : h("div", { class: "muted" }, subject.value === "lock"
? "🔒 Outside the subject is already protected. Click an object inside the subject mask to keep it unchanged, then Apply fix."
: "Pick a subject, click a frame below, then click its person. Subject N = Picture N."),
live.open ? h("div", { class: "log" }, live.log.length ? live.log.map((l, i) => h("div", { key: i }, l)) : "no log yet") : null,
]),
h("div", { class: "card" }, [
h("div", { class: "row" }, [
h("span", { style: "font-weight:600" }, "Frames"),
run.data ? h("span", { class: "counts" }, (() => { const c = counts(); return [
h("span", null, [h("b", { style: "color:#22c55e" }, "✓ "), `${c.picked} picked`]),
h("span", null, [h("b", { style: "color:#60a5fa" }, "↔ "), `${c.tracked} tracked`]),
h("span", null, [h("b", { style: "color:#f43f5e" }, "⚠ "), `${c.lost} lost`])]; })()) : null,
]),
run.data?.frames?.length
? h("div", { class: "strip" }, run.data.frames.map((d, i) => {
const [cls, mark] = status(i);
return h("div", { class: ["fr", i === active.value ? "on" : ""], key: i, title: run.data.report?.[i]?.source || "",
style: { backgroundImage: `url("${fileUrl(d.image)}")` },
onClick: () => { if (active.value !== i) { active.value = i; focusFrame = run.data.indices?.[i] ?? i; save(); mountPanel(); } } },
[h("span", { class: "b" }, `f${run.data.indices?.[i] ?? i}`), mark ? h("span", { class: ["s", cls] }, mark) : null,
h("span", { class: "dots" }, pickedSubjects(i).map(k => k === "lock"
? h("span", { title: "Protected object picked", style: "color:#fbbf24;background:#181818;border-radius:3px;padding:0 2px" }, "🔒")
: h("i", { style: { background: SUBJECTS[(+k - 1) % SUBJECTS.length][1] } })))]);
}))
: h("div", { class: "empty" }, "Run the node once: the frames appear here. Click a frame, then the person."),
]),
run.data?.timeline?.length ? h("div", { class: "card" }, [
h("div", { class: "tl" }, [
h("img", { src: fileUrl(tlAt()), title: "every frame with its masks: scrub to find a wrong or missing mark" }),
h("div", { class: "scrub" }, [
h("div", { class: "row" }, [
h("span", { style: "font-weight:600" }, "Whole video"),
h("span", { class: "fn" }, `f${tlAt().frame}`),
h("span", { class: "muted" }, `/ ${(run.data.count || 1) - 1}`),
h("span", { class: "sp" }),
h("button", { class: "btn", onClick: () => addFrame(tlAt().frame),
title: "put this frame in the strip to pick or fix the person on it" }, "+ add this frame"),
]),
h("div", { class: "track" }, [
h("div", { class: "bg", style: { background: lostTrack() } }),
h("input", { type: "range", min: 0, max: run.data.timeline.length - 1, value: tlPos.value,
onInput: e => { tlPos.value = +e.target.value; } }),
]),
h("div", { class: "chips" }, (run.data.lost || []).length
? (run.data.lost || []).slice(0, 12).map(r => h("span", { class: "c", title: "add the middle frame of this stretch to the strip",
onClick: () => addFrame((r.start + r.end) / 2) },
[h("i", { style: { background: SUBJECTS[(+r.subject - 1) % SUBJECTS.length]?.[1] || "#999" } }), `f${r.start}–${r.end}`]))
: [h("span", { class: "okk" }, "✓ no lost stretches")]),
]),
]),
]) : null,
h("div", { ref: el => { if (el && !el.contains(panelHost)) { el.appendChild(panelHost); if (!panelApp) mountPanel(); } }, style: "flex:1;position:relative;min-height:260px;border:1px solid #2e3138;border-radius:8px;overflow:hidden" }),
]),
}).mount(root);
const onExecuted = node.onExecuted;
node.onExecuted = function (output) {
onExecuted?.apply(this, arguments);
const d = output?.bfs_vsegpick?.[0];
if (!d) return;
run.data = d; busy.value = "";
const focusIndex = d.indices?.indexOf?.(focusFrame) ?? -1;
if (focusIndex >= 0) active.value = focusIndex;
if (active.value >= d.frames.length) active.value = 0;
mountPanel();
};
const onConfigure = node.onConfigure;
node.onConfigure = function () {
const r = onConfigure?.apply(this, arguments);
setTimeout(() => {
let saved = {};
try { saved = JSON.parse(widget?.value || "{}"); } catch { /* ignore */ }
frames.v = parse(widget?.value); focusFrame = Number.isInteger(+saved.active_frame) ? +saved.active_frame : 0;
if (scopeWidget?.value) scope.value = scopeWidget.value;
mountPanel();
}, 0);
return r;
};
node.size = [Math.max(node.size[0], 780), Math.max(node.size[1], 1180)];
},
});
+167
View File
@@ -0,0 +1,167 @@
/**
* BFS Workflow Loop: the panel. Lists the nodes upstream of `result`. "Inject" picks a node whose output is replaced
* by the current item (or its mask) on every iteration; "Change per iteration" picks widgets with one value per
* iteration (one per line, or "a, b, c" / "start:stop:step" for numbers). State lives in the hidden `loop` widget.
*/
import { app } from "../../scripts/app.js";
import { wheelToCanvas } from "./bfs_wheel.js";
import { createApp, h, reactive, computed, watch } from "./vendor/vue.esm-browser.prod.mjs";
const SKIP_TYPES = new Set(["hidden", "converted-widget", "button"]);
const SKIP_NAMES = new Set(["control_after_generate", "upload", "image_upload", "videopreview", "loop"]);
function upstreamNodes(node) {
const graph = node.graph || app.graph;
const getLink = id => graph.links[id] ?? graph.links.get?.(id);
const inp = node.inputs?.find(i => i.name === "result");
const link = inp?.link != null ? getLink(inp.link) : null;
if (!link) return [];
const seen = new Set(), order = [], stack = [link.origin_id];
while (stack.length) {
const id = stack.pop();
if (seen.has(id)) continue;
seen.add(id);
const n = graph.getNodeById(id);
if (!n) continue;
order.push(n);
for (const i of n.inputs || []) if (i.link != null) { const l = getLink(i.link); if (l) stack.push(l.origin_id); }
}
return order.sort((a, b) => a.id - b.id);
}
const widgetsOf = n => (n?.widgets || []).filter(w => w.name && !SKIP_TYPES.has(w.type) && !SKIP_NAMES.has(w.name) && !w.hidden);
const kindOf = w => (w?.type === "combo" || Array.isArray(w?.options?.values)) ? "combo"
: (w?.type === "toggle" || typeof w?.value === "boolean") ? "bool" : (w?.type === "number" || typeof w?.value === "number") ? "number" : "text";
function parseValues(text, kind) {
if (kind === "number") {
const out = [];
for (const part of String(text).split(/[,\n]/).map(s => s.trim()).filter(Boolean)) {
const r = part.split(":").map(Number);
if (r.length === 3 && r.every(Number.isFinite) && r[2] !== 0) {
for (let v = r[0], i = 0; (r[2] > 0 ? v <= r[1] + 1e-9 : v >= r[1] - 1e-9) && i < 256; v += r[2], i++) out.push(+v.toFixed(6));
} else if (Number.isFinite(+part)) out.push(+part);
}
return out;
}
if (kind === "bool") return String(text).split(/[,\n]/).map(s => s.trim().toLowerCase()).filter(Boolean).map(s => ["1", "true", "yes", "on"].includes(s));
return String(text).split("\n").map(s => s.trim()).filter(Boolean);
}
function parseState(text) {
let s = {};
try { s = JSON.parse(text || "{}") || {}; } catch { s = {}; }
return {
inject: (s.inject || []).map(i => ({ node: String(i.node ?? ""), slot: +i.slot || 0, source: i.source || "items" })),
params: (s.params || []).map(p => ({ node: String(p.node ?? ""), widget: p.widget || "", text: p.text ?? (p.values || []).join("\n") })),
};
}
let styled = false;
function styles() {
if (styled) return;
styled = true;
const css = document.createElement("style");
css.textContent = `
.bwl{position:absolute;inset:0;display:flex;flex-direction:column;gap:8px;padding:6px;box-sizing:border-box;font:12px system-ui,sans-serif;color:#ddd;overflow:auto}
.bwl .sec{display:flex;align-items:center;gap:6px;color:#9ab;font-size:11px;text-transform:uppercase;letter-spacing:.05em}
.bwl .row{background:#1d1d1d;border:1px solid #363636;border-radius:6px;padding:5px;display:flex;flex-direction:column;gap:4px}
.bwl .line{display:flex;gap:4px;align-items:center}
.bwl select,.bwl textarea{background:#262626;color:#ddd;border:1px solid #4a4a4a;border-radius:4px;padding:3px 5px;font:inherit;min-width:0;flex:1}
.bwl textarea{resize:vertical;min-height:38px}
.bwl button{background:#333;color:#ddd;border:1px solid #555;border-radius:4px;padding:2px 8px;cursor:pointer;font:inherit}
.bwl button:hover{background:#444}
.bwl .x{color:#999;cursor:pointer;padding:0 4px}.bwl .x:hover{color:#f66}
.bwl .hint{color:#888;font-size:11px}.bwl .warn{color:#f7a}
.bwl .foot{color:#bbb}.bwl .foot b{color:#fff}
`;
document.head.appendChild(css);
}
function Panel(io) {
const st = reactive(parseState(io.get()));
const tick = reactive({ n: 0 });
const nodes = computed(() => (tick.n, upstreamNodes(io.node)));
const byId = id => nodes.value.find(n => String(n.id) === String(id));
const save = () => io.set(JSON.stringify({
inject: st.inject.filter(i => i.node).map(i => ({ node: i.node, slot: i.slot, source: i.source })),
params: st.params.filter(p => p.node && p.widget).map(p => {
const w = widgetsOf(byId(p.node)).find(x => x.name === p.widget);
return { node: p.node, widget: p.widget, values: parseValues(p.text, kindOf(w)), text: p.text };
}),
}));
watch(st, save, { deep: true });
io.expose({ refresh: () => { tick.n++; }, reload: () => { Object.assign(st, parseState(io.get())); tick.n++; } });
const nodeSelect = (obj, onPick) => h("select", { value: obj.node, onChange: e => { obj.node = e.target.value; onPick?.(); } },
[h("option", { value: "" }, "— pick a node —"), ...nodes.value.map(n => h("option", { value: String(n.id) }, `[${n.id}] ${n.title || n.type}`))]);
const injectRow = (i, idx) => {
const n = byId(i.node);
const outs = (n?.outputs || []).map((o, k) => ({ k, label: `${o.name || o.type} (${o.type})` }));
return h("div", { class: "row", key: "i" + idx }, [
h("div", { class: "line" }, [nodeSelect(i, () => { i.slot = 0; }), h("span", { class: "x", onClick: () => st.inject.splice(idx, 1) }, "✕")]),
n ? h("div", { class: "line" }, [
h("select", { value: String(i.slot), onChange: e => { i.slot = +e.target.value; } }, outs.map(o => h("option", { value: String(o.k) }, `output: ${o.label}`))),
h("select", { value: i.source, onChange: e => { i.source = e.target.value; } },
[h("option", { value: "items" }, "← item i (items)"), h("option", { value: "items_b" }, "← item i (items_b)"),
h("option", { value: "item_masks" }, "← mask i (item_masks)")]),
]) : null,
]);
};
const paramRow = (p, idx) => {
const n = byId(p.node);
const w = widgetsOf(n).find(x => x.name === p.widget);
const kind = kindOf(w);
const vals = parseValues(p.text, kind);
return h("div", { class: "row", key: "p" + idx }, [
h("div", { class: "line" }, [nodeSelect(p, () => { p.widget = ""; p.text = ""; }), h("span", { class: "x", onClick: () => st.params.splice(idx, 1) }, "✕")]),
n ? h("select", { value: p.widget, onChange: e => { p.widget = e.target.value; const ww = widgetsOf(n).find(x => x.name === p.widget); p.text = ww ? String(ww.value ?? "") : ""; } },
[h("option", { value: "" }, "— pick a widget —"), ...widgetsOf(n).map(x => h("option", { value: x.name }, `${x.name} (${String(x.value).split(/[\\/]/).pop().slice(0, 36)})`))]) : null,
w ? h("textarea", { value: p.text, placeholder: kind === "number" ? "1, 2, 3 or 1:10:1" : "one value per line (iteration 1, 2, 3...)",
onInput: e => { p.text = e.target.value; } }) : null,
w ? h("div", { class: "hint" }, kind === "combo" ? `${vals.length} values; options: ${(w.options?.values || []).slice(0, 6).map(v => String(v).split(/[\\/]/).pop()).join(", ")}…`
: `${vals.length} values${vals.length ? ": " + vals.slice(0, 8).join(", ") + (vals.length > 8 ? "…" : "") : ""}`) : null,
]);
};
const itemsLinked = () => !!io.node.inputs?.find(x => x.name === "items")?.link;
return () => h("div", { class: "bwl" }, [
nodes.value.length ? null : h("div", { class: "warn" }, "Connect what one iteration produces to `result`; its upstream nodes appear here."),
h("div", { class: "sec" }, ["Inject the current item into", h("span", { style: "flex:1" }),
h("button", { onClick: () => st.inject.push({ node: "", slot: 0, source: "items" }) }, "+")]),
...st.inject.map(injectRow),
st.inject.length ? null : h("div", { class: "hint" }, "e.g. the Load Image that stands for “the frame”: on iteration i it gets item i."),
h("div", { class: "sec" }, ["Change per iteration", h("span", { style: "flex:1" }),
h("button", { onClick: () => st.params.push({ node: "", widget: "", text: "" }) }, "+")]),
...st.params.map(paramRow),
st.params.length ? null : h("div", { class: "hint" }, "e.g. a seed or a prompt line per iteration (cycled when shorter than the items)."),
h("div", { class: "foot" }, [itemsLinked() ? "Iterations: one per item." : "No items connected: iterations = the longest value list (or count).",
h("span", { style: "flex:1" }), h("button", { onClick: () => { tick.n++; }, title: "re-read the graph" }, "↻")]),
]);
}
app.registerExtension({
name: "BFSNodes.WorkflowLoop",
async nodeCreated(node) {
if (node.comfyClass !== "BFSWorkflowLoop") return;
styles();
const widget = node.widgets?.find(w => w.name === "loop");
if (widget) {
widget.type = "hidden"; widget.hidden = true; widget.computeSize = () => [0, -4];
widget.options = { ...(widget.options || {}), hidden: true, serialize: true };
widget.serializeValue = () => widget.value;
}
const host = document.createElement("div");
host.style.cssText = "position:relative;width:100%;height:100%;min-width:340px;min-height:360px";
node.addDOMWidget("bfs_workflow_loop", "div", host, { serialize: false, hideOnZoom: false, hideInPanel: true });
wheelToCanvas(host);
const handle = {};
createApp({ setup: () => Panel({ node, get: () => widget?.value ?? "{}",
set: v => { if (widget) widget.value = v; node.graph?.setDirtyCanvas(true); }, expose: o => Object.assign(handle, o) }) }).mount(host);
const onConn = node.onConnectionsChange;
node.onConnectionsChange = function () { const r = onConn?.apply(this, arguments); handle.refresh?.(); return r; };
const onConfigure = node.onConfigure;
node.onConfigure = function () { const r = onConfigure?.apply(this, arguments); setTimeout(() => handle.reload?.(), 0); return r; };
node.size = [Math.max(node.size[0], 380), Math.max(node.size[1], 440)];
},
});
+213
View File
@@ -0,0 +1,213 @@
/**
* BFS XY Sweep: the panel. Lists every node upstream of the `result` input and its widgets (like TinyTerra's
* advanced xyPlot); for X and Y pick a node + widget and its values. Combo widgets (LoRA names, samplers...) get a
* filterable checklist; numbers take "0.5, 0.7, 0.9" or a range "start:stop:step"; text takes one value per line.
* State lives in the hidden `sweep` STRING widget (JSON), saved with the workflow.
*/
import { app } from "../../scripts/app.js";
import { wheelToCanvas } from "./bfs_wheel.js";
import { createApp, h, reactive, computed, watch } from "./vendor/vue.esm-browser.prod.mjs";
const SKIP_TYPES = new Set(["hidden", "converted-widget", "button"]);
const SKIP_NAMES = new Set(["control_after_generate", "upload", "image_upload", "videopreview", "sweep"]);
function upstreamNodes(node) {
const graph = node.graph || app.graph;
const inp = node.inputs?.find(i => i.name === "result");
const link = inp?.link != null ? graph.links[inp.link] ?? graph.links.get?.(inp.link) : null;
if (!link) return [];
const seen = new Set(), order = [], stack = [link.origin_id];
while (stack.length) {
const id = stack.pop();
if (seen.has(id)) continue;
seen.add(id);
const n = graph.getNodeById(id);
if (!n) continue;
order.push(n);
for (const i of n.inputs || []) {
if (i.link == null) continue;
const l = graph.links[i.link] ?? graph.links.get?.(i.link);
if (l) stack.push(l.origin_id);
}
}
return order.sort((a, b) => a.id - b.id);
}
function widgetsOf(n) {
return (n.widgets || []).filter(w => w.name && !SKIP_TYPES.has(w.type) && !SKIP_NAMES.has(w.name) && !w.hidden
&& !(w.options && w.options.hidden));
}
function kindOf(w) {
if (!w) return "text";
if (w.type === "combo" || Array.isArray(w.options?.values)) return "combo";
if (w.type === "toggle" || typeof w.value === "boolean") return "bool";
if (w.type === "number" || typeof w.value === "number") return "number";
return "text";
}
function parseNumbers(text) {
const out = [];
for (const part of String(text).split(/[,\n]/).map(s => s.trim()).filter(Boolean)) {
const r = part.split(":").map(Number);
if (r.length === 3 && r.every(Number.isFinite) && r[2] !== 0) {
for (let v = r[0], i = 0; (r[2] > 0 ? v <= r[1] + 1e-9 : v >= r[1] - 1e-9) && i < 256; v += r[2], i++) out.push(+v.toFixed(6));
} else if (Number.isFinite(+part)) out.push(+part);
}
return out;
}
function parseState(text) {
let s = {};
try { s = JSON.parse(text || "{}") || {}; } catch { s = {}; }
const axis = a => ({ node: a?.node != null ? String(a.node) : "", widget: a?.widget || "", values: Array.isArray(a?.values) ? a.values : [],
text: a?.text ?? (Array.isArray(a?.values) ? a.values.join(", ") : ""), title: a?.title || "", filter: "" });
return { x: axis(s.x), y: axis(s.y) };
}
let styled = false;
function styles() {
if (styled) return;
styled = true;
const css = document.createElement("style");
css.textContent = `
.bxy{position:absolute;inset:0;display:flex;flex-direction:column;gap:8px;padding:6px;box-sizing:border-box;font:12px system-ui,sans-serif;color:#ddd;overflow:auto}
.bxy .ax{background:#1d1d1d;border:1px solid #363636;border-radius:6px;padding:6px;display:flex;flex-direction:column;gap:5px}
.bxy .hd{display:flex;align-items:center;gap:6px}
.bxy .tag{font-weight:700;background:#2d5a8a;color:#fff;border-radius:4px;padding:1px 7px}
.bxy .tag.y{background:#7a3d8a}
.bxy select,.bxy input,.bxy textarea{background:#262626;color:#ddd;border:1px solid #4a4a4a;border-radius:4px;padding:3px 5px;font:inherit;min-width:0}
.bxy select{flex:1}
.bxy textarea{resize:vertical;min-height:40px}
.bxy .opts{max-height:120px;overflow:auto;display:flex;flex-direction:column;gap:1px;border:1px solid #333;border-radius:4px;padding:3px}
.bxy .opts label{display:flex;gap:5px;align-items:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}
.bxy .opts label:hover{background:#2a2a2a}
.bxy .chips{display:flex;flex-wrap:wrap;gap:3px}
.bxy .chip{background:#2a2a2a;border:1px solid #444;border-radius:9px;padding:0 6px;font-size:11px}
.bxy .hint{color:#888;font-size:11px}
.bxy .foot{display:flex;align-items:center;gap:6px;color:#bbb}
.bxy .foot .n{font-weight:700;color:#fff}
.bxy .warn{color:#f7a}
.bxy button{background:#333;color:#ddd;border:1px solid #555;border-radius:4px;padding:2px 8px;cursor:pointer;font:inherit}
.bxy button:hover{background:#444}
`;
document.head.appendChild(css);
}
function Panel(io) {
const st = reactive(parseState(io.get()));
const tick = reactive({ n: 0 }); // bumps on graph changes so the node lists refresh
const nodes = computed(() => (tick.n, upstreamNodes(io.node)));
const nodeById = id => nodes.value.find(n => String(n.id) === String(id));
const widgetOf = a => widgetsOf(nodeById(a.node) || {}).find(w => w.name === a.widget);
const save = () => {
const out = {};
for (const k of ["x", "y"]) {
const a = st[k];
if (!a.node || !a.widget) continue;
const kind = kindOf(widgetOf(a));
let values = a.values;
if (kind === "number") values = parseNumbers(a.text);
else if (kind === "text") values = String(a.text).split("\n").map(s => s.trim()).filter(Boolean);
else if (kind === "bool") values = [false, true].filter(v => a.values.includes(v));
const n = nodeById(a.node);
out[k] = { node: a.node, widget: a.widget, values, text: a.text, title: n ? (n.title || n.type) : a.title };
}
io.set(JSON.stringify(out));
};
watch(st, save, { deep: true });
const count = computed(() => {
const len = a => {
if (!a.node || !a.widget) return 0;
const kind = kindOf(widgetOf(a));
if (kind === "number") return parseNumbers(a.text).length;
if (kind === "text") return String(a.text).split("\n").filter(s => s.trim()).length;
return a.values.length;
};
const x = len(st.x), y = len(st.y);
return x * (y || 1);
});
io.expose({ refresh: () => { tick.n++; }, reload: () => { Object.assign(st, parseState(io.get())); tick.n++; } });
const axisView = (k, label) => {
const a = st[k];
const n = nodeById(a.node);
const ws = n ? widgetsOf(n) : [];
const w = widgetOf(a);
const kind = kindOf(w);
const pickNode = e => { a.node = e.target.value; a.widget = ""; a.values = []; a.text = ""; };
const pickWidget = e => {
a.widget = e.target.value; a.values = []; const ww = widgetOf(a);
a.text = ww && kindOf(ww) !== "combo" ? String(ww.value ?? "") : "";
};
let editor = null;
if (w && kind === "combo") {
const all = (w.options?.values || []).map(String);
const shown = a.filter ? all.filter(v => v.toLowerCase().includes(a.filter.toLowerCase())) : all;
editor = [
h("input", { placeholder: "filter options…", value: a.filter, onInput: e => { a.filter = e.target.value; } }),
h("div", { class: "opts" }, shown.slice(0, 400).map(v => h("label", { title: v }, [
h("input", { type: "checkbox", checked: a.values.includes(v),
onChange: e => { a.values = e.target.checked ? [...a.values, v] : a.values.filter(x => x !== v); } }), v]))),
a.values.length ? h("div", { class: "chips" }, a.values.map(v => h("span", { class: "chip", title: v }, v.split(/[\\/]/).pop()))) : null,
];
} else if (w && kind === "bool") {
editor = h("div", { class: "chips" }, [false, true].map(v => h("label", null, [
h("input", { type: "checkbox", checked: a.values.includes(v),
onChange: e => { a.values = e.target.checked ? [...a.values, v] : a.values.filter(x => x !== v); } }), String(v)])));
} else if (w && kind === "number") {
editor = [h("input", { value: a.text, placeholder: "0.5, 0.7, 0.9 or 0.5:0.9:0.2", onInput: e => { a.text = e.target.value; } }),
h("div", { class: "hint" }, `→ ${parseNumbers(a.text).join(", ") || "no values"} (now ${w.value})`)];
} else if (w) {
editor = [h("textarea", { value: a.text, placeholder: "one value per line", onInput: e => { a.text = e.target.value; } })];
}
return h("div", { class: "ax" }, [
h("div", { class: "hd" }, [h("span", { class: ["tag", k] }, label),
h("select", { value: a.node, onChange: pickNode }, [h("option", { value: "" }, k === "y" ? "— none —" : "— pick a node —"),
...nodes.value.map(nd => h("option", { value: String(nd.id) }, `[${nd.id}] ${nd.title || nd.type}`))]),
]),
n ? h("select", { value: a.widget, onChange: pickWidget }, [h("option", { value: "" }, "— pick a widget —"),
...ws.map(ww => h("option", { value: ww.name }, `${ww.name} (${String(ww.value).split(/[\\/]/).pop().slice(0, 40)})`))]) : null,
...(Array.isArray(editor) ? editor : [editor]),
]);
};
return () => h("div", { class: "bxy" }, [
nodes.value.length ? null : h("div", { class: "warn" }, "Connect the image to compare to `result`; its upstream nodes appear here."),
axisView("x", "X"),
axisView("y", "Y"),
h("div", { class: "foot" }, [h("span", null, ["cells: ", h("span", { class: "n" }, String(count.value))]),
count.value > 64 ? h("span", { class: "warn" }, "max 64") : null, h("span", { style: "flex:1" }),
h("button", { onClick: () => { tick.n++; }, title: "re-read the graph" }, "↻")]),
h("div", { class: "hint" }, "Only the nodes that depend on the swept widgets run once per cell; models and the rest run once."),
]);
}
app.registerExtension({
name: "BFSNodes.XYSweep",
async nodeCreated(node) {
if (node.comfyClass !== "BFSXYSweep") return;
styles();
const widget = node.widgets?.find(w => w.name === "sweep");
if (widget) {
widget.type = "hidden"; widget.hidden = true; widget.computeSize = () => [0, -4];
widget.options = { ...(widget.options || {}), hidden: true, serialize: true };
widget.serializeValue = () => widget.value;
}
const host = document.createElement("div");
host.style.cssText = "position:relative;width:100%;height:100%;min-width:340px;min-height:380px";
node.addDOMWidget("bfs_xy_sweep", "div", host, { serialize: false, hideOnZoom: false, hideInPanel: true });
wheelToCanvas(host);
const handle = {};
createApp({ setup: () => Panel({ node, get: () => widget?.value ?? "{}",
set: v => { if (widget) widget.value = v; node.graph?.setDirtyCanvas(true); }, expose: o => Object.assign(handle, o) }) }).mount(host);
const onConn = node.onConnectionsChange;
node.onConnectionsChange = function () { const r = onConn?.apply(this, arguments); handle.refresh?.(); return r; };
const onConfigure = node.onConfigure;
node.onConfigure = function () { const r = onConfigure?.apply(this, arguments); setTimeout(() => handle.reload?.(), 0); return r; };
node.size = [Math.max(node.size[0], 380), Math.max(node.size[1], 520)];
},
});
+55 -65
View File
@@ -30,22 +30,21 @@ Use the language selector in the top bar to switch the interface between English
Chinese. The choice is saved in this browser and does not alter the workflow plan or prompt text. **Tour** opens an
optional step-by-step introduction when clicked; it never appears automatically.
The planner's panel has five tabs, in the order you work. The timeline stays on top of every tab.
The planner's panel has four tabs, in the order you work. The timeline stays on top of every tab.
| tab | what is there |
|---|---|
| 🎞 **Video** | pick or upload the video, audio, how it is split into shots (cuts or fixed length), generation size |
| 🎬 **Shots** | one card per shot (scroll sideways, **← →** move between shots), the preview player and the selected shot's editor: reference & prompt, who is replaced (mask, generation mode, `{target}`), continuity, **Copy to other shots** |
| 👥 **People / Filters / Masks** | the cast found by face, shot filters and the global mask settings |
| 📝 **Prompts & refs** | the global reference and prompt, the VLM and the reference descriptions (`{details}`) |
| ▶ **Run** | **Checks** (what to fix before running, with a button to each shot) and how the shots run (shot by shot / queue loop / auto loop) |
| 🎞 **Video** | pick or upload the video, the range, how it is split into shots (cuts or fixed length), generation size. The cut detector, frame grid and fps are under *More*. |
| 📝 **Pictures & prompt** | the pictures and prompt every shot uses, 🤖 Auto prompt, and (collapsed) the VLM and the picture descriptions (`{details}`) |
| 🎬 **Shots** | one card per shot (scroll sideways, **← →** move between shots), the preview player and the selected shot's editor: pictures & prompt, **What changes** (the selection, Colored Mark, how it is generated, `{target}`), continuity, **Copy to other shots**; then the mask settings shared by every shot (collapsed) |
| ▶ **Run** | **Checks** (what to fix before running, with a button to each shot), how the shots run (shot by shot / queue loop / auto loop) and the stored shots |
Shot cards show badges for what each shot has: 🎯 mask, 🎭 / ✂ generation mode, 🧑 `{target}`, ⛓ continuity and ⚠ problems.
The header's ⚠ count opens the checks.
**Copy to other shots** (in the shot editor) copies the chosen parts of the selected shot to all shots, or to the
shots after it: references, prompt, mask text, mask points (each shot gets them on its frame at the same relative
position), generation mode, `{target}`, continuity. It replaces the old "→ all" buttons.
position), generation mode, `{target}`, continuity.
## How the loop works
@@ -197,44 +196,14 @@ that will not run. The panel shows the shot's start, end and current time (and f
playhead follows on the timeline, and clicking the timeline seeks. The source video streams from
the input folder, so only what you play is loaded.
## Shot filters (People / Filters / Masks tab)
## Turning shots off
Skip shots that should not run, decided per shot from a few sampled frames:
**⏸ Disable** in a shot's editor turns a shot off. Shots that are off do not run. Connect the planner's `timeline`
output to BFS Shot Join and they are filled with the original video, keeping the full duration and soundtrack, or
removed (with the matching audio) when *Skipped shots in the output* is set to remove them.
| filter | skips a shot when |
|---|---|
| Needs a person | no person is detected |
| Min person size | the largest person covers less than that fraction of the frame (wide shots) |
| Max people | more people than that (crowds) |
| Needs a face | no face is detected |
| Skip dark / fades | the mean brightness is below the level |
| Skip static | there is almost no motion (title cards, freeze frames) |
| Min frames | the shot is shorter than that |
People and faces use YOLO from `models/ultralytics` (`person_yolov8m-seg.pt`, `face_yolov8m.pt`, the
Impact Pack files) with OpenCV fallbacks. *Analyse people & faces* shows the numbers on every shot
card. Each shot can override the filters (always run / always skip).
Skipped shots do not run. Connect the planner's `timeline` output to BFS Shot Join and they are
filled with the original video, keeping the full duration and soundtrack, or removed (with the
matching audio) when *Skipped shots in the output* is set to remove them.
## Cast (people by face)
*Find people* samples the video every 0.5 s, detects faces and groups them into people by face
identity, on the CPU (it never touches the GPU; the result is cached per video). **No extra package is needed**: by
default it uses OpenCV Zoo's **YuNet** (detection) + **SFace** (recognition), Apache-2.0 models run by OpenCV itself and
downloaded once into `models/opencv_face` (0.2 + 37 MB). If **InsightFace** is installed (`buffalo_l` in
`models/insightface`) it is used instead (a bit more accurate; mind its license). Last resort, without either: SAM 3.1
faces grouped by colours (hair, skin, clothes). Each
person card shows the face, screen share and first/last appearance (click the face to seek). The *ignore* checkbox excludes a detected identity from cast-based filters. Every shot card shows the people detected in it, with the main person highlighted.
| option | effect |
|---|---|
| Split where the main person changes | adds a boundary where the largest face switches to another person for at least a second, then re-splits |
| Only run shots with a detected person | shots with no detected, non-ignored person are skipped |
Face identities are used for filtering and optional shot splitting. References and marker colors are selected globally or per shot; they are not assigned to detected identities.
The shot filters (person / face / dark / static detection) and the face cast were removed in 1.122: a plan that
skipped a shot by hand opens with that shot disabled, and the old filter and cast settings are ignored.
## References and prompts
@@ -261,10 +230,18 @@ node stops at 9 and LoRAs trained like OpenJutsu saw up to 9, so the log notes a
## Mask & crop (SAM 3)
Generate only part of the frame. In a shot's editor (*Select region*), type what to segment in English
**MolmoPoint (optional).** Connect *Auto Download & Load MolmoPoint-8B* (ComfyUI-Molmo2-H3) to the planner's
`point_model` input. While it is connected, a selection written as text (`the man on the left`, `the second car`,
`the woman holding the cup`) is pointed by MolmoPoint on the shot (the object it follows longest, on its frame
nearest the middle of the shot) and SAM 3 tracks from that point, instead of reading the text itself. SAM 3's own
text grounding finds every match of a noun; MolmoPoint understands which one. Points you click always win, and when
MolmoPoint finds nothing the text goes to SAM 3 as before. The panel's previews use it after the workflow has run
once with it connected (ComfyUI only hands models to nodes when they run).
Generate only part of the frame. In a shot's editor (**What changes**), type what to select in English
(`person in white`, `woman with red hair`, `red car`; up to 32 tokens, commas for several things) or click
**🎯 Points** to pick positive / negative points on any frame of the shot, then **👁 Preview** (red = mask,
yellow = crop box). Then pick how the shot is generated:
yellow = crop box). Then pick how the shot is generated in **Generate**:
| mode | what is generated | crop / uncrop |
|---|---|---|
@@ -282,20 +259,33 @@ result back, feathered by the mask (or the whole box). **BFS Shot Unpack** also
e.g. for an inpainting model. In **BFS Shot Join**'s comparison video, cropped shots show their mask (red) and crop
box (yellow) over the original column (`comparison_mask`, on by default).
**Mask opacity** (mask modes, per shot, slider under the modes): the value of the white inside the generation mask.
**Mask strength** (mask modes, per shot, slider under **Generate**): the value of the white inside the generation mask.
1 regenerates the masked area completely. A grey mask (e.g. 0.85) keeps part of the original there: H3 puts those
rows at *opacity* × the noise level, so they start from the original partly visible. 0.8-0.9 keeps pose, outline and lighting while still swapping; too low copies the original
person. With `guide_mode = aligned guide` the model also sees the whole original shot as its guide, so it follows
the motion either way.
**🎨 Colored Mark** is an optional translucent overlay on the source video: each marked person is tinted with the
color of the picture they become, and the prompt gets the mapping ("the person marked in red becomes the character
from `<Picture 3>`"), for any model or LoRA. Every picture has its **Mark color** (under it in **Prompts & refs**:
**🎨 Colored Mark** is an optional translucent overlay on the source video: each selected subject (a person, an object,
a vehicle) is tinted with the color of the picture it becomes, and the prompt gets the mapping ("the person marked in red
becomes the character from `<Picture 3>`"), for any model or LoRA. Every picture has its **Mark color** (under it in **Pictures & prompt**:
green, purple, red, blue, yellow, magenta, cyan, orange, white, brown, pink, olive by default); change it when it is
too close to a color in the video. In a shot, turn on Colored Mark and pick which picture the shot's selection (its
SAM 3 text or points) becomes; **+ Mark another person** adds a person with their own picture and text or 🎯 points
(the points window has one point set per person). This is only a visual marker: it does not create an inpainting mask
or change **Full frame**, **Mask only** or crop behavior. More than one marked person needs Crop off, and the
too close to a color in the video. In a shot, turn on Colored Mark: every row under it is a subject, with its text or
🎯 points and **→ Picture N**, the picture it becomes. *Subject 1* is the shot's own selection; **+ Add subject** adds
another row (the points window has one point set per subject).
The style next to the checkbox sets how the marks are drawn (the same styles as **BFS Video Segment Picker**):
| style | what the guide shows | opacity |
|---|---|---|
| **translucent** (default) | the subject tinted with its color | 0.35 |
| **hatching + contour** | colored diagonal lines and a matching inner contour; the original texture and light stay visible between the lines | 1 |
| **normal map** | the subject's surface-normal map in gray detail, tinted with its color (no original texture) | 1 |
*Normal map* needs MoGe normals of the same frames: run MoGe on **BFS Shot Unpack**'s frames (Run MoGe Inference →
Render MoGe Geometry, `normal_directx`) and connect it to **BFS Shot H3 Conditioning → normals**. The panel's preview
draws the hatching; for the normal map it shows a tint. The prompt says how each subject is marked ("the subject
marked with green hatching lines and a green contour uses the character appearance from `<Picture 1>`"). This is only a visual marker: it does not create an inpainting mask
or change **Full frame**, **Mask only** or crop behavior. More than one marked subject needs Crop off, and the
shot must have every picture the marks use. The overlay keeps movement and facial
expression visible. Plans before 1.113 (a second region for ref 2) open with that region as `<Picture 2>`.
@@ -308,7 +298,7 @@ detailed_description: [Shot 1] Replace the person marked by the translucent gree
```
The VLM's shot analysis suggests a SAM 3 text selection from the original frames; it does not assign guide colors or
decide that two detections are the same identity. **Describe target** only describes the first shot selection. For
decide that two detections are the same identity. **🧑 Describe** only describes the first shot selection. For
two-color swaps, choose both selections and identities yourself, and use the explicit mapping above (or let
🤖 Auto prompt write it: it is told which tinted person becomes which picture).
@@ -330,7 +320,7 @@ Crop + mask), `{target}`, the setting picture and the comparison overlay. Three
| shot editor → **🎞 mask video** | only that shot (its first frame = the shot's first frame) | everything |
| the shot's own SAM 3 text / points | that shot | the two below |
| the planner's optional **`mask`** input (MASK or IMAGE batch, e.g. Load Video + Convert Image to Mask) | the whole video, one mask per source frame | the panel's mask video |
| People / Filters / Masks → **🎞 Mask video (whole video)** | the whole video, matched by time | SAM 3 for shots without a mask |
| Shots → **Mask settings** → **🎞 Mask video (whole video)** | the whole video, matched by time | SAM 3 for shots without a mask |
Any size and frame rate: it is matched to the shot by time and resized. The console says which mask each shot used.
@@ -355,9 +345,9 @@ Credit to **Neko (Nekodificador)** and **AbleJones**, whose workflow and nodes t
ComfyUI builds without native H3 generation masks need a per-row mask patch on the model
(e.g. ComfyUI-MiniMaxH3-PerRowMasking).
The **People / Filters / Masks** tab holds the global mask settings, with defaults that work as they are: fill holes on,
**Mask settings (every shot)**, at the bottom of the Shots tab, holds the global mask settings, with defaults that work as they are: fill holes on,
temporal expand 2 frames (less flicker), expand 16 px, feather 12 px, padding 15 %, paste the whole box with feathered edges, blockify off
(16 aligns the mask to H3's latent grid), threshold 0.5, 4 objects. *Show masks on shots* overlays the preview
(16 aligns the mask to H3's latent grid), threshold 0.5, 4 objects. *show mask previews* (top of the Shots tab) overlays the preview
on the shot cards. SAM 3 uses the official `sam3.1_multiplex_fp16.safetensors` in `models/checkpoints`,
downloaded from Comfy-Org/sam3.1 the first time.
@@ -365,15 +355,15 @@ downloaded from Comfy-Org/sam3.1 the first time.
Connect a vision-language model to the planner's `vlm` input (CLIPLoader with `qwen3vl_4b` or `qwen3vl_8b`).
It looks at a few frames of every shot and suggests what to segment, a description of the shot (camera,
framing, action) and whether to run it. In the **Prompts & refs** tab: *Use the VLM when the workflow runs* applies the
suggestions at run time (mask text for shots without one; `{shot}` in a prompt becomes that shot's
framing, action). In the **Pictures & prompt** tab (*VLM*): *Use the VLM when the workflow runs* applies the
suggestions at run time (selection text for shots without one; `{shot}` in a prompt becomes that shot's
description), *Analyse shots* asks from the panel (after one run with the VLM connected), and each shot's
editor shows its suggestion with buttons to apply it. The summary output lists them too.
## Describe references ({details})
The VLM can describe the references, for any task (not tied to swaps), and any subject: a person, a creature, a robot,
an animal or a cartoon character (it says what the subject is first). In the **Prompts & refs** tab pick an instruction
an animal or a cartoon character (it says what the subject is first). In the **Pictures & prompt** tab (*Describe references*) pick an instruction
preset: *object* (a noun phrase for a prop, product, vehicle...: what it is, shape, materials, colors, text), *short* (the default: one noun phrase of at most ~20 words that fits inside a sentence, e.g. `green goblin with
long pointed ears in a torn brown leather vest`, for `<Subject 1> is the {details} shown in <Picture 1>`), *full body* (face, hair, skin, age, build, clothing piece by piece), *head / face*, *face attributes* (a
short comma-separated list), *outfit*, or *custom* (your own instruction). **📝 Describe refs** writes one
@@ -417,7 +407,7 @@ paste/crop-only composition and inpainting outside a subject mask, set **first_f
**setting_ref** off (that option adds the original source location). The full aligned guide still contains source
scene geometry, so a correct prompt alone does not guarantee complete background replacement by a checkpoint.
In **Prompts & refs → 🤖 Auto prompt**, *Write the prompts with the VLM* turns the prompt boxes (global and per shot)
In **Pictures & prompt → 🤖 Auto prompt**, *Write the prompts with the VLM* turns the prompt boxes (global and per shot)
into **requests** ("replace the bottle with the red can from Picture 2", "Picture 1 dances in the rain"), and the VLM
writes each shot's MiniMax H3 prompt in the official full-reference format (MiniMaxAI/MiniMax-H3
`docs/VIDEO_PROMPT_WRITING_GUIDE_ref_en.md`): `subject_definitions`, `summary` with its task type, `retention_analysis`,
@@ -699,12 +689,12 @@ Then save:
**🎯 Track → whole video** (in the points window) is for a person who moves around: the selection is tracked by SAM 3
through the whole video (or the range), forward and backward from the frame you clicked on, and every shot without
its own selection takes its part of that track. Also type a short text (e.g. `woman in pink top`): after a camera cut
the tracker uses it to find the subject again. The People / Filters / Masks tab shows the track and removes it. The first run
the tracker uses it to find the subject again. **Mask settings** (Shots tab) shows the track and removes it. The first run
tracks the whole range (a minute or two per few hundred frames); the result is cached until the selection changes.
### 3. Describe the person
Click **🧑 Describe target** in the shot's editor:
Click **🧑 Describe** next to `{target}` in the shot's editor:
- SAM 3 cuts the person out of the selected frame, and a thumbnail of the cut-out appears next to the field.
- The VLM writes a short phrase into the **`{target}`** field.
- The field stays editable: correct it or write your own. Keep it short and visual (who, hair, main clothes with
@@ -721,7 +711,7 @@ Click **🧑 Describe target** in the shot's editor:
### What happens when the workflow runs
Every shot's prompt gets its own `{target}` text: the one you wrote or made with 🧑 Describe target; when the field is
Every shot's prompt gets its own `{target}` text: the one you wrote or made with 🧑 Describe; when the field is
empty but the shot has a selection (mask text, points or a mask video) and the VLM is connected, the subject is cut out
and described automatically (written for the prompt's `{target}` sentence). A shot with neither gets "the person". The selection is
only used to make the description: it does not crop or mask anything unless the shot's mode is *Mask only*, *Crop*
@@ -730,7 +720,7 @@ or *Crop + mask*.
### Examples
- **Two people, swap only one:** in the first shot, click on the woman's torso, put an exclude point on the man,
then **Save → all shots** and **🧑 Describe target** → "the young woman with long black hair in a white blouse".
then **Save → all shots** and **🧑 Describe** → "the young woman with long black hair in a white blouse".
**Copy to other shots** with *{target}* ticked.
- **The person changes between shots:** describe shot by shot, e.g. shot 1 "the man in the grey suit", shot 2
"the woman in the red dress". Each shot keeps its own text.
+263 -227
View File
@@ -13,178 +13,205 @@ export function setUiLanguage(value) {
}
const TEXT = {
"🎞 Video": ["🎞 Vídeo", "🎞 Vídeo", "🎞 视频"], "🎬 Shots": ["🎬 Shots", "🎬 Planos", "🎬 镜头"],
"👥 People & masks": ["👥 Pessoas e máscaras", "👥 Personas y máscaras", "👥 人物和蒙版"],
"📝 Prompts & refs": ["📝 Prompts e referências", "📝 Prompts y referencias", "📝 提示词和参考图"],
"▶ Run": ["▶ Executar", "▶ Ejecutar", "▶ 运行"], "📖 Guide": ["📖 Guia", "📖 Guía", "📖 指南"],
"🧭 Tour": ["🧭 Tour", "🧭 Recorrido", "🧭 引导"], "Language": ["Idioma", "Idioma", "语言"],
"Source video": ["Vídeo de origem", "Vídeo de origen", "源视频"], "Range": ["Trecho", "Rango", "范围"],
"Timeline": ["Linha do tempo", "Línea de tiempo", "时间轴"], "zoom": ["zoom", "zoom", "缩放"],
"Click a shot to select it (← → move between shots) · drag the white handles to move a boundary · double-click the shot bar to split · Delete removes the selected shot's cut": [
"Clique em um plano para selecioná-lo (← → navega entre planos) · arraste as alças brancas para mover uma divisão · dê dois cliques na barra para dividir · Delete remove o corte do plano selecionado",
"Haz clic en un plano para seleccionarlo (← → cambia de plano) · arrastra los controles blancos para mover un límite · haz doble clic en la barra para dividir · Delete elimina el corte del plano seleccionado",
"点击镜头以选中(← → 切换镜头)· 拖动白色控点移动分界 · 双击镜头条进行拆分 · 按 Delete 删除所选镜头的切点",
],
"🎞 Video": ["🎞 Vídeo", "🎞 Vídeo", "🎞 视频"],
"🎬 Shots": ["🎬 Shots", "🎬 Planos", "🎬 镜头"],
"▶ Run": ["▶ Executar", "▶ Ejecutar", "▶ 运行"],
"📖 Guide": ["📖 Guia", "📖 Guía", "📖 指南"],
"🧭 Tour": ["🧭 Tour", "🧭 Recorrido", "🧭 引导"],
"Language": ["Idioma", "Idioma", "语言"],
"Source video": ["Vídeo de origem", "Vídeo de origen", "源视频"],
"Range": ["Trecho", "Rango", "范围"],
"Timeline": ["Linha do tempo", "Línea de tiempo", "时间轴"],
"zoom": ["zoom", "zoom", "缩放"],
"Click a shot to select it (← → move between shots) · drag the white handles to move a boundary · double-click the shot bar to split · Delete removes the selected shot's cut": ["Clique em um plano para selecioná-lo (← → navega entre planos) · arraste as alças brancas para mover uma divisão · dê dois cliques na barra para dividir · Delete remove o corte do plano selecionado", "Haz clic en un plano para seleccionarlo (← → cambia de plano) · arrastra los controles blancos para mover un límite · haz doble clic en la barra para dividir · Delete elimina el corte del plano seleccionado", "点击镜头以选中(← → 切换镜头)· 拖动白色控点移动分界 · 双击镜头条进行拆分 · 按 Delete 删除所选镜头的切点"],
"camera cuts or fixed length, on the model's frame grid": ["cortes de câmera ou duração fixa, na grade de frames do modelo", "cortes de cámara o duración fija, según la cuadrícula de fotogramas del modelo", "按镜头切换或固定时长,并符合模型帧步长"],
"Range length, seconds (0 = to the end)": ["Duração do trecho, em segundos (0 = até o fim)", "Duración del tramo, en segundos (0 = hasta el final)", "片段时长(秒,0 = 到结尾)"],
"refresh the input folder": ["atualizar a pasta de entrada", "actualizar la carpeta de entrada", "刷新输入文件夹"],
"Pick or upload a video to start": ["Escolha ou envie um vídeo para começar", "Elige o sube un vídeo para empezar", "选择或上传视频以开始"],
"use the video’s audio": ["usar o áudio do vídeo", "usar el audio del vídeo", "使用视频音频"],
"The outputs carry a silent track of the right length, so Create Video works": ["As saídas incluem uma faixa silenciosa com a duração correta para funcionar em Create Video", "Las salidas incluyen una pista silenciosa con la duración correcta para que funcione Create Video", "输出会带有时长正确的静音音轨,以便 Create Video 正常工作"],
"Audio of the planner's and the join's outputs": ["Áudio das saídas do Planner e do Join", "Audio de las salidas del Planner y del Join", "Planner 和 Join 输出的音频"],
"the range ends after the player's frame": ["o trecho termina depois do frame atual do player", "el tramo termina después del fotograma actual del reproductor", "片段在播放器当前帧之后结束"],
"the range ends after the player's frame": ["o trecho termina após o frame atual do player", "el rango termina después del fotograma actual", "范围在播放器当前帧之后结束"],
"Play or scrub the video (or click the timeline), then ⇤ / ⇥. {…} frames ({…} s) of {…}: only this part is split, analysed, generated and joined.": ["Reproduza ou avance o vídeo (ou clique na linha do tempo) e use ⇤ / ⇥. {…} frames ({…} s) de {…}: somente este trecho será dividido, analisado, gerado e unido.", "Reproduce o desplaza el vídeo (o haz clic en la línea de tiempo) y usa ⇤ / ⇥. {…} fotogramas ({…} s) de {…}: solo se dividirá, analizará, generará y unirá este tramo.", "播放或拖动视频(也可点击时间轴),然后按 ⇤ / ⇥。共 {…}/{…} 帧({…} 秒):仅拆分、分析、生成并合并此片段。"],
"15 s in one shot": ["15 s em um único plano", "15 s en un solo plano", "单镜头 15 秒"],
"from the start frame · {…} frames": ["a partir do frame inicial · {…} frames", "desde el fotograma inicial · {…} fotogramas", "从起始帧开始 · {…} 帧"],
"limit how many detected shots are used": ["limita quantos planos detectados serão usados", "limita cuántos planos detectados se usan", "限制使用的检测镜头数量"],
"Auto split replaces the boundaries; per-shot settings stay with the shot at the same position.": ["A divisão automática substitui os limites; as configurações por plano permanecem na mesma posição.", "La división automática reemplaza los límites; los ajustes por plano se mantienen en la misma posición.", "自动拆分会替换分界;逐镜头设置保留在相同位置。"],
"before the start: not planned": ["antes do início: fora do plano", "antes del inicio: no incluido", "起点之前:不处理"],
"after the end: not planned": ["depois do fim: fora do plano", "después del final: no incluido", "终点之后:不处理"],
"Split into shots": ["Dividir em planos", "Dividir en planos", "拆分镜头"], "Generation size": ["Tamanho da geração", "Tamaño de generación", "生成尺寸"],
"Camera cuts": ["Cortes de câmera", "Cortes de cámara", "镜头切换"], "Fixed length": ["Duração fixa", "Duración fija", "固定时长"],
"Split into shots": ["Dividir em planos", "Dividir en planos", "拆分镜头"],
"Generation size": ["Tamanho da geração", "Tamaño de generación", "生成尺寸"],
"Camera cuts": ["Cortes de câmera", "Cortes de cámara", "镜头切换"],
"Fixed length": ["Duração fixa", "Duración fija", "固定时长"],
"One shot (whole range)": ["Um plano (trecho inteiro)", "Un plano (rango completo)", "单个镜头(整个范围)"],
"Mode": ["Modo", "Modo", "模式"], "Detector": ["Detector", "Detector", "检测器"], "Sensitivity": ["Sensibilidade", "Sensibilidad", "灵敏度"],
"Frame grid": ["Grade de frames", "Múltiplo de fotogramas", "帧步长"], "Max seconds / shot": ["Máx. segundos por plano", "Máx. segundos por plano", "每个镜头最长秒数"],
"Min seconds / shot": ["Mín. segundos por plano", "Mín. segundos por plano", "每个镜头最短秒数"], "Timeline fps": ["FPS da linha do tempo", "FPS de la línea de tiempo", "时间轴帧率"],
"Megapixels": ["Megapixels", "Megapíxeles", "百万像素"], "Size multiple": ["Múltiplo do tamanho", "Múltiplo del tamaño", "尺寸倍数"],
"Size mode": ["Modo de tamanho", "Modo de tamaño", "尺寸模式"], "Megapixel target": ["Alvo em megapixels", "Objetivo en megapíxeles", "百万像素目标"],
"Original video": ["Vídeo original", "Vídeo original", "原视频"], "Custom dimensions": ["Dimensões personalizadas", "Dimensiones personalizadas", "自定义尺寸"],
"Mode": ["Modo", "Modo", "模式"],
"Detector": ["Detector", "Detector", "检测器"],
"Sensitivity": ["Sensibilidade", "Sensibilidad", "灵敏度"],
"Frame grid": ["Grade de frames", "Múltiplo de fotogramas", "帧步长"],
"Max seconds / shot": ["Máx. segundos por plano", "Máx. segundos por plano", "每个镜头最长秒数"],
"Min seconds / shot": ["Mín. segundos por plano", "Mín. segundos por plano", "每个镜头最短秒数"],
"Timeline fps": ["FPS da linha do tempo", "FPS de la línea de tiempo", "时间轴帧率"],
"Megapixels": ["Megapixels", "Megapíxeles", "百万像素"],
"Size multiple": ["Múltiplo do tamanho", "Múltiplo del tamaño", "尺寸倍数"],
"Size mode": ["Modo de tamanho", "Modo de tamaño", "尺寸模式"],
"Megapixel target": ["Alvo em megapixels", "Objetivo en megapíxeles", "百万像素目标"],
"Original video": ["Vídeo original", "Vídeo original", "原视频"],
"Custom dimensions": ["Dimensões personalizadas", "Dimensiones personalizadas", "自定义尺寸"],
"Output size": ["Tamanho de saída", "Tamaño de salida", "输出尺寸"],
"Custom width": ["Largura personalizada", "Ancho personalizado", "自定义宽度"], "Custom height": ["Altura personalizada", "Alto personalizado", "自定义高度"],
"Custom width": ["Largura personalizada", "Ancho personalizado", "自定义宽度"],
"Custom height": ["Altura personalizada", "Alto personalizado", "自定义高度"],
"Image max side (VLM)": ["Lado máximo da imagem (VLM)", "Lado máximo de imagen (VLM)", "VLM 图像最长边"],
"VLM image sizing": ["Redimensiona as imagens enviadas ao VLM, mantendo a proporção. O processador do modelo ainda pode redimensioná-las.", "Ajusta las imágenes enviadas al VLM manteniendo la proporción. El procesador del modelo puede volver a ajustarlas.", "调整发送给 VLM 的图像并保持比例。模型处理器仍可能再次缩放。"],
"Original resolution is rounded to the size multiple for model compatibility.": ["A resolução original é arredondada ao múltiplo definido para compatibilidade com o modelo.", "La resolución original se redondea al múltiplo indicado para compatibilidad con el modelo.", "原始分辨率会取整到尺寸倍数,以兼容模型。"],
"✂ Auto split": ["✂ Dividir automaticamente", "✂ División automática", "✂ 自动拆分"], "↻ Re-analyse": ["↻ Analisar novamente", "↻ Volver a analizar", "↻ 重新分析"],
"Reference & prompt": ["Referência e prompt", "Referencia y prompt", "参考图和提示词"], "Who is replaced": ["Quem será substituído", "Quién se reemplaza", "替换对象"],
"Continuity": ["Continuidade", "Continuidad", "连续性"], "Copy to other shots": ["Copiar para outros planos", "Copiar a otros planos", "复制到其他镜头"],
"Global reference & prompt": ["Referência e prompt globais", "Referencia y prompt globales", "全局参考图和提示词"],
"✂ Auto split": ["✂ Dividir automaticamente", "✂ División automática", "✂ 自动拆分"],
"↻ Re-analyse": ["↻ Analisar novamente", "↻ Volver a analizar", "↻ 重新分析"],
"Continuity": ["Continuidade", "Continuidad", "连续性"],
"Copy to other shots": ["Copiar para outros planos", "Copiar a otros planos", "复制到其他镜头"],
"Describe references ({details})": ["Descrever referências ({details})", "Describir referencias ({details})", "描述参考图({details})"],
"Ref 1 description ({details})": ["Descrição da ref. 1 ({details})", "Descripción de ref. 1 ({details})", "参考图 1 描述({details})"],
"Ref 2 description ({details2})": ["Descrição da ref. 2 ({details2})", "Descripción de ref. 2 ({details2})", "参考图 2 描述({details2})"],
"no description yet: Describe refs, write your own, or leave it to the VLM at run time": ["sem descrição: use Descrever referências, escreva uma descrição ou deixe o VLM gerar durante a execução", "sin descripción: pulsa Describir referencias, escribe una o deja que el VLM la genere al ejecutar", "暂无描述:点击“描述参考图”、自行填写,或在运行时由 VLM 生成"],
"VLM": ["VLM", "VLM", "视觉语言模型"], "Use the VLM when the workflow runs": ["Usar o VLM ao executar o workflow", "Usar el VLM al ejecutar el flujo", "运行工作流时使用 VLM"],
"Fill the mask text": ["Preencher o texto da máscara", "Completar el texto de la máscara", "填写蒙版描述"],
"VLM": ["VLM", "VLM", "视觉语言模型"],
"Use the VLM when the workflow runs": ["Usar o VLM ao executar o workflow", "Usar el VLM al ejecutar el flujo", "运行工作流时使用 VLM"],
"Fill {shot} in the prompt": ["Preencher {shot} no prompt", "Completar {shot} en el prompt", "在提示词中填入 {shot}"],
"Frames per shot": ["Frames por plano", "Fotogramas por plano", "每个镜头的帧数"], "Max tokens": ["Máx. tokens", "Máx. tokens", "最大 token 数"],
"🤖 Analyse shots": ["🤖 Analisar planos", "🤖 Analizar planos", "🤖 分析镜头"], "📝 Describe refs": ["📝 Descrever referências", "📝 Describir referencias", "📝 描述参考图"],
"🎨 Colored Mark": ["🎨 Marcação colorida", "🎨 Marca de color", "🎨 彩色标记"], "Colored Mark": ["Marcação colorida", "Marca de color", "彩色标记"], "Ref 1 color": ["Cor da ref. 1", "Color de ref. 1", "参考图 1 的颜色"],
"Ref 2": ["Ref. 2", "Ref. 2", "参考图 2"], "+ Add a second person (optional)": ["+ Adicionar segunda pessoa (opcional)", "+ Añadir segunda persona (opcional)", "+ 添加第二个人(可选)"],
"Second person · Ref 2": ["Segunda pessoa · Ref. 2", "Segunda persona · Ref. 2", "第二个人 · 参考图 2"],
"Describe the second person for SAM 3": ["Descreva a segunda pessoa para o SAM 3", "Describe la segunda persona para SAM 3", "为 SAM 3 描述第二个人"],
"👁 Preview": ["👁 Prévia", "👁 Vista previa", "👁 预览"], "More options": ["Mais opções", "Más opciones", "更多选项"],
"Keep ref 2 color consistent across shots (optional)": ["Manter a cor da ref. 2 nos outros planos (opcional)", "Mantener el color de ref. 2 en otros planos (opcional)", "在其他镜头中保持参考图 2 的颜色(可选)"],
"Ref 1 identity": ["Identidade da ref. 1", "Identidad de ref. 1", "参考图 1 的身份"], "Ref 2 identity": ["Identidade da ref. 2", "Identidad de ref. 2", "参考图 2 的身份"],
"Choose manually": ["Escolher manualmente", "Elegir manualmente", "手动选择"], "↩ cast color": ["↩ cor do elenco", "↩ color del reparto", "↩ 使用角色颜色"],
"Opacity": ["Opacidade", "Opacidad", "不透明度"], "The default keeps movement and expression visible.": ["O padrão mantém o movimento e a expressão visíveis.", "El valor predeterminado mantiene visibles el movimiento y la expresión.", "默认值会保留动作和表情。"],
"Full frame": ["Quadro inteiro", "Fotograma completo", "整帧"], "🧩 Frame + paste": ["🧩 Quadro + colar", "🧩 Fotograma + pegar", "🧩 整帧生成并贴回"],
"🎭 Mask only": ["🎭 Somente máscara", "🎭 Solo máscara", "🎭 仅蒙版"], "✂ Crop": ["✂ Recorte", "✂ Recorte", "✂ 裁剪"], "✂🎭 Crop + mask": ["✂🎭 Recorte + máscara", "✂🎭 Recorte + máscara", "✂🎭 裁剪并蒙版"],
"People & masks": ["Pessoas e máscaras", "Personas y máscaras", "人物和蒙版"], "Cast": ["Elenco", "Reparto", "角色"],
"Mask & crop settings": ["Configurações de máscara e recorte", "Ajustes de máscara y recorte", "蒙版和裁剪设置"],
"Green": ["Verde", "Verde", "绿色"], "Purple": ["Roxo", "Morado", "紫色"],
"Red": ["Vermelho", "Rojo", "红色"], "Blue": ["Azul", "Azul", "蓝色"], "Yellow": ["Amarelo", "Amarillo", "黄色"], "Magenta": ["Magenta", "Magenta", "品红"], "Cyan": ["Ciano", "Cian", "青色"],
"Checks": ["Verificações", "Comprobaciones", "检查"], "Run": ["Executar", "Ejecutar", "运行"], "Filters": ["Filtros", "Filtros", "筛选"],
"Auto loop (one run)": ["Loop automático (uma execução)", "Bucle automático (una ejecución)", "自动循环(一次运行)"],
"Frames per shot": ["Frames por plano", "Fotogramas por plano", "每个镜头的帧数"],
"Max tokens": ["Máx. tokens", "Máx. tokens", "最大 token 数"],
"🤖 Analyse shots": ["🤖 Analisar planos", "🤖 Analizar planos", "🤖 分析镜头"],
"📝 Describe refs": ["📝 Descrever referências", "📝 Describir referencias", "📝 描述参考图"],
"🎨 Colored Mark": ["🎨 Marcação colorida", "🎨 Marca de color", "🎨 彩色标记"],
"Colored Mark": ["Marca colorida", "Marca de color", "彩色标记"],
"👁 Preview": ["👁 Prévia", "👁 Vista previa", "👁 预览"],
"Full frame": ["Quadro inteiro", "Fotograma entero", "整个画面"],
"Red": ["Vermelho", "Rojo", "红色"],
"Checks": ["Verificações", "Comprobaciones", "检查"],
"Run": ["Executar", "Ejecutar", "运行"],
"Queue loop (one shot per run)": ["Fila (um plano por execução)", "Cola (un plano por ejecución)", "队列循环(每次运行一个镜头)"],
"Skipped shots in the output": ["Planos ignorados na saída", "Planos omitidos en la salida", "输出中跳过的镜头"], "Keep original video": ["Manter vídeo original", "Mantener vídeo original", "保留原视频"],
"Remove them": ["Removê-los", "Quitarlos", "移除"], "Max shots (0 = all)": ["Máx. planos (0 = todos)", "Máx. planos (0 = todos)", "镜头上限(0 = 全部)"],
"Max total seconds (0 = all)": ["Máx. duração total (0 = tudo)", "Duración total máx. (0 = todo)", "总时长上限(0 = 全部)"],
"Skipped shots in the output": ["Planos ignorados na saída", "Planos omitidos en la salida", "输出中跳过的镜头"],
"Keep original video": ["Manter o vídeo original", "Conservar el vídeo original", "保留原视频"],
"Remove them": ["Removê-los", "Quitarlos", "移除"],
"Max shots (0 = all)": ["Máx. planos (0 = todos)", "Máx. planos (0 = todos)", "镜头上限(0 = 全部)"],
"Auto-queue the next shot": ["Enfileirar próximo plano automaticamente", "Añadir automáticamente el siguiente plano a la cola", "自动将下一个镜头加入队列"],
"Needs a person": ["Precisa ter uma pessoa", "Debe haber una persona", "需要有人物"], "Needs a face": ["Precisa ter um rosto", "Debe haber un rostro", "需要有人脸"],
"Skip dark / fades": ["Ignorar cenas escuras / fades", "Omitir escenas oscuras / fundidos", "跳过暗场/淡入淡出"], "Skip static shots": ["Ignorar planos estáticos", "Omitir planos estáticos", "跳过静止镜头"],
"👤 Analyse people & faces": ["👤 Analisar pessoas e rostos", "👤 Analizar personas y rostros", "👤 分析人物和人脸"],
"Video": ["Vídeo", "Vídeo", "视频"], "Shots": ["Planos", "Planos", "镜头"], "People": ["Pessoas", "Personas", "人物"], "Prompts": ["Prompts", "Prompts", "提示词"],
"Back": ["Voltar", "Atrás", "上一步"], "Next": ["Avançar", "Siguiente", "下一步"], "Close tour": ["Fechar tour", "Cerrar recorrido", "关闭引导"],
"Shot Planner tour": ["Tour do Shot Planner", "Recorrido de Shot Planner", "Shot Planner 引导"], "Shot": ["Plano", "Plano", "镜头"],
"Video": ["Vídeo", "Vídeo", "视频"],
"Shots": ["Planos", "Planos", "镜头"],
"Prompts": ["Prompts", "Prompts", "提示词"],
"Back": ["Voltar", "Atrás", "上一步"],
"Next": ["Avançar", "Siguiente", "下一步"],
"Close tour": ["Fechar tour", "Cerrar recorrido", "关闭引导"],
"Shot Planner tour": ["Tour do Shot Planner", "Recorrido de Shot Planner", "Shot Planner 引导"],
"Shot": ["Plano", "Plano", "镜头"],
"This tour is optional. Use Next to visit each main tab; close it whenever you like.": ["Este tour é opcional. Use Avançar para conhecer cada aba; feche quando quiser.", "Este recorrido es opcional. Pulsa Siguiente para visitar cada pestaña; puedes cerrarlo cuando quieras.", "此引导为可选。点击下一步浏览各个主选项卡,也可随时关闭。"],
"Choose a video, set the frame range, split at camera cuts, then set the generation size.": ["Escolha um vídeo e o trecho, divida nos cortes de câmera e defina o tamanho da geração.", "Elige un vídeo y un rango, divídelo en cortes de cámara y define el tamaño de generación.", "选择视频和范围,按镜头切换拆分,然后设置生成尺寸。"],
"Review each shot, assign its references and prompt, and choose how the result is applied.": ["Revise cada plano, associe referências e prompt e escolha como aplicar o resultado.", "Revisa cada plano, asigna referencias y prompt, y elige cómo aplicar el resultado.", "检查每个镜头,设置参考图和提示词,并选择结果的应用方式。"],
"Track detected face identities to filter or split shots. Set masks here or per shot.": ["Rastreie identidades faciais para filtrar ou dividir planos. Configure as máscaras aqui ou por plano.", "Sigue identidades faciales para filtrar o dividir planos. Configura máscaras aquí o por plano.", "跟踪人脸身份以筛选或拆分镜头。可在此处或逐镜头设置蒙版。"],
"Set shared references, prompts and optional VLM assistance. Written prompts are kept exactly as authored.": ["Defina referências, prompts e assistência opcional do VLM. Os prompts escritos são preservados como estão.", "Configura referencias, prompts y asistencia opcional del VLM. Los prompts escritos se conservan tal cual.", "设置共享参考图、提示词和可选的 VLM 辅助。提示词会保持原样。"],
"Check warnings, choose auto or queue loop, and set filters or quick-run limits.": ["Confira avisos, escolha loop automático ou fila e configure filtros e limites de teste.", "Revisa avisos, elige bucle automático o cola y configura filtros y límites de prueba.", "查看警告,选择自动循环或队列,并设置筛选条件和快速运行限制。"],
"Color the selected region in the source video as a translucent visual marker. It does not create or enable an inpainting mask.": ["Colorir a região selecionada no vídeo de origem como uma marca translúcida. Isso não cria nem ativa uma máscara de inpainting.", "Colorea la región seleccionada en el vídeo de origen como una marca translúcida. No crea ni activa una máscara de inpainting.", "在源视频的选定区域叠加半透明颜色标记,不会创建或启用修补蒙版。"],
"🎬 Shot Planner": ["🎬 Planejador de planos", "🎬 Planificador de planos", "🎬 镜头规划器"],
"— choose a video from the input folder —": ["— escolha um vídeo da pasta de entrada —", "— elige un vídeo de la carpeta de entrada —", "— 从输入文件夹选择视频 —"],
"⬆ Upload": ["⬆ Enviar", "⬆ Subir", "⬆ 上传"], "Pick or upload a video to start": ["Escolha ou envie um vídeo para começar", "Elige o sube un vídeo para empezar", "选择或上传视频以开始"],
"⬆ Upload": ["⬆ Enviar", "⬆ Subir", "⬆ 上传"],
"It is split into shots the model can generate; every shot then gets its own reference, prompt and mask.": ["O vídeo será dividido em planos que o modelo consegue gerar; cada plano terá sua própria referência, prompt e máscara.", "El vídeo se divide en planos que el modelo puede generar; cada uno tendrá su referencia, prompt y máscara.", "视频会拆分为模型可生成的镜头,每个镜头都有自己的参考图、提示词和蒙版。"],
"work on a part of the video": ["trabalhe em um trecho do vídeo", "trabaja en una parte del vídeo", "处理视频的一部分"],
"Start frame": ["Frame inicial", "Fotograma inicial", "起始帧"], "End frame (0 = end)": ["Frame final (0 = fim)", "Fotograma final (0 = final)", "结束帧(0 = 末尾)"],
"⇤ start here": ["⇤ começar aqui", "⇤ empezar aquí", "⇤ 从此处开始"], "⇥ end here": ["⇥ terminar aqui", "⇥ terminar aquí", "⇥ 在此处结束"], "✕ whole video": ["✕ vídeo inteiro", "✕ vídeo completo", "✕ 整个视频"],
"Camera cuts or fixed length, on the model's frame grid": ["Cortes de câmera ou duração fixa, na grade de frames do modelo", "Cortes de cámara o duración fija, según la cuadrícula de fotogramas del modelo", "按镜头切换或固定时长,并符合模型帧步长"],
"re-analyses": ["reanalisar", "vuelve a analizar", "重新分析"], "ignores Max seconds / shot": ["ignora Máx. segundos por plano", "ignora Máx. segundos por plano", "忽略每个镜头的最长秒数"],
"Start frame": ["Frame inicial", "Fotograma inicial", "起始帧"],
"End frame (0 = end)": ["Frame final (0 = fim)", "Fotograma final (0 = final)", "结束帧(0 = 末尾)"],
"⇤ start here": ["⇤ começar aqui", "⇤ empezar aquí", "⇤ 从此处开始"],
"⇥ end here": ["⇥ terminar aqui", "⇥ terminar aquí", "⇥ 在此处结束"],
"✕ whole video": ["✕ vídeo inteiro", "✕ vídeo completo", "✕ 整个视频"],
"re-analyses": ["reanalisar", "vuelve a analizar", "重新分析"],
"ignores Max seconds / shot": ["ignora Máx. segundos por plano", "ignora Máx. segundos por plano", "忽略每个镜头的最长秒数"],
"cropped shots use it for the crop": ["planos recortados usam esse valor no recorte", "los planos recortados usan este valor", "裁剪镜头会使用此尺寸"],
"Split where the main person changes": ["Dividir quando a pessoa principal mudar", "Dividir cuando cambie la persona principal", "主要人物变化时拆分"],
"Only run shots with a detected person": ["Executar apenas planos com pessoa detectada", "Ejecutar solo planos con una persona detectada", "仅运行检测到人物的镜头"],
"adds a boundary when the biggest face switches to someone else (re-splits the plan)": ["cria uma divisão quando o maior rosto muda para outra pessoa (refaz a divisão)", "añade un corte cuando el rostro más grande cambia de persona (vuelve a dividir)", "最大人脸切换到其他人时添加分界(重新拆分计划)"],
"shots with no detected, non-ignored person are skipped": ["planos sem pessoa detectada e não ignorada são pulados", "se omiten los planos sin una persona detectada y no ignorada", "跳过没有检测到未忽略人物的镜头"],
"Nobody found yet.": ["Nenhuma pessoa encontrada.", "Aún no se encontraron personas.", "尚未检测到人物。"], "↻ Re-analyse people": ["↻ Analisar pessoas novamente", "↻ Volver a analizar personas", "↻ 重新分析人物"], "👥 Find people": ["👥 Encontrar pessoas", "👥 Buscar personas", "👥 查找人物"],
"faces sampled every 0.5 s and grouped by identity (InsightFace, CPU)": ["rostos amostrados a cada 0,5 s e agrupados por identidade (InsightFace, CPU)", "rostros muestreados cada 0,5 s y agrupados por identidad (InsightFace, CPU)", "每 0.5 秒采样人脸并按身份分组(InsightFace,CPU)"],
"detects faces across the video and groups them into people": ["detecta rostos no vídeo e os agrupa por pessoa", "detecta rostros en el vídeo y los agrupa por persona", "检测视频中的人脸并按人物分组"],
"track face identities to filter or split shots": ["rastreie rostos, associe cada pessoa a uma referência e divida os planos por quem aparece", "rastrea rostros, vincula cada persona a una referencia y divide los planos según quién aparece", "跟踪人脸、关联参考图,并按出镜人物拆分镜头"],
"Each shot picks what it marks (🎯 text or points, or 🎞 its own mask video) and how it is generated (": ["Cada plano define a seleção (🎯 texto, pontos ou 🎞 vídeo de máscara) e o modo de geração (", "Cada plano define la selección (🎯 texto, puntos o 🎞 vídeo de máscara) y el modo de generación (", "每个镜头设置选区(🎯 文本、点或 🎞 蒙版视频)和生成方式("],
"🎞 Mask video (whole video)": ["🎞 Vídeo de máscara (vídeo inteiro)", "🎞 Vídeo de máscara (vídeo completo)", "🎞 蒙版视频(整个视频)"],
"— none: SAM 3 per shot —": ["— nenhum: SAM 3 por plano —", "— ninguno: SAM 3 por plano —", "— 无:每个镜头使用 SAM 3 —"], "Fill holes": ["Preencher buracos", "Rellenar huecos", "填充孔洞"], "Invert": ["Inverter", "Invertir", "反转"],
"Show masks on shots": ["Mostrar máscaras nos planos", "Mostrar máscaras en los planos", "在镜头卡片上显示蒙版"], "Expand (px)": ["Expandir (px)", "Expandir (px)", "扩展(像素)"],
"Temporal expand (frames)": ["Expansão temporal (frames)", "Expansión temporal (fotogramas)", "时间扩展(帧)"], "Detection threshold": ["Limiar de detecção", "Umbral de detección", "检测阈值"], "Max objects": ["Máx. objetos", "Máx. objetos", "最大对象数"],
"Crop padding (%)": ["Margem do recorte (%)", "Margen del recorte (%)", "裁剪边距(%)"], "Feather (px)": ["Suavização da borda (px)", "Suavizado del borde (px)", "羽化(像素)"], "Paste back": ["Colar de volta", "Pegar de vuelta", "贴回方式"],
"only the mask (feathered)": ["somente a máscara (suavizada)", "solo la máscara (suavizada)", "仅蒙版(羽化边缘)"], "the whole box (feathered)": ["a caixa inteira (suavizada)", "todo el cuadro (suavizado)", "整个框(羽化边缘)"], "Blockify (px, 0 = off)": ["Blocos (px, 0 = desligado)", "Bloques (px, 0 = desactivado)", "块化(像素,0 = 关闭)"],
"Uses the official sam3.1_multiplex_fp16 checkpoint (models/checkpoints), downloaded from Comfy-Org/sam3.1 the first time.": ["Usa o checkpoint oficial sam3.1_multiplex_fp16 (models/checkpoints), baixado de Comfy-Org/sam3.1 na primeira utilização.", "Usa el checkpoint oficial sam3.1_multiplex_fp16 (models/checkpoints), descargado de Comfy-Org/sam3.1 la primera vez.", "使用官方 sam3.1_multiplex_fp16 检查点(models/checkpoints),首次运行时从 Comfy-Org/sam3.1 下载。"],
"SAM 3": ["SAM 3", "SAM 3", "SAM 3"], "track face identities to filter or split shots": ["rastreie rostos, associe cada pessoa a uma referência e organize os planos por quem aparece", "rastrea rostros, vincula cada persona a una referencia y organiza los planos según quién aparece", "跟踪人脸、关联参考图,并按出镜人物分配镜头"],
"Global reference & prompt": ["Referência e prompt globais", "Referencia y prompt globales", "全局参考图和提示词"], "defaults for every shot": ["padrão para todos os planos", "valor predeterminado para cada plano", "所有镜头的默认设置"],
"who the shot becomes": ["em quem o plano será transformado", "en quién se transforma el plano", "镜头中的人物将替换为谁"], "Prompt for this shot (empty = global prompt). Placeholders: {target} {details} {shot} {setting}": ["Prompt deste plano (vazio = prompt global). Variáveis: {target} {details} {shot} {setting}", "Prompt de este plano (vacío = prompt global). Variables: {target} {details} {shot} {setting}", "此镜头的提示词(留空则使用全局提示词)。占位符:{target} {details} {shot} {setting}"],
"recent (click = ref, shift+click = ref 2):": ["recentes (clique = ref., Shift+clique = ref. 2):", "recientes (clic = ref., Mayús+clic = ref. 2):", "最近使用(点击 = 参考图,Shift+点击 = 参考图 2):"], "✕ clear": ["✕ limpar", "✕ borrar", "✕ 清除"], "✕ clear all": ["✕ limpar tudo", "✕ borrar todo", "✕ 全部清除"], "Use global": ["Usar global", "Usar global", "使用全局设置"],
"empty slots use the global references (Prompts & refs tab)": ["campos vazios usam a referência da pessoa (Elenco) ou a global (aba Prompts e referências)", "los campos vacíos usan la referencia de la persona (Reparto) o la global (Prompts y referencias)", "空白项使用人物参考图(角色)或全局参考图(提示词和参考图选项卡)"],
"Full frame": ["Quadro inteiro", "Fotograma completo", "整帧"], "The whole frame is regenerated. The mask, if any, only feeds {target} / the setting picture.": ["O quadro inteiro é gerado novamente. A máscara, se houver, só fornece {target} / a imagem de cenário.", "Se vuelve a generar todo el fotograma. La máscara, si existe, solo aporta {target} / la imagen del escenario.", "重新生成整帧。蒙版(如有)仅用于提供 {target} / 场景参考图。"],
"Only the mask is regenerated, on the whole frame: the rest stays exactly as it was. No crop / uncrop.": ["Somente a área da máscara é regenerada; o restante permanece igual. Sem recorte.", "Solo se regenera el área de la máscara; el resto permanece igual. Sin recorte.", "仅重新生成蒙版区域,其余部分保持不变。不进行裁剪。"],
"A box around the mask is regenerated (more pixels for a small subject); BFS Shot Join pastes it back.": ["A caixa ao redor da máscara é regenerada (mais pixels para pessoas pequenas); o BFS Shot Join cola o resultado de volta.", "Se regenera el cuadro alrededor de la máscara (más píxeles para sujetos pequeños); BFS Shot Join lo pega de vuelta.", "重新生成蒙版周围的区域(为较小主体提供更多像素);BFS Shot Join 会将其贴回。"],
"Inside the crop, only the mask is regenerated: the most detail with the background kept.": ["Dentro do recorte, somente a máscara é regenerada: mais detalhes e fundo preservado.", "Dentro del recorte, solo se regenera la máscara: más detalle y fondo conservado.", "仅重新生成裁剪区域内的蒙版:细节更多,同时保留背景。"],
"The whole frame is generated (the pose follows the guide, the new subject may be bigger); BFS Shot Join pastes only the subject onto the original: background exact.": ["O quadro inteiro é gerado (a pose segue o guia e o novo sujeito pode ser maior); o BFS Shot Join cola apenas o sujeito no original e preserva o fundo.", "Se genera todo el fotograma (la pose sigue la guía y el sujeto puede ser más grande); BFS Shot Join pega solo al sujeto sobre el original y conserva el fondo.", "生成整帧(姿势遵循引导,主体可更大);BFS Shot Join 仅将主体贴回原画面,背景保持原样。"],
"🎯 Points…": ["🎯 Pontos…", "🎯 Puntos…", "🎯 点…"], "👁 Preview": ["👁 Prévia", "👁 Vista previa", "👁 预览"], "✕ Clear": ["✕ Limpar", "✕ Borrar", "✕ 清除"],
"🎞 mask video": ["🎞 vídeo de máscara", "🎞 vídeo de máscara", "🎞 蒙版视频"], "↳ global prompt": ["↳ prompt global", "↳ prompt global", "↳ 全局提示词"], "— no prompt —": ["— sem prompt —", "— sin prompt —", "— 无提示词 —"],
"▶ Play all": ["▶ Reproduzir todos", "▶ Reproducir todos", "▶ 播放全部"], "■ Stop": ["■ Parar", "■ Detener", "■ 停止"], "loop shot": ["repetir plano", "repetir plano", "循环播放镜头"],
"🎞 Mask video (whole video)": ["🎞 Vídeo de máscara (vídeo inteiro)", "🎞 Vídeo de máscara (todo el vídeo)", "🎞 蒙版视频(整段视频)"],
"Fill holes": ["Preencher buracos", "Rellenar huecos", "填充孔洞"],
"Invert": ["Inverter", "Invertir", "反转"],
"Expand (px)": ["Expandir (px)", "Expandir (px)", "扩展(像素)"],
"Temporal expand (frames)": ["Expansão temporal (frames)", "Expansión temporal (fotogramas)", "时间扩展(帧)"],
"Detection threshold": ["Limiar de detecção", "Umbral de detección", "检测阈值"],
"Max objects": ["Máx. objetos", "Máx. objetos", "最大对象数"],
"Crop padding (%)": ["Margem do recorte (%)", "Margen del recorte (%)", "裁剪边距(%)"],
"Feather (px)": ["Suavização da borda (px)", "Suavizado del borde (px)", "羽化(像素)"],
"Paste back": ["Colar de volta", "Pegar de vuelta", "贴回方式"],
"only the mask (feathered)": ["somente a máscara (suavizada)", "solo la máscara (suavizada)", "仅蒙版(羽化边缘)"],
"the whole box (feathered)": ["a caixa inteira (suavizada)", "todo el cuadro (suavizado)", "整个框(羽化边缘)"],
"Blockify (px, 0 = off)": ["Blocos (px, 0 = desligado)", "Bloques (px, 0 = desactivado)", "块化(像素,0 = 关闭)"],
"SAM 3": ["SAM 3", "SAM 3", "SAM 3"],
"✕ clear": ["✕ limpar", "✕ borrar", "✕ 清除"],
"✕ clear all": ["✕ limpar tudo", "✕ borrar todo", "✕ 全部清除"],
"Use global": ["Usar global", "Usar global", "使用全局设置"],
"🎯 Points…": ["🎯 Pontos…", "🎯 Puntos…", "🎯 点…"],
"🎞 mask video": ["🎞 vídeo de máscara", "🎞 vídeo de máscara", "🎞 蒙版视频"],
"↳ global prompt": ["↳ prompt global", "↳ prompt global", "↳ 全局提示词"],
"— no prompt —": ["— sem prompt —", "— sin prompt —", "— 无提示词 —"],
"▶ Play all": ["▶ Reproduzir todos", "▶ Reproducir todos", "▶ 播放全部"],
"■ Stop": ["■ Parar", "■ Detener", "■ 停止"],
"loop shot": ["repetir plano", "repetir plano", "循环播放镜头"],
"Click the timeline to seek. Play all skips shots that will not run.": ["Clique na linha do tempo para buscar. Reproduzir todos ignora planos que não serão executados.", "Haz clic en la línea de tiempo para buscar. Reproducir todos omite los planos que no se ejecutarán.", "点击时间轴进行定位。播放全部会跳过不运行的镜头。"],
"Reference & prompt": ["Referência e prompt", "Referencia y prompt", "参考图和提示词"], "Who is replaced": ["Quem será substituído", "Quién se reemplaza", "替换对象"], "Continuity": ["Continuidade", "Continuidad", "连续性"],
"Global reference & prompt": ["Referência e prompt globais", "Referencia y prompt globales", "全局参考图和提示词"], "Describe references ({details})": ["Descrever referências ({details})", "Describir referencias ({details})", "描述参考图({details})"],
"Custom": ["Personalizado", "Personalizado", "自定义"], "short": ["curto", "breve", "简短"], "full body": ["corpo inteiro", "cuerpo entero", "全身"], "head / face": ["cabeça / rosto", "cabeza / rostro", "头部/面部"], "face attributes": ["características do rosto", "rasgos faciales", "面部特征"], "outfit": ["roupa", "ropa", "服装"],
"Apply suggestions → all": ["Aplicar sugestões → todos", "Aplicar sugerencias → todos", "应用建议 → 全部"], "fit the prompt": ["ajustar ao prompt", "ajustar al prompt", "适配提示词"],
"Connect a Qwen3-VL (CLIPLoader) to the planner's vlm input. The VLM buttons work after the workflow has run once with it connected (ComfyUI only hands models to nodes when they run). Each shot's suggestion shows in its editor.": ["Conecte um Qwen3-VL (CLIPLoader) à entrada vlm do planner. Os botões do VLM funcionam depois que o workflow executar uma vez com ele conectado. A sugestão de cada plano aparece no editor.", "Conecta un Qwen3-VL (CLIPLoader) a la entrada vlm del planificador. Los botones del VLM funcionan después de ejecutar el flujo una vez con él conectado. La sugerencia de cada plano aparece en su editor.", "将 Qwen3-VL(CLIPLoader)连接到规划器的 vlm 输入。工作流连接后运行一次,VLM 按钮即可使用。每个镜头的建议会显示在其编辑器中。"],
"look at every shot and suggests the mask, a description and whether to run it": ["analisa cada plano e sugere máscara, descrição e se deve ser executado", "analiza cada plano y sugiere una máscara, una descripción y si debe ejecutarse", "分析每个镜头并建议蒙版、描述及是否运行"],
"🎯 Track → whole video": ["🎯 Rastrear → vídeo inteiro", "🎯 Seguir → vídeo completo", "🎯 跟踪 → 整个视频"], "Save (this shot)": ["Salvar (este plano)", "Guardar (este plano)", "保存(此镜头)"], "Save → all shots": ["Salvar → todos os planos", "Guardar → todos los planos", "保存 → 所有镜头"], "Cancel": ["Cancelar", "Cancelar", "取消"], "Clear points": ["Limpar pontos", "Borrar puntos", "清除点"], "👁 Segment": ["👁 Segmentar", "👁 Segmentar", "👁 分割"],
"Custom": ["Personalizado", "Personalizado", "自定义"],
"short": ["curto", "breve", "简短"],
"full body": ["corpo inteiro", "cuerpo entero", "全身"],
"head / face": ["cabeça / rosto", "cabeza / rostro", "头部/面部"],
"face attributes": ["características do rosto", "rasgos faciales", "面部特征"],
"outfit": ["roupa", "ropa", "服装"],
"Apply suggestions → all": ["Aplicar sugestões → todos", "Aplicar sugerencias → todos", "应用建议 → 全部"],
"fit the prompt": ["ajustar ao prompt", "ajustar al prompt", "适配提示词"],
"🎯 Track → whole video": ["🎯 Rastrear → vídeo inteiro", "🎯 Seguir → vídeo completo", "🎯 跟踪 → 整个视频"],
"Save (this shot)": ["Salvar (este plano)", "Guardar (este plano)", "保存(此镜头)"],
"Save → all shots": ["Salvar → todos os planos", "Guardar → todos los planos", "保存 → 所有镜头"],
"Cancel": ["Cancelar", "Cancelar", "取消"],
"Clear points": ["Limpar pontos", "Borrar puntos", "清除点"],
"👁 Segment": ["👁 Segmentar", "👁 Segmentar", "👁 分割"],
"Apply these points to EVERY shot, each on its frame at the same relative position (a subject that stays in place)": ["Aplicar estes pontos a TODOS os planos, na mesma posição relativa (para um sujeito que não muda de lugar)", "Aplicar estos puntos a TODOS los planos, en la misma posición relativa (si el sujeto no se mueve)", "将这些点应用到每个镜头的相同相对位置(适用于位置不变的主体)"],
"the range starts at the player's frame": ["o trecho começa no frame atual do player", "el rango empieza en el fotograma actual", "范围从播放器当前帧开始"], "the range ends after the player's frame": ["o trecho termina após o frame atual do player", "el rango termina después del fotograma actual", "范围在播放器当前帧之后结束"],
"Nothing to fix in the shots that run.": ["Nenhum problema nos planos que serão executados.", "No hay nada que corregir en los planos que se ejecutarán.", "将运行的镜头没有需要修复的问题。"], "all good": ["tudo certo", "todo bien", "一切正常"], "warning(s)": ["aviso(s)", "aviso(s)", "条警告"], "note(s)": ["observação(ões)", "nota(s)", "条备注"],
"quick tests": ["testes rápidos", "pruebas rápidas", "快速测试"], "from the start of the range": ["a partir do início do trecho", "desde el inicio del rango", "从范围起点开始"], "Each run generates one shot and stores it. Nodes after BFS Shot Join only run on the last shot, with the full video.": ["Cada execução gera e salva um plano. Os nodes após BFS Shot Join só executam no último plano, com o vídeo completo.", "Cada ejecución genera y guarda un plano. Los nodos posteriores a BFS Shot Join solo se ejecutan en el último plano, con el vídeo completo.", "每次运行生成并保存一个镜头。BFS Shot Join 后的节点仅在最后一个镜头运行,并接收完整视频。"],
"skipped shots do not run": ["planos ignorados não são executados", "los planos omitidos no se ejecutan", "跳过的镜头不会运行"], "only shots that run": ["somente planos executados", "solo los planos que se ejecutan", "仅统计将运行的镜头"],
"off": ["desligado", "desactivado", "关闭"], "on": ["ligado", "activado", "开启"], "run": ["executar", "ejecutar", "运行"], "skip": ["ignorar", "omitir", "跳过"], "filters decide": ["filtros decidem", "deciden los filtros", "由筛选条件决定"], "always run": ["sempre executar", "ejecutar siempre", "始终运行"], "always skip": ["sempre ignorar", "omitir siempre", "始终跳过"], "⏸ Disable": ["⏸ Desativar", "⏸ Desactivar", "⏸ 禁用"], "▶ Enable": ["▶ Ativar", "▶ Activar", "▶ 启用"], "✂ Split": ["✂ Dividir", "✂ Dividir", "✂ 拆分"], "⇥ Merge": ["⇥ Unir", "⇥ Unir", "⇥ 合并"],
"Advanced": ["Avançado", "Avanzado", "高级"], "English": ["Inglês", "Inglés", "英语"], "Português": ["Português", "Portugués", "葡萄牙语"], "Español": ["Espanhol", "Español", "西班牙语"], "中文": ["Chinês", "Chino", "中文"],
"Video": ["Vídeo", "Vídeo", "视频"], "Shots": ["Planos", "Planos", "镜头"], "Prompts": ["Prompts", "Prompts", "提示词"],
"Marker color · Ref 1": ["Cor do marcador · Ref. 1", "Color de marca · Ref. 1", "标记颜色 · 参考图 1"], "Marker color · Ref 2": ["Cor do marcador · Ref. 2", "Color de marca · Ref. 2", "标记颜色 · 参考图 2"],
"These are the default marker colors for the global references. Turn on Colored Mark on a shot to apply them; shot-level colors can override them.": ["Estas são as cores padrão das referências globais. Ative Marcação colorida no plano para usá-las; cada plano pode substituí-las.", "Estos son los colores predeterminados de las referencias globales. Activa Marca de color en un plano para aplicarlos; cada plano puede cambiarlos.", "这些是全局参考图的默认标记颜色。启用镜头的彩色标记即可应用;镜头也可以单独覆盖。"],
"Audio": ["Áudio", "Audio", "音频"], "use the video's audio": ["usar o áudio do vídeo", "usar el audio del vídeo", "使用视频音频"], "silent track": ["faixa silenciosa", "pista silenciosa", "静音音轨"],
"auto": ["automático", "automático", "自动"], "fixed": ["fixo", "fijo", "固定"], "single": ["único", "único", "单个"], "adaptive": ["adaptativo", "adaptativo", "自适应"], "content": ["conteúdo", "contenido", "内容"], "builtin": ["integrado", "integrado", "内置"],
"PySceneDetect adaptive": ["PySceneDetect adaptativo", "PySceneDetect adaptativo", "PySceneDetect 自适应"], "PySceneDetect content": ["PySceneDetect por conteúdo", "PySceneDetect por contenido", "PySceneDetect 内容检测"], "Built-in": ["Integrado", "Integrado", "内置"],
"Auto loop": ["Loop automático", "Bucle automático", "自动循环"], "Queue loop": ["Loop em fila", "Bucle en cola", "队列循环"], "queue loop": ["loop em fila", "bucle en cola", "队列循环"], "auto loop": ["loop automático", "bucle automático", "自动循环"],
"no audio": ["sem áudio", "sin audio", "无音频"], "silent track": ["faixa silenciosa", "pista silenciosa", "静音音轨"], "timeline": ["linha do tempo", "línea de tiempo", "时间轴"], "generate at": ["gerar em", "generar a", "生成尺寸"],
"Max person size (0-1 of frame)": ["Tamanho mínimo da pessoa (0–1 do quadro)", "Tamaño mínimo de persona (0–1 del fotograma)", "人物最小尺寸(占画面 0–1)"], "Min person size (0-1 of frame)": ["Tamanho mínimo da pessoa (0–1 do quadro)", "Tamaño mínimo de persona (0–1 del fotograma)", "人物最小尺寸(占画面 0–1)"],
"Max people (0 = any)": ["Máx. pessoas (0 = qualquer)", "Máx. personas (0 = cualquiera)", "人物上限(0 = 不限)"], "Min frames (0 = off)": ["Mín. frames (0 = desligado)", "Mín. fotogramas (0 = desactivado)", "最少帧数(0 = 关闭)"], "Frames sampled / shot": ["Frames amostrados por plano", "Fotogramas muestreados por plano", "每个镜头采样帧数"], "Dark below (0-1)": ["Escuro abaixo de (0–1)", "Oscuro por debajo de (0–1)", "暗度低于(0–1)"], "Static below": ["Estático abaixo de", "Estático por debajo de", "静止度低于"],
"e.g. 0.03 skips wide shots": ["ex.: 0,03 ignora planos abertos", "p. ej., 0,03 omite planos amplios", "例如 0.03 会跳过远景镜头"], "skip crowds": ["ignorar multidões", "omitir multitudes", "跳过人群"], "re-analyses": ["reanalisar", "vuelve a analizar", "重新分析"],
"Describe target": ["Descrever alvo", "Describir objetivo", "描述目标"], "🧑 Describe target": ["🧑 Descrever alvo", "🧑 Describir objetivo", "🧑 描述目标"], "new subject": ["novo sujeito", "nuevo sujeto", "新主体"], "person (or dog, cat, car… what the result shows)": ["pessoa (ou cão, gato, carro… o que aparecerá no resultado)", "persona (o perro, gato, coche… lo que aparecerá en el resultado)", "人物(或狗、猫、汽车等结果中的主体)"],
"The second person (optional)": ["Segunda pessoa (opcional)", "Segunda persona (opcional)", "第二个人(可选)"], "Follow cast": ["Seguir identidade", "Seguir reparto", "跟随角色"], "manual identity": ["identidade manual", "identidad manual", "手动身份"],
"Guide video": ["Vídeo guia", "Vídeo guía", "引导视频"], "Reference": ["Referência", "Referencia", "参考图"], "Prompt": ["Prompt", "Prompt", "提示词"], "reference": ["referência", "referencia", "参考图"],
"on": ["ativado", "activado", "开启"], "off": ["desativado", "desactivado", "关闭"], "not analysed": ["não analisado", "sin analizar", "未分析"], "not analyzed": ["não analisado", "sin analizar", "未分析"],
"No shots yet": ["Nenhum plano ainda", "Aún no hay planos", "尚无镜头"], "No video": ["Nenhum vídeo", "Sin vídeo", "没有视频"], "No faces": ["Nenhum rosto", "Sin rostros", "没有人脸"], "no faces": ["sem rostos", "sin rostros", "没有人脸"],
"cut": ["corte", "corte", "切换"], "run": ["executar", "ejecutar", "运行"], "skip": ["ignorar", "omitir", "跳过"], "skip this shot": ["ignorar este plano", "omitir este plano", "跳过此镜头"],
"people": ["pessoas", "personas", "人物"], "person": ["pessoa", "persona", "人物"], "shots": ["planos", "planos", "镜头"], "shot(s)": ["plano(s)", "plano(s)", "个镜头"], "linked": ["vinculada", "vinculada", "已关联"], "main": ["principal", "principal", "主要"],
"No VLM yet": ["Nenhum VLM carregado", "Aún no hay un VLM", "尚未加载 VLM"], "Connect a Qwen3-VL": ["Conecte um Qwen3-VL", "Conecta un Qwen3-VL", "连接 Qwen3-VL"],
"↻ Status": ["↻ Status", "↻ Estado", "↻ 状态"], "⟲ Reset loop": ["⟲ Reiniciar loop", "⟲ Reiniciar bucle", "⟲ 重置循环"], "Queue loop": ["Loop em fila", "Bucle en cola", "队列循环"],
"Start here": ["Começar aqui", "Empezar aquí", "从此处开始"], "End here": ["Terminar aqui", "Terminar aquí", "在此处结束"], "reset": ["redefinir", "restablecer", "重置"], "all shots": ["todos os planos", "todos los planos", "所有镜头"], "this shot": ["este plano", "este plano", "此镜头"],
"Describe target": ["Descrever alvo", "Describir objetivo", "描述目标"], "Settings": ["Configurações", "Ajustes", "设置"], "mask text": ["texto da máscara", "texto de máscara", "蒙版描述"],
"The global reference and prompt are used when a shot has no values of its own.": ["A referência e o prompt globais são usados quando um plano não tem valores próprios.", "La referencia y el prompt globales se usan cuando un plano no tiene valores propios.", "镜头没有单独设置时会使用全局参考图和提示词。"],
"A VLM is optional": ["O VLM é opcional", "El VLM es opcional", "VLM 为可选项"], "Optional": ["Opcional", "Opcional", "可选"], "optional": ["opcional", "opcional", "可选"],
"person in white": ["pessoa de branco", "persona de blanco", "穿白衣的人"], "woman in pink top": ["mulher de blusa rosa", "mujer con blusa rosa", "穿粉色上衣的女人"],
"nothing found": ["nada encontrado", "no se encontró nada", "未找到内容"], "not found": ["não encontrado", "no encontrado", "未找到"], "Preview": ["Prévia", "Vista previa", "预览"],
"Mask only / Crop + mask": ["Somente máscara / Recorte + máscara", "Solo máscara / Recorte + máscara", "仅蒙版 / 裁剪并蒙版"], "per shot (planner)": ["por plano (planner)", "por plano (planificador)", "逐镜头(规划器)"],
"Person": ["Pessoa", "Persona", "人物"], "shot": ["plano", "plano", "镜头"], "shot(s)": ["plano(s)", "plano(s)", "个镜头"],
"mask covers": ["máscara cobre", "la máscara cubre", "蒙版覆盖"], "nothing to fix": ["nada a corrigir", "nada que corregir", "无需修复"],
"the range starts at the player's frame": ["o trecho começa no frame atual do player", "el rango empieza en el fotograma actual", "范围从播放器当前帧开始"],
"Nothing to fix in the shots that run.": ["Nenhum problema nos planos que serão executados.", "No hay nada que corregir en los planos que se ejecutarán.", "将运行的镜头没有需要修复的问题。"],
"all good": ["tudo certo", "todo bien", "一切正常"],
"warning(s)": ["aviso(s)", "aviso(s)", "条警告"],
"note(s)": ["observação(ões)", "nota(s)", "条备注"],
"quick tests": ["testes rápidos", "pruebas rápidas", "快速测试"],
"Each run generates one shot and stores it. Nodes after BFS Shot Join only run on the last shot, with the full video.": ["Cada execução gera e salva um plano. Os nodes após BFS Shot Join só executam no último, com o vídeo completo.", "Cada ejecución genera y guarda un plano. Los nodos posteriores a BFS Shot Join solo se ejecutan en el último, con el vídeo completo.", "每次运行生成并保存一个镜头。BFS Shot Join 后的节点仅在最后一个镜头运行,并接收完整视频。"],
"only shots that run": ["somente planos executados", "solo los planos que se ejecutan", "仅统计将运行的镜头"],
"off": ["desligado", "desactivado", "关闭"],
"on": ["ativado", "activado", "开启"],
"run": ["executar", "ejecutar", "运行"],
"skip": ["ignorar", "omitir", "跳过"],
"⏸ Disable": ["⏸ Desativar", "⏸ Desactivar", "⏸ 禁用"],
"▶ Enable": ["▶ Ativar", "▶ Activar", "▶ 启用"],
"✂ Split": ["✂ Dividir", "✂ Dividir", "✂ 拆分"],
"⇥ Merge": ["⇥ Unir", "⇥ Unir", "⇥ 合并"],
"Advanced": ["Avançado", "Avanzado", "高级"],
"English": ["Inglês", "Inglés", "英语"],
"Português": ["Português", "Portugués", "葡萄牙语"],
"Español": ["Espanhol", "Español", "西班牙语"],
"中文": ["Chinês", "Chino", "中文"],
"Audio": ["Áudio", "Audio", "音频"],
"use the video's audio": ["usar o áudio do vídeo", "usar el audio del vídeo", "使用视频音频"],
"silent track": ["faixa silenciosa", "pista silenciosa", "静音音轨"],
"auto": ["automático", "automático", "自动"],
"fixed": ["fixo", "fijo", "固定"],
"single": ["único", "único", "单个"],
"adaptive": ["adaptativo", "adaptativo", "自适应"],
"content": ["conteúdo", "contenido", "内容"],
"builtin": ["integrado", "integrado", "内置"],
"PySceneDetect adaptive": ["PySceneDetect adaptativo", "PySceneDetect adaptativo", "PySceneDetect 自适应"],
"PySceneDetect content": ["PySceneDetect por conteúdo", "PySceneDetect por contenido", "PySceneDetect 内容检测"],
"Built-in": ["Integrado", "Integrado", "内置"],
"Auto loop": ["Loop automático", "Bucle automático", "自动循环"],
"Queue loop": ["Loop em fila", "Bucle en cola", "队列循环"],
"queue loop": ["loop em fila", "bucle en cola", "队列循环"],
"auto loop": ["loop automático", "bucle automático", "自动循环"],
"no audio": ["sem áudio", "sin audio", "无音频"],
"timeline": ["linha do tempo", "línea de tiempo", "时间轴"],
"generate at": ["gerar em", "generar a", "生成尺寸"],
"Describe target": ["Descrever alvo", "Describir objetivo", "描述目标"],
"🧑 Describe target": ["🧑 Descrever alvo", "🧑 Describir objetivo", "🧑 描述目标"],
"new subject": ["novo sujeito", "nuevo sujeto", "新对象"],
"Prompt": ["Prompt", "Prompt", "提示词"],
"reference": ["referência", "referencia", "参考图"],
"No shots yet": ["Nenhum plano ainda", "Aún no hay planos", "尚无镜头"],
"cut": ["corte", "corte", "切换"],
"people": ["pessoas", "personas", "人物"],
"person": ["pessoa", "persona", "人物"],
"shots": ["planos", "planos", "镜头"],
"shot(s)": ["plano(s)", "plano(s)", "个镜头"],
"main": ["principal", "principal", "主要"],
"Connect a Qwen3-VL": ["Conecte um Qwen3-VL", "Conecta un Qwen3-VL", "连接 Qwen3-VL"],
"reset": ["redefinir", "restablecer", "重置"],
"all shots": ["todos os planos", "todos los planos", "所有镜头"],
"this shot": ["este plano", "este plano", "此镜头"],
"Settings": ["Configurações", "Ajustes", "设置"],
"mask text": ["texto da máscara", "texto de máscara", "蒙版描述"],
"optional": ["opcional", "opcional", "可选"],
"woman in pink top": ["mulher de blusa rosa", "mujer con blusa rosa", "穿粉色上衣的女人"],
"nothing found": ["nada encontrado", "no se encontró nada", "未找到内容"],
"Preview": ["Prévia", "Vista previa", "预览"],
"per shot (planner)": ["por plano (planner)", "por plano (planificador)", "逐镜头(规划器)"],
"shot": ["plano", "plano", "镜头"],
"mask covers": ["máscara cobre", "la máscara cubre", "蒙版覆盖"],
"Camera cuts: shots start at the detected cuts, long ones are split evenly. Fixed length: equal parts no longer than the maximum.": ["Cortes de câmera: os planos começam nos cortes detectados; planos longos são divididos igualmente. Duração fixa: partes iguais limitadas pelo máximo.", "Cortes de cámara: los planos empiezan en los cortes detectados; los largos se dividen por igual. Duración fija: partes iguales con el máximo indicado.", "镜头切换:镜头从检测到的切点开始,较长镜头会均匀拆分。固定时长:拆分为不超过上限的等长片段。"],
"Cut detector. PySceneDetect adaptive works on most footage (fast camera moves don't trigger it); content is stricter on hard cuts; built-in needs no package.": ["Detector de cortes. O modo adaptativo do PySceneDetect funciona na maioria dos vídeos (movimentos rápidos de câmera não acionam cortes); o modo conteúdo é mais rigoroso; o integrado não exige pacote.", "Detector de cortes. El modo adaptativo de PySceneDetect funciona en la mayoría de vídeos (los movimientos rápidos no activan cortes); contenido es más estricto; el integrado no requiere paquetes.", "镜头切换检测器。PySceneDetect 自适应模式适用于大多数素材(快速运镜不会触发切点);内容模式对硬切更严格;内置模式无需额外软件包。"],
"How easily a change counts as a cut. Higher finds more cuts (and more false ones).": ["O quanto uma mudança é considerada corte. Um valor maior encontra mais cortes e também mais falsos positivos.", "Facilidad con la que un cambio cuenta como corte. Un valor mayor detecta más cortes y también más falsos positivos.", "变化被判定为切点的难易程度。数值越高,检测到的切点越多,误报也越多。"],
@@ -193,10 +220,7 @@ const TEXT = {
"Shorter shots merge into a neighbour when the merge still fits the maximum.": ["Planos mais curtos são unidos ao vizinho quando a junção ainda respeita o máximo.", "Los planos más cortos se unen al vecino si la unión respeta el máximo.", "较短镜头会在合并后仍未超过上限时与相邻镜头合并。"],
"The planner works on this frame rate (the source is resampled). Generated video and audio use it too.": ["O planner usa esta taxa de frames (o vídeo de origem é reamostrado). O vídeo e áudio gerados também a usam.", "El planificador usa esta frecuencia de fotogramas (el origen se remuestrea). El vídeo y el audio generados también la usan.", "规划器使用此帧率(源视频会重新采样)。生成的视频和音频也会使用此帧率。"],
"Only the first N shots (0 = all). Handy for quick tests.": ["Somente os primeiros N planos (0 = todos). Útil para testes rápidos.", "Solo los primeros N planos (0 = todos). Útil para pruebas rápidas.", "仅处理前 N 个镜头(0 = 全部)。适合快速测试。"],
"Only the first N seconds of the video (0 = all).": ["Somente os primeiros N segundos do vídeo (0 = todos).", "Solo los primeros N segundos del vídeo (0 = todos).", "仅处理视频前 N 秒(0 = 全部)。"],
"Generation size: the source aspect ratio at this pixel area (0.15 ≈ 512×288). Cropped shots use it for the crop.": ["Tamanho da geração: mantém a proporção do vídeo nessa área de pixels (0,15 ≈ 512×288). Planos recortados usam esse valor no recorte.", "Tamaño de generación: mantiene la proporción de origen con esta área de píxeles (0,15 ≈ 512×288). Los planos recortados usan este valor.", "生成尺寸:按此像素面积保持源视频宽高比(0.15 ≈ 512×288)。裁剪镜头使用此尺寸。"],
"Width and height snap to this multiple (32 for H3 and most video models).": ["Largura e altura são ajustadas a este múltiplo (32 para H3 e a maioria dos modelos de vídeo).", "El ancho y alto se ajustan a este múltiplo (32 para H3 y la mayoría de modelos de vídeo).", "宽度和高度会对齐到此倍数(H3 和多数视频模型使用 32)。"],
"Auto loop: every shot in one queue run. Queue loop: one shot per run, stored on disk, the join outputs the video after the last shot.": ["Loop automático: todos os planos em uma execução da fila. Loop em fila: um plano por execução, salvo em disco; o join gera o vídeo após o último plano.", "Bucle automático: todos los planos en una ejecución. Bucle en cola: un plano por ejecución, guardado en disco; el join genera el vídeo al terminar el último.", "自动循环:一次队列运行所有镜头。队列循环:每次运行一个镜头并保存到磁盘;最后一个镜头完成后由 Join 输出视频。"],
"Shots that do not run: keep the original video there, or remove them (with their audio).": ["Planos não executados: manter o vídeo original nesses trechos ou removê-los junto com o áudio.", "Planos que no se ejecutan: conserva el vídeo original en ese tramo o elimínalo junto con su audio.", "未运行的镜头:保留对应原视频片段,或连同音频一起移除。"],
"Context kept around the mask's box, as a % of the box. More context blends better; less gives the model more pixels on the subject.": ["Contexto mantido ao redor da caixa da máscara, como porcentagem da caixa. Mais contexto mistura melhor; menos dá mais pixels ao sujeito.", "Contexto alrededor del cuadro de la máscara, como porcentaje. Más contexto mezcla mejor; menos ofrece más píxeles al sujeto.", "蒙版边框周围保留的上下文比例。上下文越多越容易融合;越少则主体像素越多。"],
"Grow the mask by this many pixels: before pasting a cropped result back, and for the generation mask (inpaint 'only the mask'). Raise it when the new person is bigger than the old one (longer hair, wider body).": ["Expande a máscara por esta quantidade de pixels antes de colar o recorte e na máscara de geração. Aumente se a nova pessoa for maior (cabelo mais comprido, corpo mais largo).", "Amplía la máscara esta cantidad de píxeles antes de pegar el recorte y para la máscara de generación. Auméntala si la persona nueva es más grande.", "粘贴裁剪结果前以及生成蒙版时,将蒙版扩展指定像素。新人物更大时(头发更长、身体更宽)可调高。"],
@@ -206,85 +230,52 @@ const TEXT = {
"SAM 3 score needed to keep a text match. Lower finds more (and more wrong) objects.": ["Pontuação mínima do SAM 3 para manter uma correspondência por texto. Valores menores encontram mais objetos, inclusive incorretos.", "Puntuación mínima de SAM 3 para conservar una coincidencia de texto. Un valor menor encuentra más objetos, también erróneos.", "保留文本匹配所需的 SAM 3 分数。调低会找到更多对象,也会增加误检。"],
"How many matches of the text prompt are tracked together.": ["Quantas correspondências do texto serão rastreadas juntas.", "Cuántas coincidencias del texto se siguen a la vez.", "同时跟踪多少个符合文本的对象。"],
"Only the mask: the result replaces the masked pixels. The whole box: the full crop is pasted with feathered borders.": ["Somente máscara: o resultado substitui os pixels mascarados. Caixa inteira: todo o recorte é colado com bordas suaves.", "Solo máscara: el resultado sustituye los píxeles enmascarados. Cuadro completo: se pega todo el recorte con bordes suavizados.", "仅蒙版:结果替换蒙版像素。整个框:粘贴完整裁剪区域并柔化边缘。"],
"How many frames of each shot the VLM looks at.": ["Quantos frames de cada plano o VLM analisa.", "Cuántos fotogramas de cada plano analiza el VLM.", "VLM 分析每个镜头的帧数。"], "Longest VLM answer. Raise it for long descriptions.": ["Tamanho máximo da resposta do VLM. Aumente para descrições longas.", "Longitud máxima de respuesta del VLM. Auméntala para descripciones largas.", "VLM 回复的最大长度。长描述可调高。"],
"skipped by hand": ["ignorado manualmente", "omitido manualmente", "手动跳过"], "no detected person": ["nenhuma pessoa detectada", "no se detectó ninguna persona", "未检测到人物"],
"How many frames of each shot the VLM looks at.": ["Quantos frames de cada plano o VLM analisa.", "Cuántos fotogramas de cada plano analiza el VLM.", "VLM 分析每个镜头的帧数。"],
"Longest VLM answer. Raise it for long descriptions.": ["Tamanho máximo da resposta do VLM. Aumente para descrições longas.", "Longitud máxima de respuesta del VLM. Auméntala para descripciones largas.", "VLM 回复的最大长度。长描述可调高。"],
"skipped by hand": ["ignorado manualmente", "omitido manualmente", "手动跳过"],
"longer than {…} frames: split it": ["mais longo que {…} frames: divida o plano", "supera {…} fotogramas: divide el plano", "超过 {…} 帧:请拆分镜头"],
"{…} needs a mask (text, points or a mask video; or the planner's mask input)": ["{…} precisa de uma máscara (texto, pontos, vídeo de máscara ou entrada de máscara do Planner)", "{…} necesita una máscara (texto, puntos, vídeo de máscara o entrada de máscara del planificador)", "{…} 需要蒙版(文本、点、蒙版视频或 Planner 的蒙版输入)"],
"the mask preview found nothing": ["a prévia da máscara não encontrou nada", "la vista previa de la máscara no encontró nada", "蒙版预览未找到目标"],
"the prompt uses {target} but the shot has no target description": ["o prompt usa {target}, mas o plano não tem uma descrição do alvo", "el prompt usa {target}, pero el plano no tiene una descripción del objetivo", "提示词使用了 {target},但镜头没有目标描述"],
"the prompt uses {details} but its references have no description yet": ["o prompt usa {details}, mas as referências ainda não têm descrição", "el prompt usa {details}, pero las referencias aún no tienen descripción", "提示词使用了 {details},但参考图还没有描述"],
"{details} sits inside a sentence of the prompt: use the 'short' describe preset (Prompts & refs), the other presets write a paragraph": ["{details} está dentro de uma frase do prompt: use a descrição 'short' (Prompts e referências); os outros modos escrevem um parágrafo", "{details} está dentro de una frase del prompt: usa la opción 'short' (Prompts y referencias); las demás opciones escriben un párrafo", "{details} 位于提示词句子中:请使用“short”描述模式(Prompts & refs),其他模式会生成段落"],
"no prompt (fine when the node's prompt input is connected)": ["sem prompt (tudo certo se a entrada prompt do node estiver conectada)", "sin prompt (no pasa nada si la entrada prompt del nodo está conectada)", "没有提示词(若节点的 prompt 输入已连接则无需处理)"],
"no reference (fine when the node's ref_image input is connected)": ["sem referência (tudo certo se a entrada ref_image do node estiver conectada)", "sin referencia (no pasa nada si la entrada ref_image del nodo está conectada)", "没有参考图(若节点的 ref_image 输入已连接则无需处理)"],
"Analysing video…": ["Analisando o vídeo…", "Analizando el vídeo…", "正在分析视频…"], "Detecting cuts…": ["Detectando cortes…", "Detectando cortes…", "正在检测切点…"], "Detecting people and faces…": ["Detectando pessoas e rostos…", "Detectando personas y rostros…", "正在检测人物和人脸…"],
"Finding people by face…": ["Localizando pessoas pelos rostos…", "Buscando personas por sus rostros…", "正在通过人脸查找人物…"], "Selecting the person (SAM 3) and describing them (VLM)…": ["Selecionando a pessoa (SAM 3) e descrevendo-a (VLM)…", "Seleccionando a la persona (SAM 3) y describiéndola (VLM)…", "正在选择人物(SAM 3)并生成描述(VLM)…"],
"Analysing video…": ["Analisando o vídeo…", "Analizando el vídeo…", "正在分析视频…"],
"Detecting cuts…": ["Detectando cortes…", "Detectando cortes…", "正在检测切点…"],
"Selecting the person (SAM 3) and describing them (VLM)…": ["Selecionando a pessoa (SAM 3) e descrevendo-a (VLM)…", "Seleccionando a la persona (SAM 3) y describiéndola (VLM)…", "正在选择人物(SAM 3)并生成描述(VLM)…"],
"The SAM 3 target cutout was created (shown below), but no VLM is loaded to describe it. Connect Qwen3-VL to the Shot Planner's vlm input and queue the workflow once; then click Describe target again. You can also type the target description yourself.": ["O recorte do alvo pelo SAM 3 foi criado abaixo, mas nenhum VLM está carregado para descrevê-lo. Conecte o Qwen3-VL à entrada vlm do Shot Planner e execute a fila uma vez; depois clique novamente em Descrever alvo. Você também pode escrever a descrição manualmente.", "SAM 3 creó el recorte del objetivo (se muestra abajo), pero no hay ningún VLM cargado para describirlo. Conecta Qwen3-VL a la entrada vlm de Shot Planner y ejecuta el flujo una vez; después vuelve a pulsar Describir objetivo. También puedes escribir la descripción.", "SAM 3 已生成目标抠图(见下方),但尚未加载 VLM 来描述它。将 Qwen3-VL 连接到 Shot Planner 的 vlm 输入并运行一次队列,然后再次点击“描述目标”。也可以手动填写描述。"],
"Segmenting with SAM 3… (the first time downloads/loads the model)": ["Segmentando com SAM 3… (na primeira vez, o modelo será baixado/carregado)", "Segmentando con SAM 3… (la primera vez se descarga/carga el modelo)", "正在使用 SAM 3 分割…(首次运行会下载并加载模型)"],
"open the checks (Run tab)": ["abrir as verificações (aba Executar)", "abrir las comprobaciones (pestaña Ejecutar)", "打开检查项(Run 标签页)"], "Open the full guide (every setting explained, with examples)": ["Abrir o guia completo (com explicações e exemplos de cada opção)", "Abrir la guía completa (explicaciones y ejemplos de cada opción)", "打开完整指南(包含各设置的说明和示例)"],
"click = keep (green) · right-click or shift+click = exclude (red) · click a point to remove it": ["clique = manter (verde) · clique direito ou Shift+clique = excluir (vermelho) · clique em um ponto para removê-lo", "clic = conservar (verde) · clic derecho o Mayús+clic = excluir (rojo) · clic en un punto para quitarlo", "单击 = 保留(绿色)· 右键或 Shift+单击 = 排除(红色)· 单击已有点可将其删除"],
"click to remove": ["clique para remover", "haz clic para quitar", "点击删除"], "text prompt (used when there are no points)": ["texto de seleção (usado quando não há pontos)", "texto de selección (se usa cuando no hay puntos)", "文本提示(没有标记点时使用)"],
"Save → all shots": ["Salvar → todos os planos", "Guardar → todos los planos", "保存 → 所有镜头"], "Save (this shot)": ["Salvar (este plano)", "Guardar (este plano)", "保存(此镜头)"],
"open the checks (Run tab)": ["abrir as verificações (aba Executar)", "abrir las comprobaciones (pestaña Ejecutar)", "打开检查项(Run 标签页)"],
"Open the full guide (every setting explained, with examples)": ["Abrir o guia completo (com explicações e exemplos de cada opção)", "Abrir la guía completa (explicaciones y ejemplos de cada opción)", "打开完整指南(包含各设置的说明和示例)"],
"click to remove": ["clique para remover", "haz clic para quitar", "点击删除"],
"text prompt (used when there are no points)": ["texto de seleção (usado quando não há pontos)", "texto de selección (se usa cuando no hay puntos)", "文本提示(没有标记点时使用)"],
"Track this selection with SAM 3 through the whole video (or the range), forward and backward from this frame; every shot without its own selection takes its part of the track. Also type a short text (e.g. 'woman in pink top') so the subject is found again after camera cuts.": ["Rastreie esta seleção com SAM 3 pelo vídeo inteiro (ou trecho), avançando e retrocedendo a partir deste frame. Cada plano sem seleção própria usa sua parte do rastreamento. Digite também uma descrição curta (por exemplo, 'mulher de blusa rosa') para reencontrar o sujeito após os cortes.", "Sigue esta selección con SAM 3 por todo el vídeo (o tramo), hacia delante y atrás desde este fotograma. Cada plano sin selección propia usa su parte del seguimiento. Añade también una descripción breve (por ejemplo, 'mujer con blusa rosa') para volver a encontrar al sujeto después de los cortes.", "使用 SAM 3 从此帧开始向前和向后跟踪所选对象,覆盖整个视频(或片段)。没有单独选区的镜头会使用对应的跟踪结果。再输入简短描述(如“穿粉色上衣的女性”),以便在切镜后重新找到主体。"],
"Colored Mark adds a translucent color overlay to a selected region in the source video. It does not enable inpainting. Choose the color before marking the region with points or a description.": ["Marcação colorida aplica uma sobreposição translúcida à região selecionada no vídeo de origem. Ela não ativa inpainting. Escolha a cor antes de marcar a região com pontos ou descrição.", "Marca de color aplica una superposición translúcida a la región seleccionada del vídeo original. No activa el inpainting. Elige el color antes de marcar con puntos o una descripción.", "彩色标记会在源视频的选定区域上叠加半透明颜色,不会启用修补。请先选颜色,再用点或描述标记区域。"],
"choose which shots run": ["escolher quais planos serão executados", "elegir qué planos se ejecutan", "选择要运行的镜头"],
"Fill enclosed gaps so each region is solid": ["Preencher espaços fechados para deixar cada região sólida", "Rellenar huecos cerrados para que cada región sea sólida", "填充封闭空隙,使区域完整"], "Use everything except the segmented object": ["Usar tudo, exceto o objeto segmentado", "Usar todo excepto el objeto segmentado", "使用除已分割对象以外的所有区域"], "Overlay the mask preview on the shot cards": ["Sobrepor a prévia da máscara nos cartões dos planos", "Superponer la vista previa de la máscara en las tarjetas de los planos", "在镜头卡片上叠加蒙版预览"],
"runs the detectors on a few frames of every shot": ["executa os detectores em alguns frames de cada plano", "ejecuta los detectores en algunos fotogramas de cada plano", "在每个镜头的若干帧上运行检测器"],
"Fill enclosed gaps so each region is solid": ["Preencher espaços fechados para deixar cada região sólida", "Rellenar huecos cerrados para que cada región sea sólida", "填充封闭空隙,使区域完整"],
"Overlay the mask preview on the shot cards": ["Sobrepor a prévia da máscara nos cartões dos planos", "Superponer la vista previa de la máscara en las tarjetas de los planos", "在镜头卡片上叠加蒙版预览"],
"Global prompt (a connected `prompt` input overrides it)": ["Prompt global (a entrada `prompt` conectada tem prioridade)", "Prompt global (la entrada `prompt` conectada tiene prioridad)", "全局提示词(若连接了 `prompt` 输入,则以输入为准)"],
"Used by shots without their own. Connected ref_image / ref_image_2 / prompt inputs on the node override these. Placeholders:": ["Usado nos planos sem valores próprios. As entradas ref_image / ref_image_2 / prompt conectadas ao node têm prioridade. Marcadores:", "Se usa en los planos sin valores propios. Las entradas ref_image / ref_image_2 / prompt conectadas al nodo tienen prioridad. Marcadores:", "镜头没有单独设置时使用。节点上连接的 ref_image / ref_image_2 / prompt 输入优先。占位符:"],
"description of the shot's references ·": ["descrição das referências do plano ·", "descripción de las referencias del plano ·", "镜头参考图描述 ·"], "who is replaced (per shot) ·": ["quem será substituído (por plano) ·", "quién se sustituye (por plano) ·", "被替换对象(逐镜头)·"], "the VLM's description of the shot ·": ["descrição do plano pelo VLM ·", "descripción del plano generada por el VLM ·", "VLM 对镜头的描述 ·"], "the setting picture (H3 Conditioning).": ["imagem de cenário (H3 Conditioning)。", "imagen del entorno (H3 Conditioning).", "场景参考图(H3 Conditioning)。"],
"At run time the planner asks the VLM about the shots it runs and applies the options below": ["Durante a execução, o Planner consulta o VLM sobre os planos processados e aplica as opções abaixo", "Durante la ejecución, el planificador consulta al VLM sobre los planos y aplica las opciones siguientes", "运行时,Planner 会让 VLM 分析要处理的镜头并应用以下选项"],
"Shots without a mask text or points get the VLM's segment suggestion": ["Planos sem texto de máscara ou pontos recebem uma sugestão de segmentação do VLM", "Los planos sin texto de máscara ni puntos reciben una sugerencia de segmentación del VLM", "没有蒙版文本或标记点的镜头会使用 VLM 的分割建议"],
"Write {shot} in a prompt: it becomes the VLM's description of that shot (camera, framing, action)": ["Use {shot} no prompt para inserir a descrição do plano pelo VLM (câmera, enquadramento e ação)", "Escribe {shot} en el prompt para insertar la descripción del plano generada por el VLM (cámara, encuadre y acción)", "在提示词中写入 {shot},即可插入 VLM 对镜头的描述(摄影机、构图和动作)"],
"Mask text for shots without one, skip where the VLM says skip": ["Texto de máscara para planos sem seleção; ignora os que o VLM indicar", "Texto de máscara para planos sin selección; omite los que el VLM marque", "为未设置选区的镜头填入蒙版文本,并跳过 VLM 建议跳过的镜头"],
"looks at every shot and suggests the mask, a description and whether to run it": ["analisa cada plano e sugere a máscara, uma descrição e se deve executá-lo", "analiza cada plano y sugiere la máscara, una descripción y si debe ejecutarse", "分析每个镜头并建议蒙版、描述以及是否运行"],
"Try another description: a new seed (and some temperature if it is 0), then describe again": ["Tentar outra descrição: usar uma nova seed (e aumentar a temperatura se estiver em 0) e descrever novamente", "Probar otra descripción: usar una nueva seed (y subir la temperatura si está en 0) y volver a describir", "重新生成描述:更换 seed(若温度为 0 则适当提高),再分析一次"],
"0 = always the same answer (greedy); 0.5-1 = varied answers, picked by the seed": ["0 = resposta sempre igual (greedy); 0,5–1 = respostas variadas, controladas pela seed", "0 = respuesta siempre igual (greedy); 0,5–1 = respuestas variadas según la seed", "0 = 始终生成相同答案(贪心);0.5–1 = 由 seed 控制的多样答案"],
"Used when the temperature is above 0: another seed, another description": ["Usada quando a temperatura é maior que 0: outra seed gera outra descrição", "Se usa cuando la temperatura es mayor que 0: otra seed genera otra descripción", "温度大于 0 时使用:更换 seed 可生成不同描述"],
"The VLM gets the global prompt's sentence that holds {details} (and the shot prompt's {target} sentence for 🧑 Describe target) with a blank in its place, and writes the description to fit it: the description adapts to the prompt, not the other way round.": ["O VLM recebe a frase do prompt global que contém {details} (ou a frase com {target} ao usar 🧑 Descrever alvo), com o marcador em branco, e escreve uma descrição que se encaixa nela.", "El VLM recibe la frase del prompt global que contiene {details} (o la frase con {target} al usar 🧑 Describir objetivo), con el marcador en blanco, y escribe una descripción que encaje.", "VLM 会读取包含 {details} 的全局提示词句子(使用 🧑 描述目标时则读取含 {target} 的镜头提示词),并在占位处生成适配的描述。"],
"Add a reference (global, per shot or per shot) first.": ["Adicione uma referência global ou por plano primeiro.", "Añade primero una referencia global o por plano.", "请先添加全局参考图或镜头参考图。"],
"✓ Nothing to fix in the shots that run.": ["✓ Nenhum ajuste necessário nos planos que serão executados.", "✓ No hay nada que corregir en los planos que se ejecutarán.", "✓ 将运行的镜头无需修正。"],
"Keep original video": ["Manter o vídeo original", "Conservar el vídeo original", "保留原视频"],
"Each run generates one shot and stores it. Nodes after BFS Shot Join only run on the last shot, with the full video.": ["Cada execução gera e salva um plano. Os nodes após BFS Shot Join só executam no último, com o vídeo completo.", "Cada ejecución genera y guarda un plano. Los nodos posteriores a BFS Shot Join solo se ejecutan en el último, con el vídeo completo.", "每次运行生成并保存一个镜头。BFS Shot Join 后的节点仅在最后一个镜头运行,并接收完整视频。"],
"go to this shot": ["ir para este plano", "ir a este plano", "跳转到此镜头"], "Pick a video in the Video tab.": ["Escolha um vídeo na aba Vídeo.", "Elige un vídeo en la pestaña Vídeo.", "请在 Video 标签页中选择视频。"],
"split this shot in two": ["dividir este plano em dois", "dividir este plano en dos", "将此镜头一分为二"], "merge with the next shot": ["unir ao próximo plano", "unir con el siguiente plano", "与下一个镜头合并"],
"go to this shot": ["ir para este plano", "ir a este plano", "跳转到此镜头"],
"Pick a video in the Video tab.": ["Escolha um vídeo na aba Vídeo.", "Elige un vídeo en la pestaña Vídeo.", "请在 Video 标签页中选择视频。"],
"split this shot in two": ["dividir este plano em dois", "dividir este plano en dos", "将此镜头一分为二"],
"merge with the next shot": ["unir ao próximo plano", "unir con el siguiente plano", "与下一个镜头合并"],
"Forget the recent pictures (the ones this plan still uses stay)": ["Esquecer as imagens recentes (as usadas neste plano serão mantidas)", "Olvidar las imágenes recientes (se conservan las que usa este plan)", "清除最近使用的图片(本计划仍在使用的图片会保留)"],
"Remove EVERY reference from this plan (global and all shots) and the recent list": ["Remover TODAS as referências deste plano (global e de todos os planos) e da lista recente", "Eliminar TODAS las referencias de este plan (globales y de todos los planos) y de la lista reciente", "从此计划(全局和所有镜头)及最近列表中移除所有参考图"],
"clear this shot's references and prompt: it uses the global ones": ["limpar as referências e o prompt deste plano para usar os globais", "borrar las referencias y el prompt de este plano para usar los globales", "清除此镜头的参考图和提示词,改用全局设置"],
"+ Mark a second region (optional)": ["+ Marcar uma segunda região (opcional)", "+ Marcar una segunda región (opcional)", "+ 标记第二个区域(可选)"], "Mark a second region · Ref 2": ["Marcar segunda região · Ref. 2", "Marcar segunda región · Ref. 2", "标记第二个区域 · 参考图 2"], "Describe second region": ["Descrever segunda região", "Describir la segunda región", "描述第二个区域"], "e.g. the person on the left": ["ex.: a pessoa à esquerda", "p. ej., la persona de la izquierda", "例如:左侧的人物"],
"mask: what to segment, in English (woman in pink top, black dog…) · Enter = preview": ["máscara: o que segmentar, em inglês (woman in pink top, black dog…) · Enter = prévia", "máscara: qué segmentar, en inglés (woman in pink top, black dog…) · Enter = vista previa", "蒙版:用英文描述分割对象(woman in pink top、black dog…)· Enter = 预览"],
"Pick positive / negative points on a frame of this shot (more precise than text when several people look alike)": ["Marque pontos positivos/negativos em um frame do plano (mais preciso que texto quando várias pessoas são parecidas)", "Marca puntos positivos/negativos en un fotograma del plano (más preciso que el texto si varias personas se parecen)", "在此镜头的一帧上标记正向/负向点(多人相似时比文本更准确)"],
"Segment this shot (the points win over the text)": ["Segmentar este plano (os pontos têm prioridade sobre o texto)", "Segmentar este plano (los puntos tienen prioridad sobre el texto)", "分割此镜头(标记点优先于文本)"], "Segment this shot with the text and show a few frames": ["Segmentar este plano pelo texto e mostrar alguns frames", "Segmentar este plano con el texto y mostrar algunos fotogramas", "根据文本分割此镜头并预览若干帧"],
"Colored Mark needs a region selection: enter a description, add points, or choose a mask video, then preview it.": ["A Marcação colorida precisa de uma região: digite uma descrição, adicione pontos ou escolha um vídeo de máscara e gere a prévia.", "La Marca de color necesita una región: escribe una descripción, añade puntos o elige un vídeo de máscara y genera la vista previa.", "彩色标记需要选定区域:输入描述、添加标记点或选择蒙版视频,然后预览。"],
"nothing found: the shot runs without a mask": ["nada encontrado: o plano será executado sem máscara", "no se encontró nada: el plano se ejecutará sin máscara", "未找到目标:此镜头将不使用蒙版运行"],
"The whole frame is regenerated. The mask, if any, only feeds {target} / the setting picture.": ["O frame inteiro é regenerado. Se houver máscara, ela só alimenta {target} ou a imagem de cenário.", "Se regenera todo el fotograma. Si hay máscara, solo se usa para {target} o la imagen del entorno.", "整帧都会重新生成。若有蒙版,仅用于 {target} 或场景参考图。"],
"What the NEW subject is, in English, for SAM 3 on the result: BFS Shot Join also pastes its outline (when it is bigger than the old one). E.g. person, dog, cat, car. Empty = person.": ["Descreva em inglês o NOVO sujeito no resultado para o SAM 3: o BFS Shot Join também cola seu contorno quando ele é maior que o antigo. Ex.: person, dog, cat, car. Vazio = person.", "Describe en inglés el NUEVO sujeto del resultado para SAM 3: BFS Shot Join también pega su silueta cuando supera la anterior. Ej.: person, dog, cat, car. Vacío = person.", "用英文描述结果中的新主体,供 SAM 3 使用:若新轮廓更大,BFS Shot Join 也会粘贴其轮廓。例如 person、dog、cat、car。留空 = person。"],
"Write {target} in the prompt: it becomes this description, so the model knows WHICH person to replace (useful with several people on screen).": ["Use {target} no prompt para inserir esta descrição e indicar ao modelo QUEM substituir (útil quando há várias pessoas na tela).", "Escribe {target} en el prompt para insertar esta descripción e indicar a quién debe sustituir el modelo (útil si hay varias personas).", "在提示词中使用 {target} 插入此描述,明确告诉模型要替换谁(画面中有多人时很有用)。"],
"Uses this shot's selection (🎯 Points… on the person — click the BODY, not only the face, so the outfit is described — or the mask text): SAM 3 cuts the person out and the VLM describes them": ["Usa a seleção deste plano (🎯 Pontos… no corpo, não apenas no rosto, para descrever a roupa; ou o texto da máscara): o SAM 3 recorta a pessoa e o VLM a descreve.", "Usa la selección de este plano (🎯 Puntos… en el cuerpo, no solo en la cara, para describir la ropa; o el texto de máscara): SAM 3 recorta a la persona y el VLM la describe.", "使用此镜头的选区(🎯 在人物身体上标点,不要只点脸,这样才能描述服装;或使用蒙版文本):SAM 3 抠出人物,VLM 生成描述。"],
"Skip this shot": ["Ignorar este plano", "Omitir este plano", "跳过此镜头"], "unparsed answer:": ["resposta não interpretada:", "respuesta sin procesar:", "未解析的回答:"],
"previous shot as reference": ["plano anterior como referência", "plano anterior como referencia", "将上一镜头作为参考图"], "previous shot as first frame": ["plano anterior como primeiro frame", "plano anterior como primer fotograma", "将上一镜头作为首帧"], "previous shot's end as history (long take)": ["final do plano anterior como histórico (plano contínuo)", "final del plano anterior como historial (toma continua)", "将上一镜头的结尾作为历史(连续长镜头)"],
"its first frame": ["seu primeiro frame", "su primer fotograma", "其首帧"], "its last frame": ["seu último frame", "su último fotograma", "其末帧"], "its middle frame": ["seu frame do meio", "su fotograma central", "其中间帧"],
"Pick a video in the Video tab.": ["Escolha um vídeo na aba Vídeo.", "Elige un vídeo en la pestaña Vídeo.", "请在 Video 标签页中选择视频。"],
"✓ copied to {…} shot(s)": ["✓ copiado para {…} plano(s)", "✓ copiado a {…} plano(s)", "✓ 已复制到 {…} 个镜头"], "show mask previews": ["mostrar prévias das máscaras", "mostrar vistas previas de las máscaras", "显示蒙版预览"],
"previous shot as reference": ["plano anterior como referência", "plano anterior como referencia", "将上一镜头作为参考图"],
"previous shot as first frame": ["plano anterior como primeiro frame", "plano anterior como primer fotograma", "将上一镜头作为首帧"],
"previous shot's end as history (long take)": ["final do plano anterior como histórico (plano contínuo)", "final del plano anterior como historial (toma continua)", "将上一镜头的结尾作为历史(连续长镜头)"],
"its first frame": ["seu primeiro frame", "su primer fotograma", "其首帧"],
"its last frame": ["seu último frame", "su último fotograma", "其末帧"],
"its middle frame": ["seu frame do meio", "su fotograma central", "其中间帧"],
"✓ copied to {…} shot(s)": ["✓ copiado para {…} plano(s)", "✓ copiado a {…} plano(s)", "✓ 已复制到 {…} 个镜头"],
"show mask previews": ["mostrar prévias das máscaras", "mostrar vistas previas de las máscaras", "显示蒙版预览"],
"max total seconds": ["duração total máxima", "duración total máxima", "总时长上限"],
"Remove every reference picture from this plan (global, every shot) and forget the recent list?": ["Remover todas as imagens de referência deste plano (global e de todos os planos) e limpar a lista recente?", "¿Eliminar todas las imágenes de referencia de este plan (globales y de todos los planos) y borrar la lista reciente?", "移除此计划中的所有参考图(全局及各镜头)并清空最近列表?"],
"Colored Mark": ["Marcação colorida", "Marca de color", "彩色标记"], "Default Colored Mark for this global reference": ["Cor padrão da Marcação colorida para esta referência global", "Color predeterminado de Marca de color para esta referencia global", "此全局参考图的默认彩色标记颜色"],
"write {details} in any prompt: each shot gets the description of its own references": ["Use {details} em qualquer prompt para inserir a descrição das referências de cada plano", "Escribe {details} en cualquier prompt para insertar la descripción de las referencias de cada plano", "在提示词中使用 {details},即可插入各镜头对应参考图的描述"],
"e.g. the person on the left": ["ex.: a pessoa à esquerda", "p. ej., la persona de la izquierda", "例如:左侧的人物"], "Keeps movement and expression visible.": ["Mantém os movimentos e as expressões visíveis.", "Mantiene visibles el movimiento y la expresión.", "保留动作和表情的可见度。"],
"What to segment, in English: a short phrase (noun + one or two traits), e.g. 'woman in pink top'; commas for several things. Enough on its own - no points needed: SAM 3 finds and tracks it through the shot (every match, up to Max objects). Press Enter to preview. Points, when set, take priority.": ["O que segmentar, em inglês: uma frase curta (substantivo e uma ou duas características), por exemplo, 'woman in pink top'; use vírgulas para vários objetos. Não precisa de pontos: o SAM 3 encontra e rastreia o objeto no plano (até Máx. objetos). Pressione Enter para pré-visualizar. Os pontos têm prioridade.", "Qué segmentar, en inglés: una frase breve (sustantivo y uno o dos rasgos), p. ej., 'woman in pink top'; usa comas para varios objetos. No hacen falta puntos: SAM 3 lo encuentra y sigue durante el plano (hasta Máx. objetos). Pulsa Enter para previsualizar. Los puntos tienen prioridad.", "用英文描述要分割的对象:简短短语(名词加一两个特征),例如“woman in pink top”;多个对象用逗号分隔。无需标记点,SAM 3 会在镜头中查找并跟踪(最多为最大对象数)。按 Enter 预览。设置了标记点时以标记点为准。"],
"Remove this shot's mask (points, text, mask video) and mode - for shots where it picked the wrong thing": ["Remover a máscara (pontos, texto ou vídeo) e o modo deste plano — útil quando a seleção está errada", "Quitar la máscara (puntos, texto o vídeo) y el modo de este plano; útil si seleccionó el objeto equivocado", "移除此镜头的蒙版(点、文本或蒙版视频)及模式;适用于选错对象时"],
"A black/white video (white = the subject), e.g. rotoscoped in another tool. It replaces SAM 3 for this shot and covers only this shot: its first frame is the shot's first frame.": ["Vídeo em preto e branco (branco = sujeito), por exemplo, rotoscopiado em outra ferramenta. Substitui o SAM 3 apenas neste plano; o primeiro frame do vídeo corresponde ao primeiro frame do plano.", "Vídeo en blanco y negro (blanco = sujeto), por ejemplo, rotoscopiado en otra herramienta. Sustituye SAM 3 solo en este plano; su primer fotograma corresponde al primer fotograma del plano.", "黑白蒙版视频(白色表示主体),例如在其他工具中制作的 rotoscoping。它仅在此镜头替代 SAM 3,视频首帧对应镜头首帧。"],
"— none: SAM 3 (text / points) —": ["— nenhum: SAM 3 (texto / pontos) —", "— ninguno: SAM 3 (texto / puntos) —", "— 无:SAM 3(文本/标记点)—"], "— none: the global mask video (People / Filters / Masks) —": ["— nenhum: vídeo de máscara global (Pessoas / Filtros / Máscaras) —", "— ninguno: vídeo de máscara global (Personas / Filtros / Máscaras) —", "— 无:全局蒙版视频(人物/筛选/蒙版)—"],
"upload a mask video for this shot": ["enviar um vídeo de máscara para este plano", "subir un vídeo de máscara para este plano", "为此镜头上传蒙版视频"], "back to SAM 3 / the global mask": ["voltar ao SAM 3 / máscara global", "volver a SAM 3 / máscara global", "切换回 SAM 3/全局蒙版"],
"upload a mask video for this shot": ["enviar um vídeo de máscara para este plano", "subir un vídeo de máscara para este plano", "为此镜头上传蒙版视频"],
"This shot uses its mask video: the text and points are ignored.": ["Este plano usa o vídeo de máscara; o texto e os pontos são ignorados.", "Este plano usa su vídeo de máscara; se ignoran el texto y los puntos.", "此镜头使用自己的蒙版视频,文本和标记点将被忽略。"],
"No mask of its own: this shot uses the global mask video (People / Filters / Masks tab).": ["Sem máscara própria: este plano usa o vídeo de máscara global (aba Pessoas / Filtros / Máscaras).", "Sin máscara propia: este plano usa el vídeo de máscara global (pestaña Personas / Filtros / Máscaras).", "没有单独蒙版:此镜头使用全局蒙版视频(People / Filters / Masks 标签页)。"],
"No mask of its own: this shot uses its part of the whole-video track (People / Filters / Masks tab).": ["Sem máscara própria: este plano usa sua parte do rastreamento do vídeo inteiro (aba Pessoas / Filtros / Máscaras).", "Sin máscara propia: este plano usa su parte del seguimiento de todo el vídeo (pestaña Personas / Filtros / Máscaras).", "没有单独蒙版:此镜头使用整段视频跟踪中对应的部分(People / Filters / Masks 标签页)。"],
"Opacity of the generation mask: the value of the white inside the mask (1 = fully white, the default). H3 reads a grey mask value as 'regenerate this much': at 0.85 the masked area starts at 85% of the noise and keeps ~15% of the original there. 0.8-0.9 keeps pose, outline and lighting while still swapping; too low copies the original person.": ["Opacidade da máscara de geração: valor do branco dentro da máscara (1 = branco total, padrão). O H3 interpreta o cinza como intensidade de regeneração: em 0,85, a região começa com 85% de ruído e preserva cerca de 15% do original. Valores de 0,8–0,9 mantêm pose, contorno e iluminação; valores muito baixos copiam a pessoa original.", "Opacidad de la máscara de generación: valor del blanco dentro de la máscara (1 = blanco total, predeterminado). H3 interpreta el gris como intensidad de regeneración: con 0,85, la zona empieza con un 85 % de ruido y conserva cerca del 15 % del original. Valores de 0,8–0,9 mantienen pose, silueta e iluminación; valores demasiado bajos copian a la persona original.", "生成蒙版的不透明度:蒙版白色区域的数值(1 = 全白,为默认值)。H3 将灰度解释为重绘强度:0.85 表示区域从 85% 噪声开始,约保留 15% 原图。0.8–0.9 可保留姿势、轮廓和光线;过低会复制原人物。"],
"keeps ~{…}% of the original inside the mask": ["preserva ~{…}% do original dentro da máscara", "conserva ~{…}% del original dentro de la máscara", "保留蒙版内约 {…}% 的原图"], "regenerates the masked area completely": ["regenera totalmente a região mascarada", "regenera por completo la zona enmascarada", "完全重绘蒙版区域"],
"set BFS Shot H3 Conditioning → inpaint to ": ["configure BFS Shot H3 Conditioning → inpaint como ", "configura BFS Shot H3 Conditioning → inpaint como ", "将 BFS Shot H3 Conditioning → inpaint 设置为 "],
"its outline on the result is pasted too, so a bigger silhouette is not cut": ["o contorno também é colado no resultado para não cortar uma silhueta maior", "también se pega su silueta en el resultado para no recortar una figura más grande", "结果中也会贴回其轮廓,避免裁掉更大的主体"],
"who is replaced, e.g. the young woman in a pink crop top (empty = the person)": ["quem será substituído, por exemplo, a jovem de top rosa (vazio = a pessoa)", "quién se sustituye, p. ej., la joven con top rosa (vacío = la persona)", "要替换谁,例如穿粉色短上衣的年轻女性(留空 = 人物)"],
"copy (the {shot} placeholder in the prompt gets it automatically at run time)": ["copiar (o marcador {shot} do prompt recebe isso durante a execução)", "copiar (el marcador {shot} del prompt lo inserta automáticamente al ejecutar)", "复制(运行时会自动填入提示词中的 {shot})"],
"SAM 3 mask (text or points), crop, {target}": ["Máscara SAM 3 (texto ou pontos), recorte, {target}", "Máscara SAM 3 (texto o puntos), recorte, {target}", "SAM 3 蒙版(文本或点)、裁剪、{target}"],
"How much of the previous result's end goes before this shot. 5 frames is a short context; longer windows carry more motion and use more memory.": ["Quantos frames finais do resultado anterior são colocados antes deste plano. 5 frames dão pouco contexto; janelas maiores carregam mais movimento e usam mais memória.", "Cuántos fotogramas finales del resultado anterior se colocan antes de este plano. 5 fotogramas dan poco contexto; ventanas más largas conservan más movimiento y usan más memoria.", "将上一结果的多少末尾帧放在此镜头之前。5 帧是较短上下文;更长窗口保留更多动作,也占用更多内存。"],
"Which frame of the previous shot's result": ["Qual frame do resultado do plano anterior", "Qué fotograma del resultado del plano anterior", "上一镜头结果中的哪一帧"],
"the first shot uses only its references": ["o primeiro plano usa apenas suas referências", "el primer plano solo usa sus referencias", "第一个镜头仅使用自己的参考图"],
@@ -294,17 +285,69 @@ const TEXT = {
"the points, on each shot's frame at the same relative position (a subject that stays in place)": ["os pontos em cada plano, na mesma posição relativa do frame (para um sujeito que permanece no lugar)", "los puntos en cada plano, en la misma posición relativa del fotograma (para un sujeto que no se desplaza)", "在每个镜头相同相对位置的帧上放置这些点(适用于位置不变的主体)"],
"the shot's mask video (it covers only its own shot: usually you want a different one per shot)": ["o vídeo de máscara do plano (cobre apenas este plano; normalmente cada plano precisa de um vídeo diferente)", "el vídeo de máscara del plano (solo cubre este plano; normalmente se usa uno distinto por plano)", "此镜头的蒙版视频(仅覆盖此镜头;通常每个镜头需要不同的视频)"],
"the generation mode (Full frame / Frame + paste / Mask only / Crop / Crop + mask) and its mask strength (shots with a mask)": ["o modo de geração (Quadro inteiro / Quadro + colar / Somente máscara / Recorte / Recorte + máscara) e a força da máscara", "el modo de generación (Fotograma completo / Fotograma + pegar / Solo máscara / Recorte / Recorte + máscara) y la intensidad de máscara", "生成模式(整帧/整帧+贴回/仅蒙版/裁剪/裁剪+蒙版)及蒙版强度"],
"the {target} description": ["a descrição de {target}", "la descripción de {target}", "{target} 的描述"], "continuity (not on the first shot)": ["continuidade (exceto no primeiro plano)", "continuidad (excepto en el primer plano)", "连续性(第一个镜头除外)"],
"shots after this": ["planos depois deste", "planos posteriores a este", "此镜头之后的镜头"], "replaces the old “→ all” buttons": ["substitui os antigos botões “→ todos”", "sustituye los antiguos botones “→ todos”", "替代旧的“→ 全部”按钮"],
"remove (use the global reference)": ["remover (usar a referência global)", "quitar (usar la referencia global)", "移除(使用全局参考图)"], "previous shot (←)": ["plano anterior (←)", "plano anterior (←)", "上一镜头(←)"],
"Override the content filters for this shot": ["Substituir os filtros de conteúdo neste plano", "Anular los filtros de contenido para este plano", "覆盖此镜头的内容筛选设置"],
"Colored Mark keeps two independent point sets; switch regions to edit each person. Both use the frame selected below.": ["Com Marcação colorida, cada pessoa tem seu próprio conjunto de pontos. Alterne as regiões para editar cada pessoa; ambas usam o frame selecionado abaixo.", "Marca de color mantiene un conjunto de puntos independiente por persona. Cambia de región para editar cada una; ambas usan el fotograma seleccionado abajo.", "彩色标记为每个人保留独立的点集。切换区域即可编辑;两组都使用下方选定的帧。"],
"Describe second region": ["Descreva a segunda região", "Describe la segunda región", "描述第二个区域"],
"🎯 Mark with points": ["🎯 Marcar com pontos", "🎯 Marcar con puntos", "🎯 用标记点选择"],
"🎯 Points 2": ["🎯 Pontos 2", "🎯 Puntos 2", "🎯 标记点 2"],
"Region 1 · color": ["Região 1 · cor", "Región 1 · color", "区域 1 · 颜色"],
"Region 2 · color": ["Região 2 · cor", "Región 2 · color", "区域 2 · 颜色"],
"click = keep (green) · right-click or shift+click = exclude (red) · click a point to remove it": ["clique = incluir (verde) · clique direito ou shift+clique = excluir (vermelho) · clique em um ponto para removê-lo", "clic = incluir (verde) · clic derecho o shift+clic = excluir (rojo) · clic en un punto para quitarlo", "单击 = 保留(绿色)· 右键或 shift+单击 = 排除(红色)· 单击标记点可删除"],
"the {target} description": ["a descrição de {target}", "la descripción de {target}", "{target} 的描述"],
"continuity (not on the first shot)": ["continuidade (exceto no primeiro plano)", "continuidad (excepto en el primer plano)", "连续性(第一个镜头除外)"],
"shots after this": ["planos depois deste", "planos posteriores a este", "此镜头之后的镜头"],
"previous shot (←)": ["plano anterior (←)", "plano anterior (←)", "上一镜头(←)"],
"📝 Pictures & prompt": ["📝 Imagens e prompt", "📝 Imágenes y prompt", "📝 图片和提示词"],
"Pictures & prompt": ["Imagens e prompt", "Imágenes y prompt", "图片和提示词"],
"every shot uses these unless it has its own": ["todos os planos usam estas, a menos que tenham as próprias", "todos los planos usan estas salvo que tengan las suyas", "所有镜头都使用这些,除非有自己的设置"],
"faded = the global ones (Pictures & prompt tab)": ["esmaecidas = as globais (aba Imagens e prompt)", "atenuadas = las globales (pestaña Imágenes y prompt)", "半透明 = 全局图片(图片和提示词标签页)"],
"Click + or drop images. Placeholders: ": ["Clique em + ou solte imagens. Marcadores: ", "Haz clic en + o suelta imágenes. Marcadores: ", "点击 + 或拖入图片。占位符:"],
" what changes (per shot) · ": [" o que muda (por plano) · ", " lo que cambia (por plano) · ", " 要改变的对象(逐镜头)· "],
" picture descriptions · ": [" descrições das imagens · ", " descripciones de las imágenes · ", " 图片描述 · "],
" the VLM's shot description · ": [" descrição do plano pelo VLM · ", " descripción del plano por el VLM · ", " VLM 的镜头描述 · "],
" the setting picture. A connected prompt input overrides this prompt.": [" a imagem de cenário. Uma entrada prompt conectada substitui este prompt.", " la imagen de escenario. Una entrada prompt conectada sustituye este prompt.", " 场景图片。连接的 prompt 输入会覆盖此提示词。"],
"What changes": ["O que muda", "Qué cambia", "要改变的内容"],
"the selection, its picture and how it is generated": ["a seleção, a imagem dela e como é gerada", "la selección, su imagen y cómo se genera", "选区、对应图片以及生成方式"],
"+ Add subject": ["+ Adicionar sujeito", "+ Añadir sujeto", "+ 添加对象"],
"add pictures above to give each subject its own": ["adicione imagens acima para dar uma a cada sujeito", "añade imágenes arriba para dar una a cada sujeto", "在上方添加图片,为每个对象各分配一张"],
"Colored Mark needs a selection: a text, points or a mask video.": ["A Marca colorida precisa de uma seleção: texto, pontos ou vídeo de máscara.", "La Marca de color necesita una selección: texto, puntos o vídeo de máscara.", "彩色标记需要选区:文本、标记点或蒙版视频。"],
"what to select, in English: woman in pink top, red car, black dog… (Enter = preview)": ["o que selecionar, em inglês: woman in pink top, red car, black dog… (Enter = prévia)", "qué seleccionar, en inglés: woman in pink top, red car, black dog… (Enter = vista previa)", "要选择的对象(英文):woman in pink top、red car、black dog…(回车 = 预览)"],
"what to select, e.g. the man on the left": ["o que selecionar, ex.: the man on the left", "qué seleccionar, p. ej. the man on the left", "要选择的对象,例如 the man on the left"],
"remove this subject": ["remover este sujeito", "quitar este sujeto", "移除此对象"],
"or a mask video": ["ou um vídeo de máscara", "o un vídeo de máscara", "或蒙版视频"],
"— none —": ["— nenhum —", "— ninguno —", "— 无 —"],
"— none: the whole-video mask —": ["— nenhum: a máscara do vídeo inteiro —", "— ninguno: la máscara de todo el vídeo —", "— 无:整段视频蒙版 —"],
"No selection of its own: this shot uses the whole-video mask (Mask settings, below).": ["Sem seleção própria: este plano usa a máscara do vídeo inteiro (Configurações de máscara, abaixo).", "Sin selección propia: este plano usa la máscara de todo el vídeo (Ajustes de máscara, abajo).", "没有单独选区:此镜头使用整段视频蒙版(见下方蒙版设置)。"],
"Generate": ["Gerar", "Generar", "生成"],
"The whole frame is regenerated.": ["O quadro inteiro é regenerado.", "Se regenera el fotograma entero.", "重新生成整个画面。"],
"The whole frame is generated; BFS Shot Join pastes only the subject back onto the original (background exact).": ["O quadro inteiro é gerado; o BFS Shot Join cola só o sujeito sobre o original (fundo exato).", "Se genera el fotograma entero; BFS Shot Join pega solo el sujeto sobre el original (fondo exacto).", "生成整个画面;BFS Shot Join 只把对象贴回原视频(背景不变)。"],
"Only the selection is regenerated; the rest of the frame stays exactly as it was.": ["Só a seleção é regenerada; o resto do quadro fica exatamente igual.", "Solo se regenera la selección; el resto del fotograma queda igual.", "只重新生成选区,画面其余部分保持不变。"],
"A box around the selection is regenerated at full size (more detail for a small subject), then pasted back.": ["Uma caixa em volta da seleção é regenerada em tamanho cheio (mais detalhe para um sujeito pequeno) e colada de volta.", "Se regenera una caja alrededor de la selección a tamaño completo (más detalle para un sujeto pequeño) y se pega de vuelta.", "以完整尺寸重新生成选区周围的框(小对象细节更多),再贴回原处。"],
"Inside the crop, only the selection is regenerated: the most detail, background kept.": ["Dentro do recorte, só a seleção é regenerada: o máximo de detalhe, com o fundo mantido.", "Dentro del recorte, solo se regenera la selección: el máximo detalle, con el fondo intacto.", "在裁剪框内只重新生成选区:细节最多,背景保留。"],
"what is replaced, for {target} in the prompt (e.g. the young woman in a pink top)": ["o que é substituído, para {target} no prompt (ex.: the young woman in a pink top)", "lo que se sustituye, para {target} en el prompt (p. ej. the young woman in a pink top)", "被替换的对象,用于提示词中的 {target}(例如 the young woman in a pink top)"],
"🧑 Describe": ["🧑 Descrever", "🧑 Describir", "🧑 描述"],
"select: ": ["selecionar: ", "seleccionar: ", "选择:"],
"Use": ["Usar", "Usar", "使用"],
"Mask settings (every shot)": ["Configurações de máscara (todos os planos)", "Ajustes de máscara (todos los planos)", "蒙版设置(所有镜头)"],
"SAM 3, crop and paste-back": ["SAM 3, recorte e colagem", "SAM 3, recorte y pegado", "SAM 3、裁剪和贴回"],
"grow the mask": ["aumenta a máscara", "amplía la máscara", "扩大蒙版"],
"less flicker": ["menos cintilação", "menos parpadeo", "减少闪烁"],
"matches of the text tracked": ["correspondências do texto rastreadas", "coincidencias del texto rastreadas", "跟踪的文本匹配数"],
"crop modes": ["modos de recorte", "modos de recorte", "裁剪模式"],
"Fill the selection text": ["Preencher o texto da seleção", "Rellenar el texto de selección", "填写选区文本"],
"suggests each shot's selection and a description": ["sugere a seleção e uma descrição de cada plano", "sugiere la selección y una descripción de cada plano", "为每个镜头建议选区和描述"],
"the text of {details} / {details@Picture n}": ["o texto de {details} / {details@Picture n}", "el texto de {details} / {details@Picture n}", "{details} / {details@Picture n} 的文本"],
"Connect a Qwen3-VL to the planner's vlm input and run the workflow once: then these buttons work.": ["Conecte um Qwen3-VL à entrada vlm do planner e execute o workflow uma vez: depois estes botões funcionam.", "Conecta un Qwen3-VL a la entrada vlm del planner y ejecuta el workflow una vez: entonces estos botones funcionan.", "把 Qwen3-VL 连接到 planner 的 vlm 输入并运行一次工作流,之后这些按钮即可使用。"],
"More: cut detector, frame grid, fps": ["Mais: detector de cortes, grade de frames, fps", "Más: detector de cortes, cuadrícula de fotogramas, fps", "更多:切点检测器、帧网格、fps"],
"only the first N shots": ["só os N primeiros planos", "solo los primeros N planos", "仅前 N 个镜头"],
"disabled": ["desativado", "desactivado", "已禁用"],
"Choose a video, set the range and split it into shots.": ["Escolha um vídeo, defina o trecho e divida em planos.", "Elige un vídeo, define el rango y divídelo en planos.", "选择视频,设置范围并拆分为镜头。"],
"Add the pictures and the prompt every shot uses. 🤖 Auto prompt can write the prompts for you.": ["Adicione as imagens e o prompt que todos os planos usam. O 🤖 Auto prompt pode escrever os prompts por você.", "Añade las imágenes y el prompt que usan todos los planos. 🤖 Auto prompt puede escribir los prompts por ti.", "添加所有镜头使用的图片和提示词。🤖 自动提示词可以替你写提示词。"],
"Check each shot: its pictures, what changes (a text or points), and how it is generated.": ["Revise cada plano: as imagens, o que muda (texto ou pontos) e como é gerado.", "Revisa cada plano: sus imágenes, qué cambia (texto o puntos) y cómo se genera.", "检查每个镜头:图片、要改变的内容(文本或标记点)以及生成方式。"],
"Colored Mark paints each selected subject with the color of the picture it becomes. Add one row per subject.": ["A Marca colorida pinta cada sujeito selecionado com a cor da imagem em que ele se transforma. Adicione uma linha por sujeito.", "La Marca de color pinta cada sujeto seleccionado con el color de la imagen en la que se convierte. Añade una fila por sujeto.", "彩色标记用目标图片的颜色涂抹每个选中的对象。每个对象添加一行。"],
"Fix the warnings, then Queue. Stored shots can be redone one by one.": ["Corrija os avisos e coloque na fila. Planos salvos podem ser refeitos um a um.", "Corrige los avisos y pon en cola. Los planos guardados se pueden rehacer uno a uno.", "修正警告后加入队列。已保存的镜头可以逐个重做。"],
"Frame + paste": ["Quadro + colagem", "Fotograma + pegado", "整帧 + 贴回"],
"Mask only": ["Só a máscara", "Solo la máscara", "仅蒙版"],
"Crop": ["Recorte", "Recorte", "裁剪"],
"Crop + mask": ["Recorte + máscara", "Recorte + máscara", "裁剪 + 蒙版"],
"runs": ["executa", "se ejecuta", "运行"],
"translucent": ["translúcida", "translúcida", "半透明"],
"hatching + contour": ["hachura + contorno", "rayado + contorno", "斜线 + 轮廓"],
"normal map (needs normals)": ["normal map (precisa de normals)", "normal map (necesita normals)", "法线贴图(需要 normals)"],
"Normal map: run MoGe on BFS Shot Unpack's frames (Render MoGe Geometry, normal_directx) and connect it to BFS Shot H3 Conditioning → normals. The preview shows a tint.": ["Normal map: rode o MoGe nos frames do BFS Shot Unpack (Render MoGe Geometry, normal_directx) e conecte em BFS Shot H3 Conditioning → normals. A prévia mostra só a cor.", "Normal map: ejecuta MoGe sobre los fotogramas de BFS Shot Unpack (Render MoGe Geometry, normal_directx) y conéctalo a BFS Shot H3 Conditioning → normals. La vista previa muestra solo el color.", "法线贴图:对 BFS Shot Unpack 的帧运行 MoGe(Render MoGe Geometry,normal_directx),并连接到 BFS Shot H3 Conditioning → normals。预览只显示颜色。"],
"📍 MolmoPoint is connected: a text selection is pointed by MolmoPoint, then SAM 3 tracks it.": ["📍 MolmoPoint conectado: a seleção por texto é apontada pelo MolmoPoint e o SAM 3 rastreia a partir daí.", "📍 MolmoPoint conectado: la selección por texto la señala MolmoPoint y SAM 3 la sigue.", "📍 已连接 MolmoPoint:文本选区先由 MolmoPoint 定位,再由 SAM 3 跟踪。"],
};
export function t(value) {
const s = String(value ?? ""), lang = uiLanguage.value;
@@ -312,12 +355,10 @@ export function t(value) {
const trimmed = s.trim(), entry = TEXT[s] || TEXT[trimmed];
if (entry) return `${s.slice(0, s.indexOf(trimmed))}${entry[{ pt: 0, es: 1, zh: 2 }[lang]]}${s.slice(s.indexOf(trimmed) + trimmed.length)}`;
let m;
if ((m = s.match(/^Person (\d+)$/))) return `${t("Person")} ${m[1]}`;
if ((m = s.match(/^Region (\d+) · color$/))) return `${lang === "pt" ? "Região" : lang === "es" ? "Región" : "区域"} ${m[1]} · ${lang === "zh" ? "颜色" : lang === "pt" ? "cor" : "color"}`;
if ((m = s.match(/^Region (\d+) \((\d+)\)$/))) return `${lang === "pt" ? "Região" : lang === "es" ? "Región" : "区域"} ${m[1]} (${m[2]})`;
if ((m = s.match(/^🎯 Points 2 \((\d+)\)$/))) return `🎯 ${lang === "pt" ? "Pontos" : lang === "es" ? "Puntos" : "标记点"} 2 (${m[1]})`;
if ((m = s.match(/^Subject (\d+)$/))) return `${lang === "pt" ? "Sujeito" : lang === "es" ? "Sujeto" : "对象"} ${m[1]}`;
if ((m = s.match(/^Mask strength (.+)$/))) return lang === "pt" ? `Força da máscara ${m[1]}` : lang === "es" ? `Fuerza de máscara ${m[1]}` : `蒙版强度 ${m[1]}`;
if ((m = s.match(/^opacity (.+)$/))) return lang === "pt" ? `opacidade ${m[1]}` : lang === "es" ? `opacidad ${m[1]}` : `不透明度 ${m[1]}`;
if ((m = s.match(/^Shot #(\d+)( · points)?$/))) return `${t("Shot")} #${m[1]}${m[2] ? ` · ${lang === "pt" ? "pontos" : lang === "es" ? "puntos" : "点"}` : ""}`;
if ((m = s.match(/^(\d+) people · (\d+) linked$/))) return lang === "pt" ? `${m[1]} pessoas · ${m[2]} vinculadas` : lang === "es" ? `${m[1]} personas · ${m[2]} vinculadas` : `${m[1]} 人物 · 已关联 ${m[2]} 人`;
if ((m = s.match(/^(\d+) shot\(s\) will run$/))) return lang === "pt" ? `${m[1]} plano(s) serão executados` : lang === "es" ? `se ejecutarán ${m[1]} plano(s)` : `将运行 ${m[1]} 个镜头`;
if ((m = s.match(/^(\d+) suggestions$/))) return lang === "pt" ? `${m[1]} sugestões` : lang === "es" ? `${m[1]} sugerencias` : `${m[1]} 条建议`;
if ((m = s.match(/^(\d+) shot\(s\)$/))) return lang === "pt" ? `${m[1]} plano(s)` : lang === "es" ? `${m[1]} plano(s)` : `${m[1]} 个镜头`;
@@ -335,12 +376,9 @@ export function t(value) {
if ((m = s.match(/^frame (\d+) · (.+)$/))) return lang === "pt" ? `frame ${m[1]} · ${m[2]}` : lang === "es" ? `fotograma ${m[1]} · ${m[2]}` : `第 ${m[1]} 帧 · ${m[2]}`;
if ((m = s.match(/^(\d+)\/(\d+) shots · ([\d.]+)s$/))) return lang === "pt" ? `${m[1]}/${m[2]} planos · ${m[3]} s` : lang === "es" ? `${m[1]}/${m[2]} planos · ${m[3]} s` : `${m[1]}/${m[2]} 个镜头 · ${m[3]} 秒`;
if ((m = s.match(/^(\d+) warning\(s\)$/))) return lang === "pt" ? `${m[1]} aviso(s)` : lang === "es" ? `${m[1]} aviso(s)` : `${m[1]} 条警告`;
if ((m = s.match(/^first seen (.+) \(click to seek\)$/))) return lang === "pt" ? `visto primeiro em ${m[1]} (clique para ir até lá)` : lang === "es" ? `primera aparición ${m[1]} (haz clic para ir)` : `首次出现于 ${m[1]}(点击跳转)`;
if ((m = s.match(/^(\d+) people · (\d+) included$/))) return lang === "pt" ? `${m[1]} pessoas · ${m[2]} incluídas` : lang === "es" ? `${m[1]} personas · ${m[2]} incluidas` : `${m[1]} 人物 · 已包含 ${m[2]} 人`;
if ((m = s.match(/^(\d+) point\(s\) at frame (\d+)$/))) return lang === "pt" ? `${m[1]} ponto(s) no frame ${m[2]}` : lang === "es" ? `${m[1]} punto(s) en el fotograma ${m[2]}` : `第 ${m[2]} 帧有 ${m[1]} 个标记点`;
if ((m = s.match(/^mask covers ([\d.]+)%(.+)$/))) return lang === "pt" ? `máscara cobre ${m[1]}%${m[2]}` : lang === "es" ? `la máscara cubre ${m[1]}%${m[2]}` : `蒙版覆盖 ${m[1]}%${m[2]}`;
if ((m = s.match(/^last (\d+) frames \(([\d.]+) s\)$/))) return lang === "pt" ? `últimos ${m[1]} frames (${m[2]} s)` : lang === "es" ? `últimos ${m[1]} fotogramas (${m[2]} s)` : `最后 ${m[1]} 帧(${m[2]} 秒)`;
if ((m = s.match(/^Mask opacity (.+)$/))) return lang === "pt" ? `Opacidade da máscara ${m[1]}` : lang === "es" ? `Opacidad de máscara ${m[1]}` : `蒙版不透明度 ${m[1]}`;
if ((m = s.match(/^Play or scrub the video \(or click the timeline\), then ⇤ \/ ⇥\. (\d+) frames \(([\d.]+) s\) of (\d+): only this part is split, analysed, generated and joined\.$/)))
return lang === "pt" ? `Reproduza ou avance o vídeo (ou clique na linha do tempo) e use ⇤ / ⇥. ${m[1]} frames (${m[2]} s) de ${m[3]}: somente este trecho será dividido, analisado, gerado e unido.` : lang === "es" ? `Reproduce o desplaza el vídeo (o haz clic en la línea de tiempo) y usa ⇤ / ⇥. ${m[1]} fotogramas (${m[2]} s) de ${m[3]}: solo se dividirá, analizará, generará y unirá este tramo.` : `播放或拖动视频(也可点击时间轴),然后按 ⇤ / ⇥。${m[1]} 帧(${m[2]} 秒),共 ${m[3]} 帧:仅拆分、分析、生成并合并此片段。`;
return s;
@@ -375,9 +413,7 @@ export const DEFAULTS = {
megapixels: 0.5, multiple: 32, generation_size_mode: "megapixels", custom_width: 1024, custom_height: 1024,
second_pass: { enabled: false, mode: "scale", scale: 2, width: 1280, height: 704, megapixels: 1, align: 32 },
detector: "adaptive", run: "recursive", between_shots: "keep", shot_cache: "output", shot_storage: "lossless", unload_before_join: true, auto_continue: true,
skip_fill: "original", audio_mode: "auto", ref_details: {}, picture_details: {}, auto_prompt: {}, auto_prompts: {}, mask_cfg: {}, vlm_cfg: {}, cast: {}, cast_split: false, cast_only: false,
filters: { person: false, min_person_area: 0, max_persons: 0, face: false, skip_dark: false, dark_level: 0.06,
skip_static: false, static_level: 0.004, min_frames: 0, samples: 6 },
skip_fill: "original", audio_mode: "auto", ref_details: {}, picture_details: {}, auto_prompt: {}, auto_prompts: {}, mask_cfg: {}, vlm_cfg: {},
};
// global mask settings (same defaults as the server's DEFAULT_MASK)
+1 -16
View File
@@ -125,10 +125,6 @@ const CSS = `
.bsl .thumbs{display:flex;gap:4px;margin-top:4px}
.bsl .rt{width:34px;height:34px;border-radius:5px;object-fit:cover;background:#2a2a33;border:1px solid #3a3a45}
.bsl .rt.ph2{display:flex;align-items:center;justify-content:center;color:#666;font-size:9px}
.bsl .who{display:flex;gap:3px;margin-top:3px;align-items:center}
.bsl .who img{width:22px;height:22px;border-radius:50%;object-fit:cover;border:1px solid #3a3a45;opacity:.7}
.bsl .who img.main{width:26px;height:26px;opacity:1;border-color:#fff}
.bsl .who img.lk{border-color:#8fd18f}
/* shot editor */
.bsl .edhd{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:6px}
@@ -157,12 +153,6 @@ const CSS = `
.bsl .recent img{width:30px;height:30px;border-radius:5px;object-fit:cover;cursor:pointer;border:1px solid #3a3a45}
.bsl .recent img:hover{border-color:var(--acc)}
.bsl .recent.sm img{width:22px;height:22px}
.bsl .modes{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:6px;margin-bottom:6px}
.bsl .mode{border:1px solid var(--line2);border-radius:7px;padding:6px 8px;cursor:pointer;background:#18181d}
.bsl .mode:hover{border-color:#55556a}
.bsl .mode.on{border-color:var(--acc);background:#1a2034}
.bsl .mode b{display:block;color:var(--hi);font-size:11px}
.bsl .mode span{font-size:10px;color:var(--txt2)}
.bsl .mstrip{display:flex;gap:4px;margin-top:6px;align-items:center;flex-wrap:wrap}
.bsl .mstrip img{height:90px;border-radius:5px;border:1px solid #3a3a45}
.bsl .vsug{margin-top:6px;padding:6px 8px;border:1px dashed #4a4a58;border-radius:6px;display:flex;flex-direction:column;gap:3px}
@@ -174,12 +164,7 @@ const CSS = `
.bsl .tc{font-family:ui-monospace,monospace;font-size:12px;color:#e6e6ea;background:#101014;border:1px solid var(--line);border-radius:6px;padding:6px 8px;line-height:1.6}
.bsl .tc b{color:#ffd34d}
/* cast, VLM, checks */
.bsl .cast{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px}
.bsl .person{background:#141418;border:1px solid var(--line);border-radius:8px;padding:6px;display:flex;flex-direction:column;gap:4px;align-items:flex-start}
.bsl .person.ign{opacity:.45}
.bsl .person .face{width:64px;height:64px;border-radius:50%;object-fit:cover;cursor:pointer;border:2px solid #3a3a45}
.bsl .person .t{font-size:10px;color:#9a9aa6}
/* VLM, checks */
.bsl .dlist{display:flex;flex-direction:column;gap:6px;margin-top:6px}
.bsl .drow{display:flex;gap:8px;align-items:flex-start}
.bsl .dthumbs{display:flex;gap:3px}
+9 -10
View File
@@ -26,7 +26,7 @@ export function pointsModal(c) {
? Array.from({ length: regionsN }, (_, q) => q + 1).flatMap(region => (region === modal.region ? modal.points : modal.groups[region]?.points || []).map((p, k) => h("div", {
class: ["pt", "cg", region !== modal.region && "other", p.label ? "pos" : "neg"],
style: `left:${p.x * 100}%;top:${p.y * 100}%;--point-color:${regionColor(region)}`,
title: `Region ${region} · Picture ${regionPicture(region)}${region === modal.region ? " · click to remove" : ""}`,
title: `Subject ${region} · Picture ${regionPicture(region)}${region === modal.region ? " · click to remove" : ""}`,
onPointerdown: e => { if (region !== modal.region) return; e.stopPropagation(); modal.points = modal.points.filter((_, j) => j !== k); modal.prev = null; },
})))
: modal.points.map((p, k) => h("div", { class: ["pt", p.label ? "pos" : "neg"], style: `left:${p.x * 100}%;top:${p.y * 100}%`,
@@ -34,15 +34,15 @@ export function pointsModal(c) {
const regionButton = r => {
const k = regionPicture(r), name = ms.names[k - 1];
return h("button", { class: modal.region === r ? "pri" : "", onClick: () => c.switchPointRegion(r),
title: `Region ${r}: becomes <Picture ${k}> (${markName(regionColor(r))})` }, [
title: `Subject ${r} becomes <Picture ${k}> (${markName(regionColor(r))})` }, [
name ? h("img", { class: "mthumb", src: c.viewUrl(name), style: `--mark:${regionColor(r)}` }) : null,
` Picture ${k} · ${markName(regionColor(r))} (${(r === modal.region ? modal.points : modal.groups[r]?.points || []).length})`]);
` ${r} → Picture ${k} · ${markName(regionColor(r))} (${(r === modal.region ? modal.points : modal.groups[r]?.points || []).length})`]);
};
return h(Teleport, { to: "body" }, h("div", { class: "bsl", style: "background:none;padding:0" }, h("div", { class: "mmodal",
onKeydown: e => e.stopPropagation(), onPointerdown: e => { if (e.target === e.currentTarget) modal.open = false; } }, [
h("div", { class: "mbox" }, [
h("div", { class: "row", style: "margin-bottom:6px" }, [h("span", { class: "sect" }, `Shot #${modal.idx + 1} · points`),
ms.color_guide ? h("div", { class: "row" }, [hint("One point set per marked person; switch to edit each. All use the frame selected below."),
ms.color_guide ? h("div", { class: "row" }, [hint("One point set per subject: switch to edit each. All use the frame below."),
...Array.from({ length: regionsN }, (_, q) => regionButton(q + 1))]) : null,
hint("click = keep · right-click or shift+click = exclude · click a point to remove it")]),
h("div", { class: "mstage" }, [
@@ -85,12 +85,11 @@ export function pointsModal(c) {
}
const TOUR_STEPS = [
["video", "Video", "Choose a video, set the frame range, split at camera cuts, then set the generation size."],
["shots", "Shots", "Review each shot, assign its references and prompt, and choose how the result is applied."],
["people", "People", "Track detected face identities to filter or split shots. Set masks here or per shot."],
["prompts", "Prompts", "Set shared references, prompts and optional VLM assistance. Written prompts are kept exactly as authored."],
["shots", "Colored Mark", "Colored Mark adds a translucent color overlay to a selected region in the source video. It does not enable inpainting. Choose the color before marking the region with points or a description."],
["run", "Run", "Check warnings, choose auto or queue loop, and set filters or quick-run limits."],
["video", "Video", "Choose a video, set the range and split it into shots."],
["prompts", "Pictures & prompt", "Add the pictures and the prompt every shot uses. 🤖 Auto prompt can write the prompts for you."],
["shots", "Shots", "Check each shot: its pictures, what changes (a text or points), and how it is generated."],
["shots", "Colored Mark", "Colored Mark paints each selected subject with the color of the picture it becomes. Add one row per subject."],
["run", "Run", "Fix the warnings, then Queue. Stored shots can be redone one by one."],
];
export function tourModal(c) {
-107
View File
@@ -1,107 +0,0 @@
// 👥 People / Filters / Masks tab: cast, shot filters, and global SAM 3 mask settings.
import { h, fmtT, fld, check, section, pill, hint } from "./common.js";
function castSection(c) {
const { plan, people, busy } = c;
const fps = plan.fps;
const castN = people.value.filter(p => c.included(p.id)).length;
const opt = (k, label, title) => check(plan[k], v => c.setCastOpt(k, v), label, title);
return section("Cast", [
h("div", { class: "row", style: "gap:14px;margin-bottom:6px" }, [
opt("cast_split", "Split where the main person changes", "adds a boundary when the biggest face switches to someone else (re-splits the plan)"),
opt("cast_only", "Only run shots with a detected person", "shots with no detected, non-ignored person are skipped"),
]),
people.value.length ? h("div", { class: "cast" }, people.value.map(p => {
const cc = c.castOf(p.id);
return h("div", { class: ["person", cc.ignore && "ign"] }, [
h("img", { class: "face", src: p.thumb, title: `first seen ${fmtT(p.first, fps)} (click to seek)`, onClick: () => c.seek(p.first) }),
h("div", { class: "t" }, [h("b", `Person ${p.id}`), ` · ${(p.share * 100).toFixed(1)}%`]),
h("div", { class: "t" }, `${fmtT(p.first, fps)} → ${fmtT(p.last, fps)}`),
check(cc.ignore, v => c.setCast(p.id, "ignore", v), "ignore"),
]);
})) : h("div", { class: "hint" }, "Nobody found yet."),
h("div", { class: "row", style: "margin-top:8px" }, [
h("button", { class: "pri", disabled: !!busy.value, onClick: () => c.findPeople(true) }, people.value.length ? "↻ Re-analyse people" : "👥 Find people"),
hint(people.value.length ? "faces sampled every 0.5 s and grouped by identity (InsightFace, CPU)" : "detects faces across the video and groups them into people"),
]),
], { sub: "track face identities to filter or split shots",
right: people.value.length ? pill(`${people.value.length} people · ${castN} included`) : pill("not analysed") });
}
function maskSection(c) {
const { plan, segs, showMasks } = c;
const MC = plan.mask_cfg || {};
const mnum = (k, step, label, help, scale = 1) => fld(label, h("input", { type: "number", step, min: 0, value: +(MC[k] * scale).toFixed(3),
onChange: e => c.setMaskCfg(k, (parseFloat(e.target.value) || 0) / scale) }), help);
const S = segs.value;
const counts = { mask: 0, crop: 0, cropmask: 0, paste: 0 };
S.forEach(s => { const m = c.modeOf(s); if (m in counts) counts[m]++; });
const masked = S.filter(s => s.mask?.text || (s.mask?.points || []).length || s.mask?.video || s.extMask).length;
return section("Mask & crop settings", [
h("div", { class: "note", style: "margin-bottom:8px" }, [
"Each shot picks what it marks (🎯 text or points, or 🎞 its own mask video) and how it is generated (", h("b", "Full frame · Frame + paste · Mask only · Crop · Crop + mask"),
") in the Shots tab. Masks from a video (here, per shot, or the planner's ", h("b", "mask"), " input) replace SAM 3. These settings apply to every shot."]),
h("div", { class: "row", style: "margin-bottom:8px;align-items:center" }, [
h("span", { style: "white-space:nowrap", title: "A black/white video of the WHOLE source video (white = the subject), e.g. rotoscoped in another tool. Shots without their own mask (video, text or points) use it instead of SAM 3. The planner's optional mask input does the same from the graph and wins over this." }, "🎞 Mask video (whole video)"),
h("select", { value: plan.mask_video || "", style: "flex:1;min-width:180px", onChange: e => c.setGlobalMaskVideo(e.target.value) },
[h("option", { value: "" }, "— none: SAM 3 per shot —"), ...c.files.videos.map(v => h("option", { value: v }, v))]),
h("button", { onClick: () => c.pickFile("video/*", c.setGlobalMaskVideo) }, "⬆ Upload"),
plan.mask_video ? h("button", { onClick: () => c.setGlobalMaskVideo("") }, "✕") : null,
]),
plan.track?.points?.length ? h("div", { class: "note", style: "margin-bottom:8px;display:flex;gap:8px;align-items:center" }, [
h("span", { class: "grow" }, [h("b", "🎯 Whole-video track: "), `${plan.track.points.length} point(s) at frame ${plan.track.frame}`,
plan.track.text ? ` · re-found by "${plan.track.text}" after cuts` : " · no text: after a cut the subject may be lost (add one in 🎯 Points…)",
". Shots without their own selection use it."]),
h("button", { class: "ghost", onClick: c.clearTrack }, "✕ remove"),
]) : null,
h("div", { class: "row", style: "gap:14px;margin-bottom:6px" }, [
check(MC.fill_holes, v => c.setMaskCfg("fill_holes", v), "Fill holes", "Fill enclosed gaps so each region is solid"),
check(MC.invert, v => c.setMaskCfg("invert", v), "Invert", "Use everything except the segmented object"),
check(showMasks.value, v => { showMasks.value = v; }, "Show masks on shots", "Overlay the mask preview on the shot cards"),
]),
h("div", { class: "grid" }, [
mnum("expand", 1, "Expand (px)", "grow the mask (paste-back and generation mask)"),
mnum("temporal_expand", 1, "Temporal expand (frames)", "hold the mask a few frames: less flicker"),
mnum("threshold", 0.05, "Detection threshold", "SAM 3 score to keep an object"),
mnum("max_objects", 1, "Max objects", "how many matches of the text are tracked"),
mnum("padding", 1, "Crop padding (%)", "Crop modes: context around the mask's box", 100),
mnum("feather", 1, "Feather (px)", "Crop modes: soft edge of the paste"),
fld("Paste back", h("select", { value: MC.paste, onChange: e => c.setMaskCfg("paste", e.target.value) },
[h("option", { value: "mask" }, "only the mask (feathered)"), h("option", { value: "box" }, "the whole box (feathered)")]), "Crop modes"),
mnum("blockify", 1, "Blockify (px, 0 = off)", "square blocks; 16 matches H3's latent grid"),
]),
hint("Uses the official sam3.1_multiplex_fp16 checkpoint (models/checkpoints), downloaded from Comfy-Org/sam3.1 the first time.", "display:block;margin-top:6px"),
], { sub: "SAM 3",
right: h("span", { class: "row" }, [pill(`${masked} masked`), counts.mask ? pill(`${counts.mask} mask only`, "acc") : null, counts.paste ? pill(`${counts.paste} frame+paste`, "acc") : null,
counts.crop + counts.cropmask ? pill(`${counts.crop + counts.cropmask} cropped`, "warn") : null]) });
}
function filtersSection(c) {
const { plan, stats, segs, busy, filtersOn } = c;
const F = plan.filters;
const chk = (k, label) => check(F[k], v => c.setFilter(k, v), label);
const fnum = (k, step, label, help) => fld(label, h("input", { type: "number", step, min: 0, value: F[k], onChange: e => c.setFilter(k, parseFloat(e.target.value) || 0) }), help);
const skippedN = segs.value.filter(s => c.skipWhy(s)).length;
return section("Shot filters", [
h("div", { class: "row", style: "gap:14px;margin-bottom:6px" }, [
chk("person", "Needs a person"), chk("face", "Needs a face"), chk("skip_dark", "Skip dark / fades"), chk("skip_static", "Skip static shots"),
]),
h("div", { class: "grid" }, [
fnum("min_person_area", 0.01, "Min person size (0-1 of frame)", "e.g. 0.03 skips wide shots"),
fnum("max_persons", 1, "Max people (0 = any)", "skip crowds"),
fnum("min_frames", 1, "Min frames (0 = off)"),
fnum("samples", 1, "Frames sampled / shot"),
fnum("dark_level", 0.01, "Dark below (0-1)"),
fnum("static_level", 0.001, "Static below"),
]),
h("div", { class: "row", style: "margin-top:8px" }, [
h("button", { class: "pri", disabled: !!busy.value, onClick: c.analyzeContent }, "👤 Analyse people & faces"),
hint(stats.value.length ? `stats for ${stats.value.length} shots · YOLO person/face from models/ultralytics` : "runs the detectors on a few frames of every shot"),
]),
], { open: filtersOn.value || stats.value.length > 0, sub: "choose which shots run",
right: filtersOn.value ? pill(`${skippedN} skipped`, "warn") : pill("off") });
}
export function peopleTab(c) {
return h("div", { class: "card" }, [castSection(c), filtersSection(c), maskSection(c)]);
}
+12 -14
View File
@@ -1,5 +1,5 @@
// 📝 Prompts & refs tab: the global reference / prompt, the VLM (shot suggestions) and the reference descriptions ({details}).
import { h, fld, check, section, pill, hint, select, refBox, promptChips, MAX_REFS, MARK_PALETTE, pictureColor } from "./common.js";
// 📝 Pictures & prompt tab: the pictures and prompt every shot uses, 🤖 Auto prompt, and the VLM helpers (collapsed).
import { h, fld, check, section, pill, hint, select, refBox, promptChips, MARK_PALETTE, pictureColor } from "./common.js";
const PRESETS = ["short", "object", "full body", "head / face", "face attributes", "outfit", "scene / setting", "custom"];
@@ -12,7 +12,7 @@ function globalSection(c) {
onChange: e => c.setPictureColor(k + 1, e.target.value) },
MARK_PALETTE.map(([v, name]) => h("option", { value: v }, name))),
]);
return section("Global reference & prompt", [
return section("Pictures & prompt", [
refBox(c, "global", plan.global_refs, [], marker),
h("div", { class: "pbox", style: "margin-top:6px" }, [
h("textarea", { style: "min-height:78px", placeholder: plan.auto_prompt?.enabled
@@ -22,12 +22,10 @@ function globalSection(c) {
promptChips(c, plan.global_refs.filter(Boolean), v => c.setPlan("global_prompt", v)),
]),
h("div", { class: "hint", style: "margin-top:4px" }, [
`Used by shots without their own (up to ${MAX_REFS} pictures, <Picture 1> first; click + or drop image files on the box). A connected prompt input on the node overrides the global prompt. Placeholders: `,
h("b", "{target}"), " who is replaced (per shot) · ", h("b", "{details}"), " description of the shot's references · ",
h("b", "{details@Picture n}"), " description of picture n alone · ",
h("b", "{shot}"), " the VLM's description of the shot · ", h("b", "{setting}"), " the setting picture (H3 Conditioning)."]),
h("div", { class: "hint", style: "margin-top:4px" }, "Mark color: the Colored Mark color of each picture. A person marked for <Picture n> in a shot is drawn in that color, and the prompt says which picture the color becomes. Change it when it is too close to a color in the video."),
], { sub: "defaults for every shot" });
"Click + or drop images. Placeholders: ", h("b", "{target}"), " what changes (per shot) · ", h("b", "{details}"), " / ",
h("b", "{details@Picture n}"), " picture descriptions · ", h("b", "{shot}"), " the VLM's shot description · ",
h("b", "{setting}"), " the setting picture. A connected prompt input overrides this prompt."]),
], { sub: "every shot uses these unless it has its own" });
}
function vlmSection(c) {
@@ -39,7 +37,7 @@ function vlmSection(c) {
section("VLM", [
h("div", { class: "row", style: "gap:14px;margin-bottom:6px" }, [
opt("enabled", "Use the VLM when the workflow runs", "At run time the planner asks the VLM about the shots it runs and applies the options below"),
opt("auto_segment", "Fill the mask text", "Shots without a mask text or points get the VLM's segment suggestion"),
opt("auto_segment", "Fill the selection text", "Shots without a selection text or points get the VLM's suggestion"),
opt("auto_shot", "Fill {shot} in the prompt", "Write {shot} in a prompt: it becomes the VLM's description of that shot (camera, framing, action)"),
]),
h("div", { class: "grid" }, [
@@ -53,10 +51,10 @@ function vlmSection(c) {
value: VC.instruction || "", onChange: e => c.setVlmCfg("instruction", e.target.value) }),
h("div", { class: "row", style: "margin-top:6px" }, [
h("button", { class: "pri", disabled: !!busy.value, onClick: c.analyseVLM }, "🤖 Analyse shots"),
h("button", { disabled: !sugN, onClick: c.applyAllSug, title: "Mask text for shots without one, skip where the VLM says skip" }, "Apply suggestions → all"),
h("button", { disabled: !sugN, onClick: c.applyAllSug, title: "The suggested selection text, on every shot without one" }, "Apply suggestions → all"),
]),
hint("Connect a Qwen3-VL (CLIPLoader) to the planner's vlm input. The VLM buttons work after the workflow has run once with it connected (ComfyUI only hands models to nodes when they run). Each shot's suggestion shows in its editor.", "display:block;margin-top:4px"),
], { sub: "looks at every shot and suggests the mask, a description and whether to run it",
hint("Connect a Qwen3-VL to the planner's vlm input and run the workflow once: then these buttons work.", "display:block;margin-top:4px"),
], { open: false, sub: "suggests each shot's selection and a description",
right: h("span", { class: "row" }, [VC.enabled ? pill("on", "ok") : pill("off"), sugN ? pill(`${sugN} suggestions`) : null]) }),
section("Describe references ({details})", [
h("div", { class: "row", style: "gap:8px" }, [
@@ -101,7 +99,7 @@ function vlmSection(c) {
]),
]);
})) : hint("Add a reference (global or per shot) first."),
], { sub: "{details} = picture 1, {details2} = picture 2, {details@Picture n} = picture n: each shot gets the descriptions of its own pictures" }),
], { open: false, sub: "the text of {details} / {details@Picture n}" }),
];
}
+2 -2
View File
@@ -69,7 +69,7 @@ function memorySection(c) {
warn: "⚠ Much bigger than anything that ran here: it may run out of memory. Lower Megapixels or Max seconds / shot, use head 5, or set Between shots = free memory.",
info: "No shot has run on this GPU yet: after the first one, every shot is compared with what already ran here." }[m.level],
"display:block;margin-top:4px"),
], { sub: { ok: "within what ran here", note: "a bit bigger", warn: "may run out of memory", info: "no data yet" }[m.level] });
], { open: m.level === "warn", sub: { ok: "within what ran here", note: "a bit bigger", warn: "may run out of memory", info: "no data yet" }[m.level] });
}
function cacheSection(c) {
@@ -137,7 +137,7 @@ function secondPassSection(c) {
hint("The planner outputs second_pass (on/off), upscale_width and upscale_height. Upscale the denoised latent of each shot "
+ "to that size (e.g. Minimax H3 Latent Upscaler (3D) in target dimensions mode) and sample it again before decoding; "
+ "use second_pass to switch that branch.", "display:block;margin-top:6px"),
], { sub: sp.enabled ? `on · ${out.w}×${out.h}` : "off" });
], { open: !!sp.enabled, sub: sp.enabled ? `on · ${out.w}×${out.h}` : "off" });
}
export function runTab(c) {
+129 -100
View File
@@ -1,21 +1,21 @@
// 🎬 Shots tab: one card per shot, then the selected shot's editor (preview, reference & prompt, who is replaced,
// continuity, copy to other shots).
import { h, hue, fmtT, segKey, hasMask, maskSource, pill, hint, check, section, refBox, promptChips, pictureColor, markName,
MAX_REFS } from "./common.js";
// 🎬 Shots tab: one card per shot, then the selected shot's editor (pictures & prompt, what changes, continuity, copy
// to other shots), then the mask settings shared by every shot.
import { h, hue, fmtT, segKey, hasMask, maskSource, pill, hint, check, section, select, fld, refBox, promptChips, pictureColor,
markName, MAX_REFS } from "./common.js";
import { h as rawH } from "../vendor/vue.esm-browser.prod.mjs";
const bdg = (text, kind = "", title = "") => h("span", { class: ["bdg", kind], title }, text);
function shotCard(c, s, i) {
const { plan, sel, maskPrev, showMasks } = c;
const fps = plan.fps, why = c.skipWhy(s), who = c.whoIn(s), st = c.statFor(s), issues = c.checksFor(i);
const fps = plan.fps, why = c.skipWhy(s), issues = c.checksFor(i);
const warnN = issues.filter(x => x.lvl === "warn").length;
const mp = maskPrev.value[segKey(s)];
return h("div", { class: ["sc", i === sel.value && "sel", why && "skip"], onClick: () => { sel.value = i; } }, [
h("button", { class: "play", title: "play this shot", onClick: e => { e.stopPropagation(); c.playShot(i); } }, "▶"),
h("div", { class: "bar", style: `background:${hue(i)}` }),
h("div", { class: "row" }, [h("b", `#${i + 1}`), s.cut ? pill("cut") : null,
why ? pill("skip", "warn", why) : pill("run", "ok"), s.force !== "auto" ? pill(s.force) : null]),
why ? pill("off", "warn", why) : pill("run", "ok")]),
h("div", { class: "badges" }, [
{ video: bdg("🎞 mask video", "", s.mask.video), global: bdg("🎞 global mask", "", "the plan's mask video for the whole video"),
track: bdg("🎯 track", "", "its part of the whole-video SAM 3 track"),
@@ -29,12 +29,6 @@ function shotCard(c, s, i) {
run: bdg("⏳", "", "not stored yet, or its settings changed: the next Queue generates it") }[c.cacheOf(s)] || null,
warnN ? bdg(`⚠ ${warnN}`, "warn", issues.map(x => x.text).join("\n")) : null,
]),
who ? h("div", { class: "who" }, who.people.length ? who.people.map(id => h("img", {
src: c.personOf(id)?.thumb || "", title: `Person ${id}${id === who.main ? " (main)" : ""}${c.included(id) ? " · included" : ""}`,
class: [id === who.main && "main", c.included(id) && "lk"] })) : [h("span", { class: "t" }, "no faces")]) : null,
st ? h("div", { class: "t", style: "margin-top:2px" },
`👤 ${st.persons} · ${(st.person_area * 100).toFixed(1)}% · 🙂 ${st.faces} · ☀ ${(st.brightness * 100).toFixed(0)}%`) : null,
why ? h("div", { class: "t", style: "color:#ffc46b" }, why) : null,
h("div", { class: "t" }, `${fmtT(s.start, fps)} → ${fmtT(s.end, fps)} · ${s.len}f → ${s.gen}f`),
h("div", { class: "thumbs" }, [
...(s.names.length ? s.names.slice(0, 3).map((name, k) => h("img", { class: "rt", src: c.viewUrl(name),
@@ -83,12 +77,9 @@ function editor(c, cur) {
h("button", { class: "ghost", disabled: i === 0, title: "previous shot (←)", onClick: () => c.select(i - 1) }, "◀"),
h("span", { class: "big" }, `Shot #${i + 1}`),
h("button", { class: "ghost", disabled: i >= S.length - 1, title: "next shot (→)", onClick: () => c.select(i + 1) }, "▶"),
c.skipWhy(cur) ? pill(`skip · ${c.skipWhy(cur)}`, "warn") : pill("runs", "ok"),
c.skipWhy(cur) ? pill("disabled", "warn") : pill("runs", "ok"),
hint(`frames ${cur.start}–${cur.end - 1} · ${cur.len} → generate ${cur.gen}`),
h("span", { class: "grow" }),
h("select", { value: cur.force, style: "width:auto", title: "Override the content filters for this shot",
onChange: e => c.setMeta(i, "force", e.target.value) },
[h("option", { value: "auto" }, "filters decide"), h("option", { value: "run" }, "always run"), h("option", { value: "skip" }, "always skip")]),
h("button", { onClick: () => c.setMeta(i, "enabled", !cur.enabled) }, cur.enabled ? "⏸ Disable" : "▶ Enable"),
h("button", { title: "split this shot in two", onClick: () => c.splitAt(cur.start + Math.floor(cur.len / 2)) }, "✂ Split"),
h("button", { disabled: i >= S.length - 1, title: "merge with the next shot", onClick: () => c.mergeNext(i) }, "⇥ Merge"),
@@ -97,7 +88,7 @@ function editor(c, cur) {
const problems = warnsHere.length ? h("div", { class: "checks", style: "margin-bottom:6px" }, warnsHere.map(x =>
h("div", { class: "ck" }, [h("span", { class: "lvl" }, x.lvl === "warn" ? "⚠" : "ℹ"), h("span", x.text)]))) : null;
const refPrompt = section("Reference & prompt", [
const refPrompt = section("Pictures & prompt", [
refBox(c, i, cur.refs, plan.global_refs),
h("div", { class: "pbox", style: "margin-top:6px" }, [
h("textarea", { placeholder: plan.auto_prompt?.enabled
@@ -127,128 +118,128 @@ function editor(c, cur) {
h("button", { class: "sm", title: "Forget the recent pictures (the ones this plan still uses stay)", onClick: () => c.clearRecent() }, "✕ clear"),
h("button", { class: "sm dng", title: "Remove EVERY reference from this plan (global and all shots) and the recent list", onClick: () => c.clearAllRefs() }, "✕ clear all")]) : null,
h("div", { class: "row", style: "margin-top:6px" }, [
h("button", { class: "dng", title: "clear this shot's references and prompt: it uses the global ones",
h("button", { class: "dng", title: "clear this shot's pictures and prompt: it uses the global ones",
onClick: () => { Object.assign(c.meta(i), { refs: [], prompt: "" }); c.save(); } }, "Use global"),
hint("empty slots use the global references (Prompts & refs tab)"),
]),
], { sub: "who the shot becomes" });
], { sub: "faded = the global ones (Pictures & prompt tab)" });
const mode = (on, title, text, onClick) => h("div", { class: ["mode", on && "on"], onClick }, [h("b", title), h("span", text)]);
// which picture a marked person becomes: "Picture 3 · red" with its thumbnail (the color is the picture's)
const picturePick = (k, onPick) => {
const n = Math.max(cur.names.length, k), color = pictureColor(plan, k);
return h("span", { class: "row", style: "gap:4px;align-items:center" }, [
cur.names[k - 1] ? h("img", { class: "mthumb", src: c.viewUrl(cur.names[k - 1]), style: `--mark:${color}`, title: cur.names[k - 1] })
: h("span", { class: "mthumb", style: `--mark:${color};display:inline-block;background:${color}` }),
h("select", { value: String(k), style: "width:auto", title: "The picture this person becomes; the mark is drawn in its color",
h("select", { value: String(k), style: "width:auto", title: "The picture this subject becomes; the mark is drawn in its color",
onChange: e => onPick(+e.target.value) },
Array.from({ length: Math.min(MAX_REFS, n) }, (_, q) => h("option", { value: String(q + 1) },
`Picture ${q + 1} · ${markName(pictureColor(plan, q + 1))}${cur.names[q] ? "" : " (no picture)"}`))),
]);
};
const who = section("Select region", [
h("div", { class: "row", style: "margin-top:4px;align-items:center;gap:8px" }, [
check(!!cur.color_guide, v => c.setMeta(i, "color_guide", v), "🎨 Colored Mark",
"Color each selected person in the source video with the color of the picture they become, as a translucent marker; the prompt gets 'the person marked in <color> becomes the character from <Picture n>'. It does not create or enable an inpainting mask."),
cur.color_guide ? h("span", { class: "hint" }, "this selection becomes") : null,
cur.color_guide ? picturePick(cur.color_guide_picture, k => { c.setMarkPicture(i, 1, k); if (maskPrev.value[segKey(cur)]) c.previewMask(i); }) : null,
// what changes in this shot: one row per subject. Row 1 is the shot's own selection (its SAM 3 text / points);
// with Colored Mark on, every row becomes a picture (drawn in that picture's color) and more rows can be added
const cg = cur.color_guide, regionsN = 1 + cur.regions.length;
const label = n => (cg ? h("span", { class: "hint", style: "white-space:nowrap;min-width:58px" }, `Subject ${n}`) : null);
const becomes = (k, onPick) => (cg ? [h("span", { class: "hint" }, "→"), picturePick(k, onPick)] : []);
const subject1 = h("div", { class: "mreg" }, [
label(1),
h("input", { type: "text", value: cur.mask.text || "", style: "flex:1;min-width:160px",
placeholder: "what to select, in English: woman in pink top, red car, black dog… (Enter = preview)",
title: "A short phrase (noun + one or two traits); SAM 3 finds and tracks it through the shot. Points, when set, win over the text.",
onInput: e => c.setMask(i, "text", e.target.value),
onKeydown: e => { if (e.key === "Enter") { e.preventDefault(); c.setMask(i, "text", e.target.value); if (e.target.value || (cur.mask.points || []).length) c.previewMask(i); } } }),
h("button", { title: "Click positive / negative points on a frame (more precise than text when subjects look alike)",
onClick: () => c.openPoints(i) }, (cur.mask.points || []).length ? `🎯 Points (${cur.mask.points.length})` : "🎯 Points…"),
h("button", { class: hasMask(cur) && !mp ? "pri" : "", disabled: !!busy.value || !hasMask(cur),
title: "Segment this shot and show a few frames", onClick: () => c.previewMask(i) }, "👁 Preview"),
...becomes(cur.color_guide_picture, k => { c.setMarkPicture(i, 1, k); if (maskPrev.value[segKey(cur)]) c.previewMask(i); }),
h("button", { class: "dng", disabled: !(cur.mask.text || (cur.mask.points || []).length || cur.mask.video || cur.crop || cur.inpaint),
title: "Clear this shot's selection (text, points, mask video) and its mode", onClick: () => c.clearMask(i) }, "✕"),
]);
const more = cg ? cur.regions.map((r, j) => h("div", { class: "mreg" }, [
label(j + 2),
h("input", { type: "text", value: r.text || "", style: "flex:1;min-width:160px", placeholder: "what to select, e.g. the man on the left",
onChange: e => c.setMarkRegion(i, j, "text", e.target.value) }),
h("button", { onClick: () => c.openPoints(i, j + 2) }, (r.points || []).length ? `🎯 Points (${r.points.length})` : "🎯 Points…"),
h("button", { disabled: !!busy.value || !(r.text || (r.points || []).length),
onClick: () => c.previewMask(i, { text: r.text || "", points: r.points || [], key: r.key || 0, max_objects: 1,
preview_color: pictureColor(plan, r.picture) }) }, "👁 Preview"),
...becomes(r.picture, k => c.setMarkRegion(i, j, "picture", k)),
h("button", { class: "dng", title: "remove this subject", onClick: () => c.removeMarkRegion(i)(j) }, "✕"),
])) : [];
const MODE_INFO = {
frame: "The whole frame is regenerated.",
paste: "The whole frame is generated; BFS Shot Join pastes only the subject back onto the original (background exact).",
mask: "Only the selection is regenerated; the rest of the frame stays exactly as it was.",
crop: "A box around the selection is regenerated at full size (more detail for a small subject), then pasted back.",
cropmask: "Inside the crop, only the selection is regenerated: the most detail, background kept.",
};
const curMode = c.modeOf(cur);
const who = section("What changes", [
h("div", { class: "row", style: "align-items:center;gap:10px;margin-bottom:4px" }, [
check(!!cg, v => c.setMeta(i, "color_guide", v), "🎨 Colored Mark",
"Paint each selected subject with the color of the picture it becomes (a translucent marker); the prompt says which color becomes which picture. It does not turn on inpainting."),
cg ? select(cur.color_guide_style, [["overlay", "translucent"], ["hatching", "hatching + contour"], ["normal map", "normal map (needs normals)"]],
v => c.setMarkStyle(i, v), { style: "width:auto", title: "How the marks are drawn on the guide (the same styles as BFS Video Segment Picker)" }) : null,
cg ? h("span", { class: "row", style: "gap:6px;align-items:center" }, [hint(`opacity ${Number(cur.color_guide_opacity ?? 0.35).toFixed(2)}`),
h("input", { type: "range", min: 0.05, max: cur.color_guide_style === "overlay" ? 0.8 : 1, step: 0.01, value: cur.color_guide_opacity ?? 0.35, style: "width:140px",
onInput: e => { c.meta(i).color_guide_opacity = parseFloat(e.target.value); }, onChange: c.save })]) : null,
]),
cur.color_guide ? h("div", { class: "note", style: "margin:4px 0;padding:6px 8px" }, [
...cur.regions.map((r, j) => h("div", { class: "mreg" }, [
h("span", { class: "hint", style: "white-space:nowrap" }, `Person ${j + 2}`),
picturePick(r.picture, k => c.setMarkRegion(i, j, "picture", k)),
h("input", { type: "text", value: r.text || "", style: "flex:1;min-width:160px", placeholder: "who, in English: e.g. the man on the left",
onChange: e => c.setMarkRegion(i, j, "text", e.target.value) }),
h("button", { disabled: !!busy.value || !(r.text || (r.points || []).length),
onClick: () => c.previewMask(i, { text: r.text || "", points: r.points || [], key: r.key || 0, max_objects: 1,
preview_color: pictureColor(plan, r.picture) }) }, "👁 Preview"),
h("button", { onClick: () => c.openPoints(i, j + 2) }, (r.points || []).length ? `🎯 Points (${r.points.length})` : "🎯 Points…"),
h("button", { class: "dng", title: "remove this person's mark", onClick: () => c.removeMarkRegion(i)(j) }, "✕"),
])),
h("div", { class: "row", style: "margin-top:6px;align-items:center;gap:8px" }, [
h("button", { disabled: 1 + cur.regions.length >= MAX_REFS, onClick: () => c.addMarkRegion(i) }, "+ Mark another person"),
hint(cur.names.length > 1 + cur.regions.length ? "each person: a picture (its color) and a text or points"
: "add more pictures (Reference & prompt) to give each person their own"),
]),
cg && cur.color_guide_style === "normal map" ? h("div", { class: "note", style: "margin:2px 0 4px;padding:6px 8px" },
"Normal map: run MoGe on BFS Shot Unpack's frames (Render MoGe Geometry, normal_directx) and connect it to BFS Shot H3 Conditioning → normals. The preview shows a tint.") : null,
subject1, ...more,
c.files.pointModel ? hint("📍 MolmoPoint is connected: a text selection is pointed by MolmoPoint, then SAM 3 tracks it.", "display:block;margin-top:4px") : null,
cg ? h("div", { class: "row", style: "margin:4px 0;align-items:center;gap:8px" }, [
h("button", { disabled: regionsN >= MAX_REFS, onClick: () => c.addMarkRegion(i) }, "+ Add subject"),
cur.names.length <= regionsN ? hint("add pictures above to give each subject its own") : null,
]) : null,
cur.color_guide ? h("details", { style: "margin:4px 0" }, [h("summary", { class: "hint", style: "cursor:pointer" }, `Opacity · ${Number(cur.color_guide_opacity ?? 0.35).toFixed(2)}`),
h("div", { class: "row", style: "margin-top:6px;gap:8px;align-items:center" }, [h("input", { type: "range", min: 0.05, max: 0.8, step: 0.01, value: cur.color_guide_opacity ?? 0.35, style: "flex:1;max-width:320px", onInput: e => { c.meta(i).color_guide_opacity = parseFloat(e.target.value); }, onChange: c.save }), hint("Keeps movement and expression visible.")])]) : null,
h("div", { class: "row", style: "align-items:center" }, [
h("input", { type: "text", value: cur.mask.text || "", style: "flex:1;min-width:160px",
placeholder: "mask: what to segment, in English (woman in pink top, black dog…) · Enter = preview",
title: "What to segment, in English: a short phrase (noun + one or two traits), e.g. 'woman in pink top'; commas for several things. Enough on its own - no points needed: SAM 3 finds and tracks it through the shot (every match, up to Max objects). Press Enter to preview. Points, when set, take priority.",
onInput: e => c.setMask(i, "text", e.target.value),
onKeydown: e => { if (e.key === "Enter") { e.preventDefault(); c.setMask(i, "text", e.target.value); if (e.target.value || (cur.mask.points || []).length) c.previewMask(i); } } }),
h("button", { title: "Pick positive / negative points on a frame of this shot (more precise than text when several people look alike)",
onClick: () => c.openPoints(i) }, (cur.mask.points || []).length ? `🎯 Points (${cur.mask.points.length})` : "🎯 Points…"),
h("button", { class: hasMask(cur) && !mp ? "pri" : "", disabled: !!busy.value || !hasMask(cur),
title: (cur.mask.points || []).length ? "Segment this shot (the points win over the text)" : "Segment this shot with the text and show a few frames",
onClick: () => c.previewMask(i) }, "👁 Preview"),
h("button", { class: "dng", disabled: !(cur.mask.text || (cur.mask.points || []).length || cur.mask.video || cur.crop || cur.inpaint), title: "Remove this shot's mask (points, text, mask video) and mode - for shots where it picked the wrong thing",
onClick: () => c.clearMask(i) }, "✕ Clear"),
]),
cg && !hasMask(cur) ? h("div", { class: "err" }, "Colored Mark needs a selection: a text, points or a mask video.") : null,
h("div", { class: "row", style: "margin-top:6px;align-items:center" }, [
h("span", { class: "hint", style: "white-space:nowrap", title: "A black/white video (white = the subject), e.g. rotoscoped in another tool. It replaces SAM 3 for this shot and covers only this shot: its first frame is the shot's first frame." }, "🎞 mask video"),
h("select", { value: cur.mask.video || "", style: "flex:1;min-width:160px",
onChange: e => c.setMask(i, "video", e.target.value) },
[h("option", { value: "" }, cur.extMask ? "— none: the global mask video (People / Filters / Masks) —" : "— none: SAM 3 (text / points) —"),
...c.files.videos.map(v => h("option", { value: v }, v))]),
h("span", { class: "hint", style: "white-space:nowrap", title: "A black/white video (white = the subject) for this shot only; it replaces SAM 3." }, "or a mask video"),
h("select", { value: cur.mask.video || "", style: "flex:1;min-width:160px", onChange: e => c.setMask(i, "video", e.target.value) },
[h("option", { value: "" }, cur.extMask ? "— none: the whole-video mask —" : "— none —"), ...c.files.videos.map(v => h("option", { value: v }, v))]),
h("button", { title: "upload a mask video for this shot", onClick: () => c.pickFile("video/*", name => c.setMask(i, "video", name)) }, "⬆ Upload"),
cur.mask.video ? h("button", { title: "back to SAM 3 / the global mask", onClick: () => c.setMask(i, "video", "") }, "✕") : null,
]),
maskSource(cur) === "video" ? hint("This shot uses its mask video: the text and points are ignored.", "display:block;margin-top:2px")
: maskSource(cur) === "global" ? hint("No mask of its own: this shot uses the global mask video (People / Filters / Masks tab).", "display:block;margin-top:2px")
: maskSource(cur) === "track" ? hint("No mask of its own: this shot uses its part of the whole-video track (People / Filters / Masks tab).", "display:block;margin-top:2px") : null,
: maskSource(cur) === "global" || maskSource(cur) === "track" ? hint("No selection of its own: this shot uses the whole-video mask (Mask settings, below).", "display:block;margin-top:2px") : null,
mp ? h("div", { class: "mstrip" }, [
...mp.frames.map(f => h("img", { src: f.src, title: `frame ${f.f}` })),
hint(mp.empty ? "nothing found: the shot runs without a mask" : `mask covers ${(mp.coverage * 100).toFixed(1)}%${cur.crop ? " · yellow = crop box" : ""}`),
]) : null,
h("div", { class: "modes", style: "margin-top:8px" }, [
["frame", "Full frame", "The whole frame is regenerated. The mask, if any, only feeds {target} / the setting picture."],
["paste", "🧩 Frame + paste", "The whole frame is generated (the pose follows the guide, the new subject may be bigger); BFS Shot Join pastes only the subject onto the original: background exact."],
["mask", "🎭 Mask only", "Only the mask is regenerated, on the whole frame: the rest stays exactly as it was. No crop / uncrop."],
["crop", "✂ Crop", "A box around the mask is regenerated (more pixels for a small subject); BFS Shot Join pastes it back."],
["cropmask", "✂🎭 Crop + mask", "Inside the crop, only the mask is regenerated: the most detail with the background kept."],
].map(([k, t, d]) => mode(c.modeOf(cur) === k, t, d, () => c.setMode(i, k)))),
cur.color_guide && !hasMask(cur) ? h("div", { class: "err" }, "Colored Mark needs a region selection: enter a description, add points, or choose a mask video, then preview it.") : null,
(cur.crop || cur.inpaint || cur.paste) && !hasMask(cur) ? h("div", { class: "err" }, `${c.modeName(cur)} needs a mask on this shot: a mask text, points or a mask video (or the planner's mask input).`) : null,
cur.inpaint ? h("div", { class: "row", style: "margin-bottom:6px;align-items:center",
title: "Opacity of the generation mask: the value of the white inside the mask (1 = fully white, the default). H3 reads a grey mask value as 'regenerate this much': at 0.85 the masked area starts at 85% of the noise and keeps ~15% of the original there. 0.8-0.9 keeps pose, outline and lighting while still swapping; too low copies the original person." }, [
h("span", { style: "white-space:nowrap" }, `Mask opacity ${Number(cur.strength).toFixed(2)}`),
h("div", { class: "row", style: "margin-top:8px;align-items:center" }, [
h("span", { style: "white-space:nowrap" }, "Generate"),
select(curMode, c.MODES.map(m => [m[0], m[1]]), v => c.setMode(i, v), { style: "width:auto" }),
hint(MODE_INFO[curMode]),
]),
(cur.crop || cur.inpaint || cur.paste) && !hasMask(cur) ? h("div", { class: "err" }, `${c.modeName(cur)} needs a selection on this shot (text, points or a mask video).`) : null,
cur.inpaint ? h("div", { class: "row", style: "margin-top:6px;align-items:center",
title: "How much of the selection is regenerated. 0.8-0.9 keeps pose, outline and lighting while still changing it; too low copies the original. Needs BFS Shot H3 Conditioning → inpaint = per shot (planner)." }, [
h("span", { style: "white-space:nowrap" }, `Mask strength ${Number(cur.strength).toFixed(2)}`),
h("input", { type: "range", min: 0.3, max: 1, step: 0.01, value: cur.strength, style: "flex:1;max-width:320px",
onInput: e => { c.meta(i).strength = parseFloat(e.target.value); }, onChange: c.save }),
h("button", { class: "ghost", disabled: cur.strength >= 1, onClick: () => c.setMeta(i, "strength", 1) }, "reset"),
hint(cur.strength >= 1 ? "regenerates the masked area completely" : `keeps ~${Math.round(100 - cur.strength * 100)}% of the original inside the mask`),
]) : null,
cur.inpaint ? h("div", { class: "note", style: "margin-bottom:6px" }, [h("b", "Mask only / Crop + mask: "),
"set BFS Shot H3 Conditioning → inpaint to ", h("b", "per shot (planner)"), " (the default). Raise Expand (People / Filters / Masks tab) when the new subject is bigger than the old one."]) : null,
cur.paste ? h("div", { class: "row", style: "margin-bottom:6px;align-items:center",
title: "What the NEW subject is, in English, for SAM 3 on the result: BFS Shot Join also pastes its outline (when it is bigger than the old one). E.g. person, dog, cat, car. Empty = person." }, [
cur.paste ? h("div", { class: "row", style: "margin-top:6px;align-items:center",
title: "What the NEW subject is, in English, so its outline on the result is pasted too (a bigger silhouette is not cut). Empty = person." }, [
h("span", { style: "white-space:nowrap" }, "new subject"),
h("input", { type: "text", value: cur.pasteText || "", placeholder: "person (or dog, cat, car… what the result shows)", style: "flex:1;max-width:420px",
h("input", { type: "text", value: cur.pasteText || "", placeholder: "person (or dog, car… what the result shows)", style: "flex:1;max-width:420px",
onChange: e => c.setMeta(i, "paste_text", e.target.value) }),
hint("its outline on the result is pasted too, so a bigger silhouette is not cut"),
]) : null,
h("div", { class: "row", style: "margin-top:4px;align-items:center" }, [
h("div", { class: "row", style: "margin-top:8px;align-items:center" }, [
targetCrop.value[k] ? h("img", { src: targetCrop.value[k], style: "height:56px;border-radius:6px" }) : null,
h("span", { class: "hint", style: "white-space:nowrap" }, "{target}"),
h("input", { type: "text", value: cur.target, style: "flex:1;min-width:200px",
placeholder: "who is replaced, e.g. the young woman in a pink crop top (empty = the person)",
title: "Write {target} in the prompt: it becomes this description, so the model knows WHICH person to replace (useful with several people on screen).",
placeholder: "what is replaced, for {target} in the prompt (e.g. the young woman in a pink top)",
title: "Write {target} in the prompt and it becomes this text, so the model knows WHICH subject changes.",
onChange: e => c.setMeta(i, "target", e.target.value) }),
h("button", { disabled: !!busy.value || !hasMask(cur),
title: "Uses this shot's selection (🎯 Points… on the person — click the BODY, not only the face, so the outfit is described — or the mask text): SAM 3 cuts the person out and the VLM describes them",
onClick: () => c.describeTarget(i) }, "🧑 Describe target"),
h("button", { disabled: !!busy.value || !hasMask(cur), title: "SAM 3 cuts the selection out and the VLM describes it",
onClick: () => c.describeTarget(i) }, "🧑 Describe"),
]),
g ? h("div", { class: "vsug" }, [
h("div", ["🤖 ", h("b", "segment: "), g.segment || "—", g.segment ? h("button", { onClick: () => c.applySug(i, "segment") }, "Use as mask") : null]),
h("div", ["🤖 ", h("b", "select: "), g.segment || "—", g.segment ? h("button", { onClick: () => c.applySug(i, "segment") }, "Use") : null]),
h("div", [h("b", "shot: "), g.shot || "—", g.shot ? h("button", { title: "copy (the {shot} placeholder in the prompt gets it automatically at run time)",
onClick: () => navigator.clipboard?.writeText(g.shot) }, "Copy") : null]),
h("div", [h("b", "recommend: "), `${g.recommend}${g.people != null ? " · " + g.people + " people" : ""}${g.reason ? " · " + g.reason : ""}`,
g.recommend === "skip" ? h("button", { onClick: () => c.applySug(i, "skip") }, "Skip this shot") : null]),
g.raw ? hint("unparsed answer: " + g.raw.slice(0, 200)) : null,
]) : null,
], { sub: "SAM 3 mask (text or points), crop, {target}" });
], { sub: "the selection, its picture and how it is generated" });
const cont = section("Continuity", h("div", { class: "row" }, [
h("select", { value: cur.chain, style: "width:auto", disabled: i === 0,
@@ -287,13 +278,50 @@ function editor(c, cur) {
h("button", { class: copyOpts.scope === v && "on", onClick: () => { copyOpts.scope = v; } }, l))),
h("button", { class: "pri", disabled: !Object.keys(COPY_FIELDS).some(f => copyOpts[f]) || S.length < 2,
onClick: e => { const n = c.copyFrom(i); const b = e.currentTarget; b.textContent = `✓ copied to ${n} shot(s)`; setTimeout(() => { b.textContent = "📋 Copy"; }, 1400); } }, "📋 Copy"),
hint("replaces the old “→ all” buttons"),
]),
], { open: false, sub: Object.entries(COPY_FIELDS).filter(([f]) => copyOpts[f]).map(([, l]) => l).join(", ") });
return h("div", { class: "card" }, [head, problems, refPrompt, who, cont, copy]);
}
// settings every shot's mask shares (SAM 3, crop and paste-back), and the optional mask for the whole video
function maskSettings(c) {
const { plan } = c;
const MC = plan.mask_cfg || {};
const mnum = (k, step, label, help, scale = 1) => fld(label, h("input", { type: "number", step, min: 0, value: +(MC[k] * scale).toFixed(3),
onChange: e => c.setMaskCfg(k, (parseFloat(e.target.value) || 0) / scale) }), help);
return h("div", { class: "card" }, [section("Mask settings (every shot)", [
h("div", { class: "row", style: "margin-bottom:8px;align-items:center" }, [
h("span", { style: "white-space:nowrap", title: "A black/white video of the WHOLE source (white = the subject). Shots without their own selection use it instead of SAM 3. The planner's mask input does the same from the graph and wins over this." }, "🎞 Mask video (whole video)"),
h("select", { value: plan.mask_video || "", style: "flex:1;min-width:180px", onChange: e => c.setGlobalMaskVideo(e.target.value) },
[h("option", { value: "" }, "— none —"), ...c.files.videos.map(v => h("option", { value: v }, v))]),
h("button", { onClick: () => c.pickFile("video/*", c.setGlobalMaskVideo) }, "⬆ Upload"),
plan.mask_video ? h("button", { onClick: () => c.setGlobalMaskVideo("") }, "✕") : null,
]),
plan.track?.points?.length ? h("div", { class: "note", style: "margin-bottom:8px;display:flex;gap:8px;align-items:center" }, [
h("span", { class: "grow" }, [h("b", "🎯 Whole-video track: "), `${plan.track.points.length} point(s) at frame ${plan.track.frame}`,
plan.track.text ? ` · re-found by "${plan.track.text}" after cuts` : " · no text: after a cut the subject may be lost",
". Shots without their own selection use it."]),
h("button", { class: "ghost", onClick: c.clearTrack }, "✕ remove"),
]) : null,
h("div", { class: "row", style: "gap:14px;margin-bottom:6px" }, [
check(MC.fill_holes, v => c.setMaskCfg("fill_holes", v), "Fill holes", "Fill enclosed gaps so each region is solid"),
check(MC.invert, v => c.setMaskCfg("invert", v), "Invert", "Use everything except the selection"),
]),
h("div", { class: "grid" }, [
mnum("expand", 1, "Expand (px)", "grow the mask"),
mnum("temporal_expand", 1, "Temporal expand (frames)", "less flicker"),
mnum("threshold", 0.05, "Detection threshold"),
mnum("max_objects", 1, "Max objects", "matches of the text tracked"),
mnum("padding", 1, "Crop padding (%)", "crop modes", 100),
mnum("feather", 1, "Feather (px)", "crop modes"),
fld("Paste back", h("select", { value: MC.paste, onChange: e => c.setMaskCfg("paste", e.target.value) },
[h("option", { value: "mask" }, "only the mask (feathered)"), h("option", { value: "box" }, "the whole box (feathered)")]), "crop modes"),
mnum("blockify", 1, "Blockify (px, 0 = off)"),
]),
], { open: false, sub: "SAM 3, crop and paste-back" })]);
}
export function shotsTab(c) {
const { segs, sel, showMasks } = c;
const S = segs.value, cur = S[sel.value];
@@ -307,5 +335,6 @@ export function shotsTab(c) {
]),
cur ? h("div", { class: "card" }, [player(c, cur)]) : null,
cur ? editor(c, cur) : null,
maskSettings(c),
]);
}
+11 -8
View File
@@ -68,22 +68,25 @@ export function videoTab(c) {
onChange: e => { const v = parseFloat(e.target.value) || 0;
c.setRange("end_f", v > 0 ? c.range.value.lo + Math.round(v * plan.fps) : 0); } }),
`from the start frame · ${c.range.value.hi - c.range.value.lo} frames`),
fld("Detector", sel_("detector", [["adaptive", "PySceneDetect adaptive"], ["content", "PySceneDetect content"], ["builtin", "Built-in"]], () => c.autoSplit(true))),
fld(`Sensitivity ${Number(plan.sensitivity).toFixed(2)}`, h("input", { type: "range", min: 0, max: 1, step: 0.05, value: plan.sensitivity,
onInput: e => { plan.sensitivity = parseFloat(e.target.value); }, onChange: () => { c.save(); c.autoSplit(true); } })),
fld("Frame grid", sel_("grid", Object.keys(GRIDS))),
fld("Max seconds / shot", num("max_s", 0.1, 0.2), `= ${maxLen.value} frames (${(maxLen.value / fps).toFixed(2)} s)`),
fld("Min seconds / shot", num("min_s", 0.1, 0)),
fld("Max shots (0 = all)", num("max_parts", 1, 0), "limit how many detected shots are used"),
fld("Timeline fps", num("fps", 1, 1), "re-analyses"),
fld("Max shots (0 = all)", num("max_parts", 1, 0), "only the first N shots"),
]),
// the cut detector and the frame grid rarely change
h("details", { style: "margin-top:6px" }, [h("summary", { class: "hint", style: "cursor:pointer" }, "More: cut detector, frame grid, fps"),
h("div", { class: "grid", style: "margin-top:6px" }, [
fld("Detector", sel_("detector", [["adaptive", "PySceneDetect adaptive"], ["content", "PySceneDetect content"], ["builtin", "Built-in"]], () => c.autoSplit(true))),
fld(`Sensitivity ${Number(plan.sensitivity).toFixed(2)}`, h("input", { type: "range", min: 0, max: 1, step: 0.05, value: plan.sensitivity,
onInput: e => { plan.sensitivity = parseFloat(e.target.value); }, onChange: () => { c.save(); c.autoSplit(true); } })),
fld("Min seconds / shot", num("min_s", 0.1, 0)),
fld("Frame grid", sel_("grid", Object.keys(GRIDS))),
fld("Timeline fps", num("fps", 1, 1), "re-analyses"),
])]),
h("div", { class: "row", style: "margin-top:8px" }, [
h("button", { class: "pri", disabled: !plan.video || !!busy.value, onClick: () => c.autoSplit(true) }, "✂ Auto split"),
h("button", { disabled: !plan.video, onClick: c.analyze }, "↻ Re-analyse"),
detectorUsed.value ? hint(`detector: ${detectorUsed.value}`) : null,
c.allSegCount.value > c.segs.value.length ? hint(`Max shots = ${plan.max_parts}: ${c.allSegCount.value - c.segs.value.length} `
+ `more shot(s) on the timeline are left out. Set Max shots to 0 to use them all.`, "color:#f0b429") : null,
hint("Auto split replaces the boundaries; per-shot settings stay with the shot at the same position."),
]),
], { sub: "camera cuts or fixed length, on the model's frame grid" });