Gate FunPack-only features on Studio and Chain Sampler availability.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
digital-garbage
2026-06-11 21:57:17 +03:00
co-authored by Cursor
parent f102c06f2f
commit cc464395ba
11 changed files with 360 additions and 59 deletions
+68
View File
@@ -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"
+29 -10
View File
@@ -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));
+1
View File
@@ -102,6 +102,7 @@
<script src="api.js"></script>
<script src="dom.js"></script>
<script src="pipeline_caps.js"></script>
<script src="view_bus.js"></script>
<script src="history.js"></script>
<script src="media_picker.js"></script>
+71 -23
View File
@@ -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); }
+11 -4
View File
@@ -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") },
+6
View File
@@ -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) {
+68
View File
@@ -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,
};
})();
+12 -5
View File
@@ -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;
+2 -2
View File
@@ -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;
+45 -15
View File
@@ -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"])))
+47
View File
@@ -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"