diff --git a/movie_editor/backend/builder.py b/movie_editor/backend/builder.py index d97aae73..e4162e90 100644 --- a/movie_editor/backend/builder.py +++ b/movie_editor/backend/builder.py @@ -537,6 +537,19 @@ def core_graph(object_info: dict, models_config: dict | None = None) -> list[dic for o in node_outputs(object_info.get(s.get("node_class")) or {}): producers.setdefault(o["type"], []).append((f"out:{sid}:{o['name']}", f"{lbl} · {o['name']}")) + # Slot inputs that pull from a core output (input_sources "core::") so each + # core output can show where it's tapped — e.g. Studio · cond → MyCustomSampler · positive. + slot_consumers: dict[tuple[str, int], list[str]] = {} + for s in slots: + lbl = slot_label.get(s.get("id"), s.get("id")) + for inp, src in (s.get("input_sources") or {}).items(): + if isinstance(src, str) and src.startswith("core:"): + try: + _, ccid, cidx = src.split(":", 2) + slot_consumers.setdefault((ccid, int(cidx)), []).append(f"{lbl} · {inp}") + except ValueError: + pass + open_by_node: dict[str, dict] = {} for (cid, inp, t, req) in OPEN_PORTS: open_by_node.setdefault(cid, {})[inp] = (t, req) @@ -575,7 +588,9 @@ def core_graph(object_info: dict, models_config: dict | None = None) -> list[dic outputs = [] for i, o in enumerate(node_outputs(nd or {})): dests = [f"{d} · {di}" for (oi, d, di) in rev.get(cid, []) if oi == i] - outputs.append({"name": o["name"], "type": o["type"], "to": dests}) + dests += slot_consumers.get((cid, i), []) + outputs.append({"name": o["name"], "type": o["type"], "to": dests, + "source_id": f"core:{cid}:{i}"}) nodes.append({"id": cid, "class": cls, "display_name": (nd or {}).get("display_name", cls), "installed": cls in object_info, diff --git a/movie_editor/backend/nodes.py b/movie_editor/backend/nodes.py index be0c1d19..a2b8b840 100644 --- a/movie_editor/backend/nodes.py +++ b/movie_editor/backend/nodes.py @@ -329,13 +329,35 @@ def pipeline_requirements() -> list[dict]: return PIPELINE_REQUIREMENTS -def core_producers() -> list[dict]: - """Typed outputs from the fixed core that slot nodes can source from.""" - return [ +def core_producers(object_info: dict | None = None) -> list[dict]: + """Typed outputs from the fixed core that slot nodes can source from. + + The project primitives are always available. When ``object_info`` is supplied, every + installed core node's outputs are exposed too — so in Full control a custom node (e.g. + a replacement sampler) can be fed Studio's model / conditioning / sigmas, or the + sampler's latent. The slot picker only offers these in Full control (see allowedSources); + build() resolves the chosen ``core::`` via _resolve_source.""" + out = [ {"id": "core:frames:0", "type": "INT", "label": "Project frames (primitive)"}, {"id": "core:fps:0", "type": "FLOAT", "label": "Project FPS (primitive)"}, {"id": "core:f2i:0", "type": "INT", "label": "Project FPS as int (primitive)"}, ] + if not object_info: + return out + from . import builder # lazy: avoid import cycle at module load + seen = {p["id"] for p in out} + for cid, cls in builder.CORE.items(): + nd = object_info.get(cls) + if not nd: + continue + node_label = nd.get("display_name", cls) + for i, o in enumerate(node_outputs(nd)): + pid = f"core:{cid}:{i}" + if pid in seen: + continue + seen.add(pid) + out.append({"id": pid, "type": o["type"], "label": f"{node_label} → {o['name']}"}) + return out def pipeline_ports(object_info: dict | None = None) -> list[dict]: diff --git a/movie_editor/frontend/inspector.js b/movie_editor/frontend/inspector.js index 04e3454e..a5bbc9a9 100644 --- a/movie_editor/frontend/inspector.js +++ b/movie_editor/frontend/inspector.js @@ -5,8 +5,9 @@ const body = document.getElementById("inspector-body"); const title = document.getElementById("inspector-title"); + // t2v ("empty") is intentionally not selectable — scenes are i2v/v2v/carry. An + // anchorless scene still falls back to t2v in the engine (effectiveSourceType). const SRC = [ - ["empty", "Empty · text-to-video"], ["image", "Image · i2v anchor"], ["generated_frame", "From generated frame"], ["v2v", "Video · v2v source"], @@ -130,12 +131,15 @@ function availableSourceOptions(st) { if (PC()?.usesChainSampler(st)) return SRC; - return SRC.filter(([v]) => v === "empty" || v === "image"); + return SRC.filter(([v]) => v === "image"); } function renderSourceField(st, root, scene) { const chain = PC()?.usesChainSampler(st); - const stored = root.source?.type || (chain ? "carry" : "empty"); + // t2v is removed as a mode — surface a legacy/default "empty" scene as the i2v + // anchor option (no committed change until the user edits or assigns an anchor). + let stored = root.source?.type || (chain ? "carry" : "image"); + if (stored === "empty") stored = "image"; const opts = availableSourceOptions(st); if (!opts.some(([v]) => v === stored) && PC()?.isChainOnlySource(stored)) { const note = el("div", "insp-hint"); @@ -146,7 +150,7 @@ 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; + if (v === stored) o.selected = true; src.append(o); }); if (stored && !opts.some(([v]) => v === stored)) { diff --git a/movie_editor/frontend/models.js b/movie_editor/frontend/models.js index f5e05875..dee2af6d 100644 --- a/movie_editor/frontend/models.js +++ b/movie_editor/frontend/models.js @@ -979,7 +979,10 @@ }); card.append(box); } - const outs = (n.outputs || []).filter((o) => (o.to || []).length); + // Show every core output. In Full control these are tappable as slot input sources + // (pick " → " in a slot's Input sources), so the user can, e.g., feed a + // replacement sampler with Studio's conditioning. In guided mode they're informational. + const outs = n.outputs || []; if (outs.length) { const box = el("div", "wire-box"); box.append(el("div", "wire-title", "Outputs → destinations")); @@ -987,7 +990,12 @@ const row = el("div", "wire-row"); row.append(el("span", "wire-out", `${o.name} (${o.type})`)); row.append(el("span", "wire-arrow", "→")); - row.append(el("span", "wire-dest", o.to.join(", "))); + const dest = (o.to || []).length + ? o.to.join(", ") + : (config.full_control ? "available — pick in a slot's Input sources" : "—"); + const destEl = el("span", "wire-dest", dest); + if (!(o.to || []).length && !config.full_control) destEl.classList.add("wire-muted"); + row.append(destEl); box.append(row); }); card.append(box); diff --git a/movie_editor/frontend/pipeline_caps.js b/movie_editor/frontend/pipeline_caps.js index 27e4b7d9..d5a71baf 100644 --- a/movie_editor/frontend/pipeline_caps.js +++ b/movie_editor/frontend/pipeline_caps.js @@ -41,7 +41,9 @@ } function defaultSceneSourceType(st) { - return usesChainSampler(st) ? "carry" : "empty"; + // t2v ("empty") is no longer a user-facing mode — new scenes default to an i2v + // anchor (image). Anchorless scenes still fall back to t2v in the engine. + return usesChainSampler(st) ? "carry" : "image"; } function sourceLabel(type) { diff --git a/movie_editor/frontend/player.js b/movie_editor/frontend/player.js index c0deafd9..4a0dec76 100644 --- a/movie_editor/frontend/player.js +++ b/movie_editor/frontend/player.js @@ -737,6 +737,9 @@ // ── transport actions ────────────────────────────────────────────────────── function _seekNow(sec) { + // Any transport scrub/seek exits the transient media-bin preview so the program + // resumes following the playhead (otherwise it stays frozen on the previewed image). + if (S.get().mediaPreviewId) S.clearMediaPreview(); _phSec = Math.max(0, sec); const clip = _clipAt(_phSec); if (!clip) { @@ -808,6 +811,7 @@ } function _play() { + if (S.get().mediaPreviewId) S.clearMediaPreview(); // At (or past) the end of the timeline → loop the playhead back to the start. if (_phSec >= (_totalSecCur || 0) - 0.05 || !_clipFrom(_phSec)) _phSec = 0; const clip = _clipFrom(_phSec); diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index 0313693b..d07f695f 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -1016,6 +1016,7 @@ if (!scene(id)) return; state.selectedOverlayId = null; state.selectedAudioTrackId = null; + state.mediaPreviewId = null; // tapping a clip exits the transient media-bin preview if (range && _selectionAnchorId && order.includes(_selectionAnchorId)) { const a = order.indexOf(_selectionAnchorId); diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index 5fab3758..5400aaa9 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -1237,6 +1237,7 @@ label.field.linked input, label.field.linked select { opacity: .6; } .wire-out { font-family: var(--font-mono); font-size: 11px; color: var(--muted); flex: 0 0 auto; min-width: 130px; } .wire-arrow { color: var(--teal); } .wire-dest { font-family: var(--font-mono); font-size: 11px; color: var(--muted); } +.wire-dest.wire-muted { color: var(--faint); opacity: .7; } .wire-select { flex: 1; } .wire-multi { display: flex; flex-direction: column; gap: 4px; flex: 1; } .wire-multi-row { display: flex; gap: 5px; align-items: center; } diff --git a/movie_editor/frontend/timeline.js b/movie_editor/frontend/timeline.js index 4ec9e959..e3f60e9a 100644 --- a/movie_editor/frontend/timeline.js +++ b/movie_editor/frontend/timeline.js @@ -601,9 +601,26 @@ }); } - // i2v anchors are set on the PLAN, not here — the timeline is the rendered result. - // (Drop an image onto a Plan card to anchor that scene.) Video clips still drop on the - // timeline track below. + // Drop an image (or video) from the Media bin onto a generative clip to set its i2v + // anchor. This used to live on the Plan card; the single-timeline layout moved it here. + if (!subclip && S.isGenerativeScene(scene)) { + clip.addEventListener("dragover", (e) => { + if (!e.dataTransfer.types.includes("application/funpack-media")) return; + e.preventDefault(); e.stopPropagation(); + e.dataTransfer.dropEffect = "copy"; + clip.classList.add("drop-target"); + }); + clip.addEventListener("dragleave", (e) => { + if (!clip.contains(e.relatedTarget)) clip.classList.remove("drop-target"); + }); + clip.addEventListener("drop", (e) => { + clip.classList.remove("drop-target"); + const id = e.dataTransfer.getData("application/funpack-media"); + if (!id) return; + e.preventDefault(); e.stopPropagation(); + S.assignMediaToScene(scene.id, id); + }); + } // i2v anchor thumbnail (image + mixed + generated_frame) const mref = anchorMediaRef(scene, p); diff --git a/movie_editor/server.py b/movie_editor/server.py index 018b4007..69baf4e3 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -1789,7 +1789,7 @@ if web is not None and PromptServer is not None: oi = None return web.json_response({ "ports": nodes.pipeline_ports(oi), - "core_producers": nodes.core_producers(), + "core_producers": nodes.core_producers(oi), "requirements": nodes.pipeline_requirements(), "wiring": pipeline_wiring.wiring_rules_payload(), })