opencut_ui: fix image-drop anchor, preview stick, t2v removal, full rewiring

1. Image drop → i2v anchor: re-wire S.assignMediaToScene onto the timeline clip
   (it was orphaned when the Plan card, its only caller, was removed).
2. Full rewiring in Full control: nodes.core_producers() now enumerates every
   installed core node's outputs (Studio model/conditioning/sigmas, sampler
   latent/images…), so a custom slot (e.g. a replacement sampler) can source
   them via input_sources. core_graph() tags each output with source_id + lists
   slot consumers; Models shows all core outputs (tappable in Full control).
3. Preview stuck on the media-bin preview ('anchor'): clear the transient
   mediaPreviewId on transport scrub/seek (_seekNow), play (_play), and clip
   selection (selectScene) so the program resumes following the playhead.
4. Remove t2v ('empty') as a selectable scene source; default new scenes to i2v
   anchor. Anchorless scenes still fall back to t2v in the engine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-19 20:07:18 +03:00
co-authored by Claude Opus 4.8
parent 114e20ac48
commit a1bf407e39
10 changed files with 89 additions and 15 deletions
+16 -1
View File
@@ -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:<cid>:<idx>") 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,
+25 -3
View File
@@ -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:<id>:<idx>`` 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]:
+8 -4
View File
@@ -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)) {
+10 -2
View File
@@ -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 "<node> → <output>" 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);
+3 -1
View File
@@ -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) {
+4
View File
@@ -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);
+1
View File
@@ -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);
+1
View File
@@ -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; }
+20 -3
View File
@@ -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);
+1 -1
View File
@@ -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(),
})