diff --git a/bfs_shot_loop.py b/bfs_shot_loop.py index b50ec2b..62944d1 100644 --- a/bfs_shot_loop.py +++ b/bfs_shot_loop.py @@ -429,7 +429,8 @@ def plan_segments(n: int, cuts: list[int], mode: str, max_len: int, min_len: int DEFAULT_PLAN = { "video": "", "fps": 24.0, "grid": DEFAULT_GRID, "mode": "shots", "max_s": 4.5, "min_s": 1.0, "sensitivity": 0.5, "max_parts": 0, "max_total_s": 0.0, "start_s": 0.0, "end_s": 0.0, "bounds": [], "segs": [], - "global_ref": "", "global_ref2": "", "global_prompt": "", "megapixels": 0.5, "multiple": 32, + "global_ref": "", "global_ref2": "", "global_prompt": "", "global_color_guide_ref1": "#00ff00", + "global_color_guide_ref2": "#9b30ff", "megapixels": 0.5, "multiple": 32, "detector": "adaptive", "run": "auto", "filters": {}, "skip_fill": "original", "cast": {}, "cast_assign": True, "cast_split": False, "cast_only": False, "mask_cfg": {}, "vlm_cfg": {}, "audio_mode": "auto", "ref_details": {}, } @@ -502,6 +503,8 @@ def resolve_plan(plan: dict, analysis: dict, path: str | None = None) -> list[di if guide_pid2 is not None: s["color_guide_color2"] = str(m.get("color_guide_color2") or (plan.get("cast") or {}).get(str(guide_pid2), {}).get("guide_color") or "") + 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") if s.get("color_guide"): cast_entries = plan.get("cast") or {} first_person = cast_entries.get(str(s.get("color_guide_person"))) or {} diff --git a/pyproject.toml b/pyproject.toml index cc3c31e..d90b300 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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.96.0" +version = "1.97.0" license = {file = "LICENSE"} dependencies = [ "torch", diff --git a/web/js/bfs_shot_loop.js b/web/js/bfs_shot_loop.js index 5c554c8..3773fad 100644 --- a/web/js/bfs_shot_loop.js +++ b/web/js/bfs_shot_loop.js @@ -248,7 +248,7 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla } const setCast = (id, k, v) => { plan.cast = { ...plan.cast, [String(id)]: { ...castOf(id), [k]: v } }; - if (k !== "ignore") remember(v); + if (k === "ref" || k === "ref2") remember(v); save(); }; async function describeTarget(i) { @@ -583,14 +583,18 @@ const meta = i => { while (plan.segs.length <= i) plan.segs.push({}); return pla // ---- references // the last 10 references picked, remembered across workflows (per browser) const RECENT_KEY = "bfs.shotloop.recentRefs"; - const recent = ref(store.get(RECENT_KEY, [])); + const isColorValue = name => typeof name === "string" && /^#[0-9a-f]{6}$/i.test(name); + const savedRecent = store.get(RECENT_KEY, []); + const recent = ref((Array.isArray(savedRecent) ? savedRecent : []).filter(name => typeof name === "string" && !isColorValue(name))); + if (recent.value.length !== (Array.isArray(savedRecent) ? savedRecent.length : 0)) store.set(RECENT_KEY, recent.value); const remember = name => { - if (!name) return; + if (!name || typeof name !== "string" || isColorValue(name)) return; recent.value = [name, ...recent.value.filter(x => x !== name)].slice(0, 10); store.set(RECENT_KEY, recent.value); }; // target: shot index or "global"; field: ref | ref2 (global_ref | global_ref2 for "global") const setRef = (target, field, name) => { + if (isColorValue(name)) return; // colors belong to Color Guide settings, never to image references if (target === "global") plan[field] = name; else meta(target)[field] = name; remember(name); save(); }; diff --git a/web/js/docs/BFSShotPlanner.md b/web/js/docs/BFSShotPlanner.md index 9beea74..66aa373 100644 --- a/web/js/docs/BFSShotPlanner.md +++ b/web/js/docs/BFSShotPlanner.md @@ -197,7 +197,8 @@ person. With `guide_mode = aligned guide` the model also sees the whole original the motion either way. (Not applied with the duet panel.) **🎨 Color Guide** is a separate optional visual cue. Turn it on for a shot with a mask and choose the marker color -for ref 1. The selected region is tinted in the source guide, while the image detail, +for ref 1. The default colors for the global references are set below each image in **Prompts & refs**; a shot may +override them. The selected region is tinted in the source guide, while the image detail, movement and expression remain visible underneath. This does not turn on `Mask only` or create an H3 generation mask. The prompt must explain that the tint is temporary and that the matching reference replaces the tinted person. diff --git a/web/js/shotloop/common.js b/web/js/shotloop/common.js index 33fce23..847bdd1 100644 --- a/web/js/shotloop/common.js +++ b/web/js/shotloop/common.js @@ -129,6 +129,8 @@ const TEXT = { "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", "中文"], "Color Guide": ["Guia de cores", "Guía de color", "颜色引导"], "Video": ["Vídeo", "Vídeo", "视频"], "Shots": ["Planos", "Planos", "镜头"], "Prompts": ["Prompts", "Prompts", "提示词"], + "Guide color · Ref 1": ["Cor guia · Ref. 1", "Color guía · Ref. 1", "引导颜色 · 参考图 1"], "Guide color · Ref 2": ["Cor guia · Ref. 2", "Color guía · Ref. 2", "引导颜色 · 参考图 2"], + "These colors are defaults for the global references. Turn on Color Guide on a shot to apply them; shot-level colors can override them.": ["Estas cores são os padrões das referências globais. Ative o Guia de cores no plano para aplicá-las; cada plano pode substituí-las.", "Estos colores son los valores predeterminados de las referencias globales. Activa la Guía 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", "内置"], @@ -217,7 +219,7 @@ export function h(type, props, children) { export const GRIDS = { "H3 (17n+5)": [17, 5], "LTX / Wan (8n+1)": [8, 1], "Wan (4n+1)": [4, 1], "any": [1, 0] }; export const DEFAULTS = { video: "", fps: 24, grid: "H3 (17n+5)", mode: "shots", max_s: 4.5, min_s: 1.0, sensitivity: 0.5, - max_parts: 0, max_total_s: 0, start_s: 0, end_s: 0, start_f: null, end_f: null, track: null, bounds: [], segs: [], global_ref: "", global_ref2: "", global_prompt: "", mask_video: "", + max_parts: 0, max_total_s: 0, start_s: 0, end_s: 0, start_f: null, end_f: null, track: null, bounds: [], segs: [], global_ref: "", global_ref2: "", global_prompt: "", global_color_guide_ref1: "#00ff00", global_color_guide_ref2: "#9b30ff", mask_video: "", megapixels: 0.5, multiple: 32, detector: "adaptive", run: "auto", auto_continue: true, skip_fill: "original", audio_mode: "auto", ref_details: {}, mask_cfg: {}, vlm_cfg: {}, cast: {}, cast_assign: true, 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, diff --git a/web/js/shotloop/styles.js b/web/js/shotloop/styles.js index 0956f2e..b5fb658 100644 --- a/web/js/shotloop/styles.js +++ b/web/js/shotloop/styles.js @@ -141,6 +141,8 @@ const CSS = ` .bsl .refslot.sm{width:56px;height:56px;font-size:10px} .bsl .refslot.via{border-style:solid;border-color:#3b5b3b} .bsl .rslot{display:flex;flex-direction:column;gap:3px;align-items:center} +.bsl .guidecolor{display:flex;flex-direction:column;align-items:center;gap:2px;max-width:112px} +.bsl .guidecolor select{width:100%;font-size:10px;padding:2px 4px} .bsl .rbtns{display:flex;gap:3px;min-height:20px} .bsl .rbtns button{padding:1px 6px;font-size:10px} .bsl .recent{display:flex;gap:4px;flex-wrap:wrap;margin-top:6px;align-items:center} diff --git a/web/js/shotloop/view_prompts.js b/web/js/shotloop/view_prompts.js index 3584e67..2aabdfe 100644 --- a/web/js/shotloop/view_prompts.js +++ b/web/js/shotloop/view_prompts.js @@ -1,12 +1,23 @@ // 📝 Prompts & refs tab: the global reference / prompt, the VLM (shot suggestions) and the reference descriptions ({details}). import { h, fld, check, section, pill, hint, select } from "./common.js"; +const GUIDE_COLORS = [["#00ff00", "Green"], ["#9b30ff", "Purple"], ["#ff2020", "Red"], ["#168cff", "Blue"], + ["#ffe600", "Yellow"], ["#ff00d4", "Magenta"], ["#00e5c0", "Cyan"]]; + function globalSection(c) { const { plan } = c; const slot = (name, label, field) => h("div", { class: "rslot" }, [ h("div", { class: "refslot", title: name ? name + " (click to replace)" : "click to upload", onClick: () => c.uploadRef("global", field) }, name ? [h("img", { src: c.viewUrl(name) })] : [label]), h("div", { class: "rbtns" }, [name ? h("button", { title: "remove", onClick: () => c.setRef("global", field, "") }, "✕") : null]), + h("label", { class: "guidecolor" }, [ + h("span", { class: "hint" }, field === "global_ref" ? "Guide color · Ref 1" : "Guide color · Ref 2"), + h("select", { value: field === "global_ref" ? (plan.global_color_guide_ref1 || "#00ff00") : (plan.global_color_guide_ref2 || "#9b30ff"), + disabled: !name, + title: "Default Color Guide marker when this global reference is used", + onChange: e => c.setPlan(field === "global_ref" ? "global_color_guide_ref1" : "global_color_guide_ref2", e.target.value) }, + GUIDE_COLORS.map(([v, text]) => h("option", { value: v }, text))), + ]), ]); return section("Global reference & prompt", [ h("div", { class: "row", style: "align-items:flex-start;gap:10px" }, [ @@ -20,6 +31,7 @@ function globalSection(c) { "Used by shots without their own. Connected ref_image / ref_image_2 / prompt inputs on the node override these. Placeholders: ", h("b", "{target}"), " who is replaced (per shot) · ", h("b", "{details}"), " description of the shot's references · ", 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" }, "These colors are defaults for the global references. Turn on Color Guide on a shot to apply them; shot-level colors can override them."), ], { sub: "defaults for every shot" }); } diff --git a/web/js/shotloop/view_shots.js b/web/js/shotloop/view_shots.js index 014c325..7e953a1 100644 --- a/web/js/shotloop/view_shots.js +++ b/web/js/shotloop/view_shots.js @@ -88,7 +88,8 @@ function editor(c, cur) { const i = sel.value, S = segs.value, k = segKey(cur), mp = maskPrev.value[k], g = vlmSug.value[k]; const issues = c.checksFor(i); const guideColor1 = cur.color_guide_color || (cur.color_guide_person != null - ? (c.castOf(cur.color_guide_person).guide_color || "#00ff00") : "#00ff00"); + ? (c.castOf(cur.color_guide_person).guide_color || plan.global_color_guide_ref1 || "#00ff00") + : (plan.global_color_guide_ref1 || "#00ff00")); const viaRef = f => cur[f] ? "" : (c.castRef(cur, f) || plan["global_" + f] || ""); const head = h("div", { class: "edhd" }, [ @@ -177,7 +178,8 @@ function editor(c, cur) { cur.color_guide ? h("label", { class: "row", style: "gap:6px;align-items:center" }, [ h("span", { class: "hint" }, "Ref 1 color"), h("select", { value: cur.color_guide_color || - (cur.color_guide_person != null ? (c.castOf(cur.color_guide_person).guide_color || "#00ff00") : "#00ff00"), + (cur.color_guide_person != null ? (c.castOf(cur.color_guide_person).guide_color || plan.global_color_guide_ref1 || "#00ff00") + : (plan.global_color_guide_ref1 || "#00ff00")), onChange: e => c.setMeta(i, "color_guide_color", e.target.value) }, GUIDE_COLORS.map(([v, label]) => h("option", { value: v }, label))), ]) : null, @@ -192,7 +194,8 @@ function editor(c, cur) { h("button", { disabled: !!busy.value || !cur.color_guide_text2, onClick: () => c.previewMask(i, { text: cur.color_guide_text2, max_objects: 1 }) }, "👁 Preview"), h("select", { value: cur.color_guide_color2 || (cur.color_guide_person2 != null - ? (c.castOf(cur.color_guide_person2).guide_color || "#9b30ff") : "#9b30ff"), + ? (c.castOf(cur.color_guide_person2).guide_color || plan.global_color_guide_ref2 || "#9b30ff") + : (plan.global_color_guide_ref2 || "#9b30ff")), title: "Choose the marker color for the person matched to ref 2", onChange: e => c.setMeta(i, "color_guide_color2", e.target.value) }, GUIDE_COLORS.map(([v, label]) => h("option", { value: v, disabled: v === guideColor1 }, label))),