diff --git a/movie_editor/backend/builder.py b/movie_editor/backend/builder.py index adab6a9b..d5256e4f 100644 --- a/movie_editor/backend/builder.py +++ b/movie_editor/backend/builder.py @@ -222,6 +222,14 @@ def build(object_info: dict, models_config: dict, params: dict, media: dict | No # (the reference graph used preview-only save_output=False). graph["vhs"]["inputs"]["save_output"] = True + # 2b. project-level widget overrides for the built-in FunPack nodes. + for k, v in (params.get("studio_inputs") or {}).items(): + if k not in graph["studio"]["inputs"] or not isinstance(graph["studio"]["inputs"][k], list): + graph["studio"]["inputs"][k] = v + for k, v in (params.get("sampler_inputs") or {}).items(): + if k not in graph["sampler"]["inputs"] or not isinstance(graph["sampler"]["inputs"][k], list): + graph["sampler"]["inputs"][k] = v + # 3. slot nodes. slots = (models_config or {}).get("slots") or [] slot_node_id = {} diff --git a/movie_editor/backend/timeline.py b/movie_editor/backend/timeline.py index 8f3b3a4b..67d49d4f 100644 --- a/movie_editor/backend/timeline.py +++ b/movie_editor/backend/timeline.py @@ -87,6 +87,7 @@ class Project: id: str = field(default_factory=_new_id) name: str = "Untitled" anchor: str = "" # text prepended to every scene (character anchor) + negative_prompt: str = "" # passed to the neg primitive node in the graph # Marker separating the anchor from the first scene (Studio needs a trigger to # close segments[0]=anchor). Defaults resolved at assembly from the library. intro_transition: str = "" @@ -102,6 +103,10 @@ class Project: # is a slot id from models.json. Stored now, full builder wiring in a future phase. conditioning_slot: str = "funpack" sampler_slot: str = "funpack" + # Widget-input overrides for the built-in FunPack nodes (only used when the + # corresponding slot == "funpack"). Keys match ComfyUI widget/input names exactly. + studio_inputs: dict = field(default_factory=dict) + sampler_inputs: dict = field(default_factory=dict) created_at: float = field(default_factory=time.time) updated_at: float = field(default_factory=time.time) @@ -111,6 +116,7 @@ class Project: id=d.get("id") or _new_id(), name=str(d.get("name", "Untitled")), anchor=str(d.get("anchor", "")), + negative_prompt=str(d.get("negative_prompt", "")), intro_transition=str(d.get("intro_transition", "")), scenes=[Scene.from_dict(s) for s in d.get("scenes", [])], seed=int(d.get("seed", 1)), @@ -121,6 +127,8 @@ class Project: max_scenes=int(d.get("max_scenes", 8)), conditioning_slot=str(d.get("conditioning_slot", "funpack")), sampler_slot=str(d.get("sampler_slot", "funpack")), + studio_inputs=dict(d.get("studio_inputs") or {}), + sampler_inputs=dict(d.get("sampler_inputs") or {}), created_at=float(d.get("created_at", time.time())), updated_at=float(d.get("updated_at", time.time())), ) diff --git a/movie_editor/frontend/inspector.js b/movie_editor/frontend/inspector.js index 0f0b3ade..dc9d5926 100644 --- a/movie_editor/frontend/inspector.js +++ b/movie_editor/frontend/inspector.js @@ -132,6 +132,69 @@ row3.append(numberField("Height", p.height != null ? p.height : 512, (v) => S.patchProjectQuiet({ height: v }), "pj-h")); body.append(row3); body.append(el("div", "insp-hint", "Link FPS / Frames / Width / Height to node inputs in Models → Linked inputs (set Source = Project …).")); + + const neg = el("textarea"); neg.rows = 2; neg.value = p.negative_prompt || ""; neg.dataset.k = "pj-neg"; + neg.placeholder = "What to avoid in every scene"; + neg.oninput = () => S.patchProjectQuiet({ negative_prompt: neg.value }); + body.append(field("Negative prompt", neg)); + + if (!p.conditioning_slot || p.conditioning_slot === "funpack") { + renderStudioSettings(st); + } + if (!p.sampler_slot || p.sampler_slot === "funpack") { + renderSamplerSettings(st); + } + } + + function renderStudioSettings(st) { + // Studio settings are managed through studio_settings JSON — most are best left + // at reference-workflow defaults. Currently nothing extra is exposed here beyond + // the negative_prompt above (which goes through the neg primitive node). + } + + // Key ChainSampler widget inputs exposed as simple form controls. + const SAMPLER_KNOBS = [ + { name: "cfg", label: "CFG", kind: "float", default: 1.0, min: 0, max: 20, step: 0.1 }, + { name: "frame_overlap", label: "Frame overlap", kind: "int", default: 16, min: 0, max: 97, step: 1 }, + { name: "carry_i2v_guides", label: "Carry i2v guides", kind: "bool", default: false }, + { name: "mid_scene_guide", label: "Mid-scene guide", kind: "bool", default: false }, + { name: "mid_scene_guide_strength", label: "Guide strength", kind: "float", default: 0.25, min: 0, max: 1, step: 0.05, dependsOn: "mid_scene_guide" }, + { name: "embed_guidance", label: "Embed guidance", kind: "bool", default: false }, + { name: "embed_guidance_strength", label: "Embed strength", kind: "float", default: 0.02, min: 0, max: 0.5, step: 0.005, dependsOn: "embed_guidance" }, + { name: "decode_noise_scale", label: "Decode noise scale", kind: "float", default: 0.0, min: 0, max: 1, step: 0.01 }, + { name: "decode_timestep", label: "Decode timestep", kind: "float", default: 0.05, min: 0, max: 1, step: 0.01 }, + ]; + + function renderSamplerSettings(st) { + const p = st.project; + const si = p.sampler_inputs || {}; + const tag = el("div", "insp-tag"); tag.textContent = "Sampler settings"; body.append(tag); + + SAMPLER_KNOBS.forEach((k) => { + const dep = k.dependsOn; + if (dep) { + const depVal = si[dep] != null ? si[dep] : SAMPLER_KNOBS.find((x) => x.name === dep)?.default; + if (!depVal) return; + } + const val = si[k.name] != null ? si[k.name] : k.default; + let ctrl; + if (k.kind === "bool") { + ctrl = el("input"); ctrl.type = "checkbox"; ctrl.checked = !!val; ctrl.style.width = "auto"; + ctrl.dataset.k = "si-" + k.name; + ctrl.onchange = () => S.setSamplerInput(k.name, ctrl.checked); + } else { + ctrl = el("input"); ctrl.type = "number"; + if (k.step != null) ctrl.step = String(k.step); + if (k.min != null) ctrl.min = String(k.min); + if (k.max != null) ctrl.max = String(k.max); + ctrl.value = val; ctrl.dataset.k = "si-" + k.name; + ctrl.oninput = () => { + const v = k.kind === "int" ? parseInt(ctrl.value || "0", 10) : parseFloat(ctrl.value || "0"); + S.setSamplerInput(k.name, v); + }; + } + body.append(field(k.label, ctrl)); + }); } function renderSplit(st) { diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index 2f89866d..1def4b4b 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -78,6 +78,16 @@ function setSamplerSlot(slotId) { patchProject({ sampler_slot: slotId || "funpack" }); } + function setSamplerInput(name, value) { + if (!state.project) return; + const prev = state.project.sampler_inputs || {}; + patchProjectQuiet({ sampler_inputs: { ...prev, [name]: value } }); + } + function setStudioInput(name, value) { + if (!state.project) return; + const prev = state.project.studio_inputs || {}; + patchProjectQuiet({ studio_inputs: { ...prev, [name]: value } }); + } // ── editing ──────────────────────────────────────────────────────────────── function scheduleSave() { @@ -366,7 +376,7 @@ patchProject, patchProjectQuiet, patchScene, patchSceneQuiet, selectScene, addScene, removeScene, moveScene, scene, resizeScene, splitScene, snapFrames, refreshPreview, syncFromPreview, generate, loadModels, loadImageTargets, setModelInput, setModelLink, - setConditioningSlot, setSamplerSlot, + setConditioningSlot, setSamplerSlot, setSamplerInput, setStudioInput, loadMedia, uploadMedia, deleteMedia, assignMediaToScene, loadShortcuts, saveShortcut, deleteShortcut, importShortcuts, loadTransitions, saveTransition, deleteTransition, importTransitions, applyTransitionToSelection, insertShortcutIntoSelection, diff --git a/movie_editor/server.py b/movie_editor/server.py index 99b70699..e3f5f138 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -374,6 +374,9 @@ if web is not None and PromptServer is not None: "num_frames_per_scene": target.num_frames_per_scene, "frame_rate": target.frame_rate, "width": target.width, "height": target.height, + "negative_prompt": target.negative_prompt or None, + "studio_inputs": target.studio_inputs if target.conditioning_slot == "funpack" else {}, + "sampler_inputs": target.sampler_inputs if target.sampler_slot == "funpack" else {}, }, media=_prepare_media(target)) if report["blocking"]: detail = "Generation blocked — " + "; ".join(report["blocking"])