Gate FunPack-only features on Studio and Chain Sampler availability.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
f102c06f2f
commit
cc464395ba
@@ -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"
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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") },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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"])))
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user