Movie Editor: per-scene Studio rating + wire outputs into widget inputs

Rating:
- Scene.rating (V2_RATING_LABELS); /api/rating-labels exposes the options.
- Timeline toolbar 'Scene N ★' dropdown, shown ONLY when FunPack Studio is the
  conditioning provider AND the selected clip has a render. Rates that clip's own
  scene (cut-aware by scene id).
- Generation injects the run's rating into Studio (studio_inputs.rating) so Studio
  refines the next generation from it; empty = use Studio's default.

Wiring:
- Widget inputs (INT/FLOAT/STRING/BOOLEAN, e.g. EmptyLatentVideo.width/height) are
  now offered as manual wire destinations (ComfyUI converts a widget to an input when
  wired) — so ImageTransform's width/height outputs can drive them. They're still not
  auto-wired, so widget values aren't clobbered unless wired explicitly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-07 14:22:29 +03:00
co-authored by Claude Opus 4.8
parent 77b4084a81
commit c38ba2c09e
8 changed files with 59 additions and 1 deletions
+9
View File
@@ -53,6 +53,15 @@ def parse_timeline_verbatim(prompt: str) -> dict:
return split_timeline_verbatim(str(prompt or ""))
def rating_labels() -> dict:
"""FunPack Studio V2 rating labels (for the Scene rating dropdown)."""
try:
from conditioning import V2_RATING_LABELS
except ImportError: # package submodule
from ...conditioning import V2_RATING_LABELS # type: ignore
return {"labels": list(V2_RATING_LABELS)}
def transitions() -> dict:
_, _, load_transition_db, transition_items = _funpack_imports()
data = load_transition_db()
+4
View File
@@ -64,6 +64,9 @@ class Scene:
width: Optional[int] = None
height: Optional[int] = None
source: SceneSource = field(default_factory=SceneSource)
# FunPack Studio RLHF rating of this scene's last render (a V2_RATING_LABELS value).
# Fed into Studio at the next generation of this scene's run so it refines. "" = unrated.
rating: str = ""
# Selective generation (UI-functional only in Phase 3 — see plan). `excluded`
# scenes are skipped by a full Generate/Render; "generate this scene only" is a
# transient request handled at the API/route level, not stored here.
@@ -83,6 +86,7 @@ class Scene:
width=d.get("width"),
height=d.get("height"),
source=SceneSource.from_dict(d.get("source")),
rating=str(d.get("rating", "")),
excluded=bool(d.get("excluded", False)),
)
+1
View File
@@ -79,6 +79,7 @@
j("POST", API(`/projects/${id}/generate`), { only_scene: onlyScene || null, scene_ids: sceneIds || null }),
status: (id, promptId) => j("GET", API(`/projects/${id}/status/${promptId}`)),
progress: () => j("GET", API("/progress")),
ratingLabels: () => j("GET", API("/rating-labels")),
interrupt: () => j("POST", API("/interrupt")),
renderFinal: (id, clips) => j("POST", API(`/projects/${id}/render`), { clips }),
resultUrl: (id, m) =>
+5
View File
@@ -315,6 +315,7 @@
return card;
}
const _KIND2T = { int: "INT", float: "FLOAT", string: "STRING", boolean: "BOOLEAN" };
function destinations(slot, type) {
const out = [{ value: "", label: "— unwired —" }];
ports.filter((p) => p.type === type).forEach((p) => out.push({ value: "port:" + p.id, label: p.label }));
@@ -322,6 +323,10 @@
const c2 = specFor(s2);
(c2?.connection_inputs || []).filter((ci) => ci.type === type).forEach((ci) =>
out.push({ value: `node:${s2.id}:${ci.name}`, label: `${slotFullLabel(s2)} · ${ci.name}` }));
// Widget inputs can also receive a connection (ComfyUI converts a widget to an
// input when wired) — e.g. EmptyLatentVideo.width/height. Offer them as targets.
(c2?.inputs || []).filter((w) => _KIND2T[w.kind] === type).forEach((w) =>
out.push({ value: `node:${s2.id}:${w.name}`, label: `${slotFullLabel(s2)} · ${w.name} (widget)` }));
});
return out;
}
+3
View File
@@ -19,6 +19,7 @@
mediaBin: [], // uploaded assets [{id,name,kind,...}]
shortcuts: [], // prompt shortcut library
imageTargets: [], // where an image asset can be wired [{value,label}]
ratingLabels: [], // FunPack Studio V2 rating options
};
const listeners = new Set();
@@ -601,6 +602,7 @@
try { const t = await API.transitions(); state.transitions = t.transitions || []; } catch (_) { state.transitions = []; }
await loadShortcuts();
await loadMedia();
try { state.ratingLabels = (await API.ratingLabels()).labels || []; } catch (_) { state.ratingLabels = []; }
await loadModels();
window.addEventListener("funpack-models-changed", loadModels);
await refreshProjectList();
@@ -619,5 +621,6 @@
loadMedia, uploadMedia, deleteMedia, assignMediaToScene,
loadShortcuts, saveShortcut, deleteShortcut, importShortcuts, loadTransitions, saveTransition, deleteTransition, importTransitions,
applyTransitionToSelection, insertShortcutIntoSelection,
setSceneRating: (id, v) => patchScene(id, { rating: v }),
};
})();
+1
View File
@@ -336,6 +336,7 @@ input[type=number] { font-family: var(--font-mono); }
.tl-tc { font-family: var(--font-mono); font-size: 11px; color: var(--muted); }
.tl-zlabel { font-family: var(--font-mono); font-size: 10px; letter-spacing: 1px; text-transform: uppercase; color: var(--faint); }
.tl-keys { font-family: var(--font-mono); font-size: 10px; letter-spacing: .5px; color: var(--faint); margin-right: 10px; opacity: .8; }
.tl-rating { font-size: 12px; padding: 3px 6px; background: var(--ink-2); border: 1px solid var(--line); border-radius: 6px; color: var(--amber-2); max-width: 200px; }
.tl-scroll { flex: 1; overflow-x: auto; overflow-y: hidden; position: relative; }
.tl-content { position: relative; height: 100%; min-height: 100%; }
+16
View File
@@ -200,6 +200,22 @@
exp.onclick = () => S.exportSelected();
bar.append(split); bar.append(del); bar.append(exp);
// Scene rating — only when FunPack Studio is the conditioning provider AND the
// selected clip has a render. Rates that clip's own scene (cut-aware by scene id);
// fed into Studio at the next generation of its run.
const studioCond = !st.project.conditioning_slot || st.project.conditioning_slot === "funpack";
if (studioCond && hasSel && hasRender(st, st.selectedSceneId) && (st.ratingLabels || []).length) {
const sc = S.scene(st.selectedSceneId);
const sceneNo = p.scenes.indexOf(sc) + 1;
const rlabel = el("span", "tl-keys", `★ Scene ${sceneNo}`);
const rsel = el("select", "tl-rating");
rsel.title = "Rate this scene's render — FunPack Studio refines from it next generation";
rsel.append(new Option("— rate —", ""));
(st.ratingLabels || []).forEach((l) => { const o = new Option(l, l); if (l === (sc.rating || "")) o.selected = true; rsel.append(o); });
rsel.onchange = () => S.setSceneRating(sc.id, rsel.value);
bar.append(rlabel); bar.append(rsel);
}
const spacer = el("div", "tl-spacer"); bar.append(spacer);
const keys = el("span", "tl-keys", "S split · ⌫ remove"); keys.title = "Select a clip, then: S splits it at the playhead · Delete/Backspace removes it";
bar.append(keys);
+20 -1
View File
@@ -128,6 +128,18 @@ def _solo(p: Project, only_scene: Optional[str]) -> Project:
return clone
def _run_studio_inputs(target: Project, active_scenes: list) -> dict:
"""Studio widget overrides for this run, incl. the RLHF rating of the run's last
render (Studio refines the next generation from it). Uses the run's first rated scene."""
if target.conditioning_slot != "funpack":
return {}
si = dict(target.studio_inputs or {})
rating = next((s.rating for s in active_scenes if (getattr(s, "rating", "") or "").strip()), None)
if rating:
si["rating"] = rating
return si
def _run_sampler_inputs(target: Project, scene_count: int) -> dict:
"""Sampler widget overrides for one chain run. Carries inside a multi-scene run
must overlap, so carry_i2v_guides is forced on; a 1-scene run leaves it alone."""
@@ -453,7 +465,7 @@ if web is not None and PromptServer is not None:
"width": target.width, "height": target.height,
"negative_prompt": target.negative_prompt or None,
"max_scenes": active_scene_count,
"studio_inputs": target.studio_inputs if target.conditioning_slot == "funpack" else {},
"studio_inputs": _run_studio_inputs(target, active_scenes),
"sampler_inputs": _run_sampler_inputs(target, active_scene_count),
}, media=_prepare_media(target))
if report["blocking"]:
@@ -497,6 +509,13 @@ if web is not None and PromptServer is not None:
running = True
return web.json_response({"state": "running" if running else "pending", "media": []})
@routes.get(UI_PREFIX + "/api/rating-labels")
async def _rating_labels(_req):
try:
return web.json_response(bridge.rating_labels())
except Exception as e: # noqa: BLE001
return web.json_response({"labels": [], "error": str(e)})
@routes.get(UI_PREFIX + "/api/progress")
async def _progress(_req):
return web.json_response(bridge.current_progress())