diff --git a/movie_editor/backend/pipeline_caps.py b/movie_editor/backend/pipeline_caps.py
new file mode 100644
index 00000000..98728749
--- /dev/null
+++ b/movie_editor/backend/pipeline_caps.py
@@ -0,0 +1,68 @@
+"""Which FunPack pipeline nodes are active for a project (Studio / Chain Sampler).
+
+UI and generation use these flags so Studio-only and Chain-only features stay off when
+the built-in pipeline is disabled or custom conditioning/sampler slots are selected.
+"""
+from __future__ import annotations
+
+from typing import Any, Optional
+
+from .timeline import Scene, source_type
+
+
+def _models_dict(models: Optional[dict]) -> dict:
+ return models if isinstance(models, dict) else {}
+
+
+def uses_funpack_studio(project: Any, models: Optional[dict] = None) -> bool:
+ if _models_dict(models).get("disable_core"):
+ return False
+ slot = getattr(project, "conditioning_slot", None) or "funpack"
+ return slot == "funpack"
+
+
+def uses_chain_sampler(project: Any, models: Optional[dict] = None) -> bool:
+ if _models_dict(models).get("disable_core"):
+ return False
+ slot = getattr(project, "sampler_slot", None) or "funpack"
+ return slot == "funpack"
+
+
+def capabilities(project: Any, models: Optional[dict] = None) -> dict[str, bool]:
+ m = _models_dict(models)
+ return {
+ "studio": uses_funpack_studio(project, m),
+ "chain_sampler": uses_chain_sampler(project, m),
+ "disable_core": bool(m.get("disable_core")),
+ "imported_workflow": bool(m.get("workflow_import")),
+ }
+
+
+# Source modes that only make sense with FunPack Chain Sampler in the graph.
+_CHAIN_ONLY_SOURCES = frozenset({"carry", "mixed", "generated_frame"})
+
+
+def effective_source_type(scene: Scene, chain_available: bool) -> str:
+ """Runtime source mode for generation/media prep.
+
+ Without Chain Sampler: fall back to t2v (empty) unless the scene has an explicit
+ image anchor. Stored carry/mixed/etc. values are preserved in the project file.
+ """
+ st = source_type(scene)
+ if chain_available:
+ return st
+ if st == "image" and scene.source and scene.source.media_ref:
+ return "image"
+ if st == "v2v" and scene.source and scene.source.media_ref:
+ return "v2v"
+ return "empty"
+
+
+def source_needs_anchor_media(scene: Scene, chain_available: bool) -> bool:
+ """True when generation expects a media-bin asset for this scene's source."""
+ st = effective_source_type(scene, chain_available)
+ return st in ("image", "mixed", "generated_frame", "v2v")
+
+
+def is_chain_only_source_type(st: str) -> bool:
+ return st in _CHAIN_ONLY_SOURCES or st == "v2v"
diff --git a/movie_editor/frontend/engine_settings.js b/movie_editor/frontend/engine_settings.js
index bdc92ff2..4770dc3e 100644
--- a/movie_editor/frontend/engine_settings.js
+++ b/movie_editor/frontend/engine_settings.js
@@ -430,20 +430,39 @@
container.append(el("div", "pj-meta", "No project open."));
return;
}
- const presetBar = el("div", "engine-preset-bar");
- presetBar.append(el("span", "engine-preset-label", "Preset"));
- Object.entries(S.ENGINE_PRESETS || {}).forEach(([key, p]) => {
- const b = el("button", "btn ghost tiny", p.label || key);
- b.onclick = () => S.applyEnginePreset(key);
- presetBar.append(b);
- });
- container.append(presetBar);
+ const PC = window.PipelineCaps;
+ const studioOn = PC?.usesFunpackStudio(st);
+ const chainOn = PC?.usesChainSampler(st);
+ if (st.models?.disable_core) {
+ container.append(el("div", "insp-hint",
+ "Built-in FunPack pipeline is disabled — Studio and Chain Sampler settings are unavailable. Tune your workflow in Models."));
+ const foot = el("div", "insp-hint");
+ foot.append(el("span", null, "Workflow nodes: "));
+ const modelsLink = el("button", "btn ghost tiny", "Models & Pipeline…");
+ modelsLink.type = "button";
+ modelsLink.onclick = () => window.ModelsModal.open();
+ foot.append(modelsLink);
+ container.append(foot);
+ return;
+ }
+ if (!studioOn && !chainOn) {
+ container.append(el("div", "insp-hint",
+ "Neither FunPack Studio nor Chain Sampler is active — pick them under Pipeline nodes or use Models to wire custom nodes."));
+ }
+ if (studioOn || chainOn) {
+ const presetBar = el("div", "engine-preset-bar");
+ presetBar.append(el("span", "engine-preset-label", "Preset"));
+ Object.entries(S.ENGINE_PRESETS || {}).forEach(([key, p]) => {
+ const b = el("button", "btn ghost tiny", p.label || key);
+ b.onclick = () => S.applyEnginePreset(key);
+ presetBar.append(b);
+ });
+ container.append(presetBar);
+ }
const p = st.project;
const slots = (st.models?.slots || []);
const summary = el("div", "engine-modal-summary");
- const studioOn = !p.conditioning_slot || p.conditioning_slot === "funpack";
- const chainOn = !p.sampler_slot || p.sampler_slot === "funpack";
const parts = [];
parts.push(studioOn ? "FunPack Studio" : (slotLabelFor(st, p.conditioning_slot) || p.conditioning_slot));
parts.push(chainOn ? "Chain Sampler" : (slotLabelFor(st, p.sampler_slot) || p.sampler_slot));
diff --git a/movie_editor/frontend/index.html b/movie_editor/frontend/index.html
index 6d9243d7..0e10d199 100644
--- a/movie_editor/frontend/index.html
+++ b/movie_editor/frontend/index.html
@@ -102,6 +102,7 @@
+
diff --git a/movie_editor/frontend/inspector.js b/movie_editor/frontend/inspector.js
index c7bca10b..73e5150e 100644
--- a/movie_editor/frontend/inspector.js
+++ b/movie_editor/frontend/inspector.js
@@ -143,6 +143,47 @@
body.append(det);
return inner;
}
+ const PC = () => window.PipelineCaps;
+
+ function availableSourceOptions(st) {
+ if (PC()?.usesChainSampler(st)) return SRC;
+ return SRC.filter(([v]) => v === "empty" || v === "image");
+ }
+
+ function renderSourceField(st, root, scene) {
+ const chain = PC()?.usesChainSampler(st);
+ const stored = root.source?.type || (chain ? "carry" : "empty");
+ const opts = availableSourceOptions(st);
+ if (!opts.some(([v]) => v === stored) && PC()?.isChainOnlySource(stored)) {
+ const note = el("div", "insp-hint");
+ note.textContent = `Stored source “${PC().sourceLabel(stored)}” needs Chain Sampler — runs as text-to-video unless you pick an input image.`;
+ body.append(note);
+ }
+ const src = el("select");
+ opts.forEach(([v, label]) => {
+ const o = el("option", null, label);
+ o.value = v;
+ if (v === stored || (!opts.some(([vv]) => vv === stored) && v === "empty")) o.selected = true;
+ src.append(o);
+ });
+ if (stored && !opts.some(([v]) => v === stored)) {
+ const o = el("option", null, PC()?.sourceLabel(stored) + " (unavailable)");
+ o.value = stored;
+ o.selected = true;
+ src.append(o);
+ }
+ src.onchange = () => {
+ const prev = root.source?.type || (chain ? "carry" : "empty");
+ const next = src.value;
+ const patch = { source: { ...(root.source || {}), type: next } };
+ if (next === "image" || next === "empty") patch.guides = [];
+ else if (chain && (prev === "carry" || next === "carry" || prev === "mixed" || next === "mixed")) patch.guides = [];
+ S.patchScene(scene.id, patch);
+ };
+ body.append(field("Source", src));
+ const eff = PC()?.effectiveSourceType(root, st) || stored;
+ if (eff === "image") renderImageSource(st, root, body);
+ }
function renderVideoSource(st, scene, parent, label) {
parent = parent || body;
@@ -217,20 +258,11 @@
const ta = el("textarea"); ta.rows = 4; ta.value = root.text || ""; ta.placeholder = "Describe this scene…"; ta.dataset.k = "sc-text";
ta.oninput = () => S.patchSceneQuiet(scene.id, { text: ta.value });
body.append(field("Prompt", ta));
- const src = el("select");
- SRC.forEach(([v, label]) => { const o = el("option", null, label); o.value = v; if ((root.source?.type) === v) o.selected = true; src.append(o); });
- src.onchange = () => {
- const prev = root.source?.type || "carry";
- const next = src.value;
- const patch = { source: { ...(root.source || {}), type: next } };
- if (next === "image" || next === "empty" || next === "generated_frame") patch.guides = [];
- else if (prev !== next && (prev === "carry" || next === "carry" || prev === "mixed" || next === "mixed")) patch.guides = [];
- S.patchScene(scene.id, patch);
- };
- body.append(field("Source", src));
- if ((root.source?.type) === "image" || (root.source?.type) === "mixed") renderImageSource(st, root, body);
- if ((root.source?.type) === "generated_frame") renderGeneratedFrameSource(st, root);
- if ((root.source?.type) === "v2v") renderVideoSource(st, root, body, "V2V source video");
+ renderSourceField(st, root, scene);
+ if (PC()?.usesChainSampler(st)) {
+ if ((root.source?.type) === "generated_frame") renderGeneratedFrameSource(st, root);
+ if ((root.source?.type) === "v2v") renderVideoSource(st, root, body, "V2V source video");
+ }
const effFrames = effOf(scene, "frames"), effFps = effOf(scene, "fps") || 1;
body.append(el("div", "insp-hint", `Duration ≈ ${(effFrames / effFps).toFixed(2)}s · trim on timeline · Convert to video on the timeline toolbar · generation splits: global prompt or Outgoing seam below`));
@@ -244,7 +276,7 @@
body.append(actions);
foldSection("More editing", false, (more) => {
- renderSceneCharacters(st, scene, more);
+ if (PC()?.usesFunpackStudio(st)) renderSceneCharacters(st, scene, more);
const fxTag = el("div", "insp-tag"); fxTag.textContent = "Video effects"; more.append(fxTag);
const fx = scene.effects || {};
@@ -337,7 +369,7 @@
more.append(reset);
}
- renderSceneGuides(st, scene, more);
+ if (PC()?.usesChainSampler(st)) renderSceneGuides(st, scene, more);
const row = el("div", "insp-block");
const chk = el("label", "chk"); const cb = el("input"); cb.type = "checkbox"; cb.checked = !!scene.excluded;
@@ -423,16 +455,28 @@
function renderEngineStrip(st) {
const p = st.project;
- const studioOn = !p.conditioning_slot || p.conditioning_slot === "funpack";
- const chainOn = !p.sampler_slot || p.sampler_slot === "funpack";
+ const strip = el("div", "insp-engine-strip");
+ const c = PC()?.caps(st) || {};
+ if (c.disable_core || c.imported_workflow) {
+ const name = st.models?.workflow_import?.name || "Custom workflow";
+ strip.append(el("span", "insp-engine-strip-txt", `Generating with imported workflow (${name})`));
+ const btn = el("button", "btn ghost tiny", "Models →");
+ btn.onclick = () => window.ModelsModal.open();
+ strip.append(btn);
+ body.append(strip);
+ return;
+ }
+ const studioOn = PC()?.usesFunpackStudio(st);
+ const chainOn = PC()?.usesChainSampler(st);
const parts = [];
parts.push(studioOn ? "FunPack Studio" : (slotLabelFor(st, p.conditioning_slot) || p.conditioning_slot));
parts.push(chainOn ? "Chain Sampler" : (slotLabelFor(st, p.sampler_slot) || p.sampler_slot));
- const strip = el("div", "insp-engine-strip");
strip.append(el("span", "insp-engine-strip-txt", "Generating with " + parts.join(" + ")));
- const btn = el("button", "btn ghost tiny", "Engine settings →");
- btn.onclick = () => window.EngineSettingsModal.open();
- strip.append(btn);
+ if (studioOn || chainOn) {
+ const btn = el("button", "btn ghost tiny", "Engine settings →");
+ btn.onclick = () => window.EngineSettingsModal.open();
+ strip.append(btn);
+ }
body.append(strip);
}
@@ -483,6 +527,7 @@
function renderSceneGuides(st, scene, parent) {
parent = parent || body;
+ if (!PC()?.usesChainSampler(st)) return;
if (!normGuideSettings(st.project).stack_enabled) return;
const sceneNo = st.project.scenes.indexOf(scene) + 1;
const tag = el("div", "insp-tag"); tag.textContent = "i2v guides"; parent.append(tag);
@@ -585,7 +630,10 @@
const why = val.anchors_changed_since_last_queue && !val.text_changed_since_last_queue
? "Anchor or source mode changed since last generate"
: "Prompt or anchors changed since last generate";
- w.append(el("span", null, `${why} — next run rebuilds guides and clears stale action/detail repairs; Studio training history is kept.`));
+ const studioNote = PC()?.usesFunpackStudio(st)
+ ? " — next run rebuilds guides and clears stale action/detail repairs; Studio training history is kept."
+ : ".";
+ w.append(el("span", null, why + studioNote));
box.append(w);
}
if (pv.parse_error) { const w = el("div", "pv-warn"); w.append(el("span", null, "▲")); w.append(el("span", null, "ComfyUI offline — preview paused")); box.append(w); }
diff --git a/movie_editor/frontend/menubar.js b/movie_editor/frontend/menubar.js
index 647cf119..180a0bd0 100644
--- a/movie_editor/frontend/menubar.js
+++ b/movie_editor/frontend/menubar.js
@@ -53,6 +53,10 @@
function menuSpec() {
const st = S.get();
+ const PC = window.PipelineCaps;
+ const studioOn = PC?.usesFunpackStudio(st);
+ const chainOn = PC?.usesChainSampler(st);
+ const customPipe = !!(st.models?.disable_core);
const recent = (st.projects || []).slice(0, 8).map((p) => ({
label: p.name, hint: `${p.scene_count}▦`, action: () => S.loadProject(p.id),
}));
@@ -88,21 +92,24 @@
{ label: "Reset Layout", action: () => { const r = document.documentElement; r.style.removeProperty("--media-w"); r.style.removeProperty("--timeline-h"); } },
],
Settings: [
- { label: "Engine settings…", disabled: !hasProject(), action: () => window.EngineSettingsModal.open() },
+ { label: "Engine settings…", disabled: !hasProject() || (!studioOn && !chainOn),
+ action: () => window.EngineSettingsModal.open() },
{ sep: true },
{ label: "Models…", action: () => window.ModelsModal.open() },
{ label: "Import ComfyUI Workflow…", disabled: !hasProject(),
action: () => window.WorkflowImportWizard?.open() },
{ label: "Refresh model list", hint: "R", action: async () => { try { await window.MovieEditorAPI.refreshModels(); } catch (_) {} } },
{ sep: true },
- { label: `Conditioning: ${_roleLabel(st.project?.conditioning_slot, "FunPack Studio")}`, disabled: !hasProject(),
+ { label: `Conditioning: ${_roleLabel(st.project?.conditioning_slot, "FunPack Studio")}`,
+ disabled: !hasProject() || customPipe,
action: () => _pickRole("conditioning_slot", "Conditioning node", st) },
- { label: `Sampler: ${_roleLabel(st.project?.sampler_slot, "FunPack Chain Sampler")}`, disabled: !hasProject(),
+ { label: `Sampler: ${_roleLabel(st.project?.sampler_slot, "FunPack Chain Sampler")}`,
+ disabled: !hasProject() || customPipe,
action: () => _pickRole("sampler_slot", "Sampler node", st) },
],
FunPack: [
{ label: st.resetSessionArmed ? "Reset Studio session ✓ armed — click to cancel" : "Reset Studio session",
- disabled: !hasProject(), action: () => S.resetStudioSession() },
+ disabled: !hasProject() || !studioOn, action: () => S.resetStudioSession() },
{ sep: true },
{ menulabel: "Libraries (in ComfyUI Studio)" },
{ label: "Open ComfyUI", hint: "↗", action: () => window.open("/", "_blank") },
diff --git a/movie_editor/frontend/models.js b/movie_editor/frontend/models.js
index ef615bb2..61c53d79 100644
--- a/movie_editor/frontend/models.js
+++ b/movie_editor/frontend/models.js
@@ -543,6 +543,12 @@
function requirementsPanel() {
const sec = el("div", "req-panel");
+ if (config.disable_core) {
+ sec.append(el("div", "req-title", "Custom workflow"));
+ sec.append(el("div", "req-empty",
+ "Built-in FunPack pipeline is off. Wire your workflow nodes below; FunPack loader requirements do not apply."));
+ return sec;
+ }
sec.append(el("div", "req-title", "Pipeline requirements"));
if (!requirements.length) {
diff --git a/movie_editor/frontend/pipeline_caps.js b/movie_editor/frontend/pipeline_caps.js
new file mode 100644
index 00000000..27e4b7d9
--- /dev/null
+++ b/movie_editor/frontend/pipeline_caps.js
@@ -0,0 +1,68 @@
+// Shared FunPack pipeline capability flags (Studio / Chain Sampler availability).
+(function () {
+ const CHAIN_ONLY = new Set(["carry", "mixed", "generated_frame", "v2v"]);
+
+ function models(st) {
+ return (st && st.models) || {};
+ }
+
+ function usesFunpackStudio(st) {
+ if (models(st).disable_core) return false;
+ const slot = (st && st.project && st.project.conditioning_slot) || "funpack";
+ return slot === "funpack";
+ }
+
+ function usesChainSampler(st) {
+ if (models(st).disable_core) return false;
+ const slot = (st && st.project && st.project.sampler_slot) || "funpack";
+ return slot === "funpack";
+ }
+
+ function caps(st) {
+ const m = models(st);
+ return {
+ studio: usesFunpackStudio(st),
+ chain_sampler: usesChainSampler(st),
+ disable_core: !!m.disable_core,
+ imported_workflow: !!m.workflow_import,
+ };
+ }
+
+ function effectiveSourceType(scene, st) {
+ const t = (scene && scene.source && scene.source.type) || "carry";
+ if (usesChainSampler(st)) return t;
+ if (t === "image" && scene.source && scene.source.media_ref) return "image";
+ if (t === "v2v" && scene.source && scene.source.media_ref) return "v2v";
+ return "empty";
+ }
+
+ function isChainOnlySource(type) {
+ return CHAIN_ONLY.has(type);
+ }
+
+ function defaultSceneSourceType(st) {
+ return usesChainSampler(st) ? "carry" : "empty";
+ }
+
+ function sourceLabel(type) {
+ const map = {
+ empty: "Empty · text-to-video",
+ image: "Image · i2v anchor",
+ generated_frame: "From generated frame",
+ v2v: "Video · v2v source",
+ carry: "Carry i2v guide · continue previous",
+ mixed: "Mixed · Img2Video anchor + prior guides",
+ };
+ return map[type] || type;
+ }
+
+ window.PipelineCaps = {
+ usesFunpackStudio,
+ usesChainSampler,
+ caps,
+ effectiveSourceType,
+ isChainOnlySource,
+ defaultSceneSourceType,
+ sourceLabel,
+ };
+})();
diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js
index 60d3f114..c0782240 100644
--- a/movie_editor/frontend/store.js
+++ b/movie_editor/frontend/store.js
@@ -233,7 +233,7 @@
gen_unit_id: id,
text: ps.text || "",
transition_to_next: "",
- source: { type: "carry" },
+ source: { type: window.PipelineCaps?.defaultSceneSourceType(state) || "carry" },
excluded: false,
};
}
@@ -246,7 +246,7 @@
gen_unit_id: id,
text: text || ghost.text || "",
transition_to_next: "",
- source: { type: "carry" },
+ source: { type: window.PipelineCaps?.defaultSceneSourceType(state) || "carry" },
excluded: false,
frames: ghost.frames,
frames_mode: ghost.frames_mode,
@@ -973,9 +973,8 @@
function addScene() {
if (!state.project) return;
_historyRecord();
- // Default to "carry": a new scene continues the previous one (overlap) unless the
- // user picks an anchor/empty. Scene 1 carrying just starts a fresh run.
- const s = { text: "", transition_to_next: "", source: { type: "carry" }, excluded: false };
+ const srcType = window.PipelineCaps?.defaultSceneSourceType(state) || "carry";
+ const s = { text: "", transition_to_next: "", source: { type: srcType }, excluded: false };
state.project.scenes.push(s);
window.Timeline?.requestAutoFit?.();
notify(); scheduleSave(); // server assigns id; reselect after commit
@@ -1771,6 +1770,9 @@
}
function _runs() {
const active = state.project.scenes.filter((s) => !s.excluded && isGenerativeScene(s));
+ if (window.PipelineCaps && !window.PipelineCaps.usesChainSampler(state)) {
+ return active.length ? [active.map((s) => s.id)] : [];
+ }
const runs = [];
for (const s of active) {
const t = (s.source && s.source.type) || "empty";
@@ -2578,6 +2580,10 @@
// ── pluggable models / exposed controls ──────────────────────────────────────
async function loadModels() {
try { state.models = await API.getModels(state.project?.id); } catch (_) { state.models = { slots: [] }; }
+ if (window.PipelineCaps && !window.PipelineCaps.usesFunpackStudio(state)) {
+ _resetSessionPending = false;
+ state.resetSessionArmed = false;
+ }
notify();
loadImageTargets();
}
@@ -2779,6 +2785,7 @@
}
// True when this scene continues the current chain run (not a new run anchor).
function _continuesChainRun(s) {
+ if (window.PipelineCaps && !window.PipelineCaps.usesChainSampler(state)) return false;
const active = state.project.scenes.filter((sc) => !sc.excluded);
const idx = active.findIndex((sc) => sc.id === s.id);
if (idx <= 0) return false;
diff --git a/movie_editor/frontend/timeline.js b/movie_editor/frontend/timeline.js
index e0691d43..b8ac784f 100644
--- a/movie_editor/frontend/timeline.js
+++ b/movie_editor/frontend/timeline.js
@@ -1151,10 +1151,10 @@
// ── toolbar ─────────────────────────────────────────────────────────────────────
function toolbarRatingBlock(st, p) {
const wrap = el("div", "tl-rating-block");
- const studioCond = !st.project.conditioning_slot || st.project.conditioning_slot === "funpack";
+ const studioOn = window.PipelineCaps?.usesFunpackStudio(st);
const hasSel = !!st.selectedSceneId;
const Picker = window.MovieRatingPicker;
- if (!studioCond || !hasSel || !hasRender(st, st.selectedSceneId) || !Picker) return wrap;
+ if (!studioOn || !hasSel || !hasRender(st, st.selectedSceneId) || !Picker) return wrap;
const sc = S.scene(st.selectedSceneId);
if (!sc || !S.isGenerativeScene(sc)) return wrap;
const sceneNo = p.scenes.indexOf(sc) + 1;
diff --git a/movie_editor/server.py b/movie_editor/server.py
index 2788adde..b9a4cd55 100644
--- a/movie_editor/server.py
+++ b/movie_editor/server.py
@@ -19,7 +19,7 @@ except Exception: # pragma: no cover - only available inside ComfyUI
web = None
PromptServer = None
-from .backend import bridge, builder, config, media, nodes, projects, workflow_import
+from .backend import bridge, builder, config, media, nodes, pipeline_caps, projects, workflow_import
from .backend.nle_effects import zoompan_z_expr
from .backend.timeline import (
Project,
@@ -184,7 +184,7 @@ def _copy_scene_media(ref: str, indir: str) -> Optional[str]:
return fn
-def _prepare_media(proj: Project, extra_refs: Optional[list] = None) -> Optional[dict]:
+def _prepare_media(proj: Project, extra_refs: Optional[list] = None, *, chain_available: bool = True) -> Optional[dict]:
"""Copy scene image assets into ComfyUI's input folder.
Returns {"primary": {filename, target}, "by_scene": {scene_id: {filename, media_ref, target}}}
@@ -223,7 +223,7 @@ def _prepare_media(proj: Project, extra_refs: Optional[list] = None) -> Optional
src = sc.source
ref = getattr(src, "media_ref", None)
tgt = getattr(src, "target", None)
- stype = getattr(src, "type", "") if src else ""
+ stype = pipeline_caps.effective_source_type(sc, chain_available)
if stype not in ("image", "generated_frame", "mixed") or not ref:
continue
_add_ref(ref, tgt, sc.id, set_primary=True)
@@ -297,7 +297,13 @@ def _solo(p: Project, only_scene: Optional[str]) -> Project:
return collapse_generative_units(_segment(p, ids))
-def _run_studio_inputs(target: Project, active_scenes: list, *, prompt_changed: bool = False) -> dict:
+def _run_studio_inputs(
+ target: Project,
+ active_scenes: list,
+ *,
+ prompt_changed: bool = False,
+ models: Optional[dict] = None,
+) -> dict:
"""Studio widget overrides for this run, incl. the RLHF rating of the run's last
render (Studio refines from it).
@@ -311,7 +317,7 @@ def _run_studio_inputs(target: Project, active_scenes: list, *, prompt_changed:
When the generation prompt changed since the last queue, send
MOVIE_EDITOR_FRESH_PROMPT_RATING instead — clears stale repairs only, keeps training.
refine_v2 uses Initial discovery when there is no previous run to refine from."""
- if target.conditioning_slot != "funpack":
+ if not pipeline_caps.uses_funpack_studio(target, models):
return {}
si = dict(target.studio_inputs or {})
try:
@@ -363,13 +369,19 @@ def _resolve_run_seed(target: Project) -> int:
return random.randint(1, 0xFFFFFFFFFFFFFFFF)
-def _run_sampler_inputs(target: Project, scene_count: int, full: Optional[Project] = None) -> dict:
+def _run_sampler_inputs(
+ target: Project,
+ scene_count: int,
+ full: Optional[Project] = None,
+ *,
+ models: Optional[dict] = None,
+) -> dict:
"""Sampler widget overrides for one generation run.
Auto continuity (project.continuity_settings) builds guides and sampler knobs per run
for carry, mixed, image, and empty sources. Manual guide_settings.stack_enabled wins.
"""
- if target.sampler_slot != "funpack":
+ if not pipeline_caps.uses_chain_sampler(target, models):
return {}
import json
from .backend.timeline import (
@@ -434,8 +446,10 @@ def _missing_continuity_media(full: Project, target: Project) -> list[str]:
return missing
-def _missing_scene_anchor_media(target: Project) -> list[str]:
+def _missing_scene_anchor_media(target: Project, *, chain_available: bool = True) -> list[str]:
"""i2v anchor images referenced on scenes but missing from the media bin."""
+ if not chain_available:
+ return []
from .backend.timeline import scene_anchor_media_refs
missing: list[str] = []
@@ -1169,6 +1183,10 @@ if web is not None and PromptServer is not None:
oi = await bridge.object_info()
except Exception as e: # noqa: BLE001
return web.json_response({"detail": f"Node registry unavailable: {e}"}, status=502)
+ models_cfg = _project_models(p)
+ caps = pipeline_caps.capabilities(p, models_cfg)
+ if not caps["studio"]:
+ reset_session = False
bridge.reset_progress()
bridge.current_progress() # ensure the sampler step-progress hook is installed
active_scenes = [s for s in target.scenes if not s.excluded and not is_video_clip(s)]
@@ -1178,14 +1196,14 @@ if web is not None and PromptServer is not None:
{"detail": "Nothing to generate — all clips are video-only or excluded."},
status=400,
)
- missing_media = _missing_continuity_media(p, target)
+ missing_media = _missing_continuity_media(p, target) if caps["chain_sampler"] else []
if missing_media:
ids = ", ".join(missing_media)
return web.json_response(
{"detail": f"Missing media for continuity or guide stack ({ids}). Re-upload or clear the reference in Engine settings / Scene inspector."},
status=400,
)
- missing_anchors = _missing_scene_anchor_media(target)
+ missing_anchors = _missing_scene_anchor_media(target, chain_available=caps["chain_sampler"])
if missing_anchors:
ids = ", ".join(missing_anchors)
return web.json_response(
@@ -1207,17 +1225,24 @@ if web is not None and PromptServer is not None:
else target.num_frames_per_scene
)
try:
- media_pack = _prepare_media(target, continuity_media_refs(p, target))
- sampler_inputs = _run_sampler_inputs(target, active_scene_count, full=p)
- _attach_scene_anchors(sampler_inputs, media_pack, target)
- graph, report = builder.build(oi, _project_models(target), {
+ media_pack = _prepare_media(
+ target,
+ continuity_media_refs(p, target) if caps["chain_sampler"] else [],
+ chain_available=caps["chain_sampler"],
+ )
+ sampler_inputs = _run_sampler_inputs(target, active_scene_count, full=p, models=models_cfg)
+ if caps["chain_sampler"]:
+ _attach_scene_anchors(sampler_inputs, media_pack, target)
+ graph, report = builder.build(oi, models_cfg, {
"prompt": prompt, "seed": _resolve_run_seed(target),
"num_frames_per_scene": effective_frames,
"frame_rate": target.frame_rate,
"width": target.width, "height": target.height,
"negative_prompt": effective_negative_prompt(target) or None,
"max_scenes": active_scene_count,
- "studio_inputs": _run_studio_inputs(target, active_scenes, prompt_changed=prompt_changed),
+ "studio_inputs": _run_studio_inputs(
+ target, active_scenes, prompt_changed=prompt_changed, models=models_cfg,
+ ),
"sampler_inputs": sampler_inputs,
"reset_session": reset_session,
}, media=(media_pack or {}).get("primary") if media_pack else None)
@@ -1573,6 +1598,11 @@ if web is not None and PromptServer is not None:
# Per-project pipeline config (slots + links). Falls back to the global default
# when the project has none yet; saving also updates the global default so the
# next NEW project inherits your latest loaders.
+ @routes.get(UI_PREFIX + "/api/projects/{pid}/pipeline-caps")
+ async def _pipeline_caps(req):
+ p = _project_or_404(req.match_info["pid"])
+ return web.json_response(pipeline_caps.capabilities(p, _project_models(p)))
+
@routes.get(UI_PREFIX + "/api/projects/{pid}/models")
async def _project_models_get(req):
return web.json_response(_project_models(_project_or_404(req.match_info["pid"])))
diff --git a/movie_editor/tests/test_pipeline_caps.py b/movie_editor/tests/test_pipeline_caps.py
new file mode 100644
index 00000000..d96eb113
--- /dev/null
+++ b/movie_editor/tests/test_pipeline_caps.py
@@ -0,0 +1,47 @@
+"""Tests for pipeline capability flags (Studio / Chain Sampler availability)."""
+import sys
+from pathlib import Path
+from types import SimpleNamespace
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT))
+
+from movie_editor.backend import pipeline_caps # noqa: E402
+from movie_editor.backend.timeline import Project, Scene, SceneSource # noqa: E402
+
+
+def _scene(stype: str, ref: str | None = None) -> Scene:
+ return Scene(
+ id="s1",
+ text="x",
+ source=SceneSource(type=stype, media_ref=ref),
+ )
+
+
+def test_disable_core_turns_off_studio_and_chain():
+ p = Project(name="t")
+ m = {"disable_core": True, "workflow_import": {"name": "W"}}
+ c = pipeline_caps.capabilities(p, m)
+ assert c["studio"] is False
+ assert c["chain_sampler"] is False
+
+
+def test_custom_slots_without_disable_core():
+ p = Project(name="t", conditioning_slot="custom1", sampler_slot="custom2")
+ c = pipeline_caps.capabilities(p, {"slots": []})
+ assert c["studio"] is False
+ assert c["chain_sampler"] is False
+
+
+def test_effective_source_fallback_without_chain():
+ sc = _scene("carry")
+ assert pipeline_caps.effective_source_type(sc, False) == "empty"
+ sc2 = _scene("mixed", "img1")
+ assert pipeline_caps.effective_source_type(sc2, False) == "empty"
+ sc3 = _scene("image", "img1")
+ assert pipeline_caps.effective_source_type(sc3, False) == "image"
+
+
+def test_effective_source_unchanged_with_chain():
+ sc = _scene("carry")
+ assert pipeline_caps.effective_source_type(sc, True) == "carry"