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"