From c38ba2c09e5f84439d1064298720aa33c8bc47a8 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sun, 7 Jun 2026 14:22:29 +0300 Subject: [PATCH] Movie Editor: per-scene Studio rating + wire outputs into widget inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- movie_editor/backend/bridge.py | 9 +++++++++ movie_editor/backend/timeline.py | 4 ++++ movie_editor/frontend/api.js | 1 + movie_editor/frontend/models.js | 5 +++++ movie_editor/frontend/store.js | 3 +++ movie_editor/frontend/styles.css | 1 + movie_editor/frontend/timeline.js | 16 ++++++++++++++++ movie_editor/server.py | 21 ++++++++++++++++++++- 8 files changed, 59 insertions(+), 1 deletion(-) diff --git a/movie_editor/backend/bridge.py b/movie_editor/backend/bridge.py index 0a87de23..44872434 100644 --- a/movie_editor/backend/bridge.py +++ b/movie_editor/backend/bridge.py @@ -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() diff --git a/movie_editor/backend/timeline.py b/movie_editor/backend/timeline.py index fe7d9edf..d30f6da3 100644 --- a/movie_editor/backend/timeline.py +++ b/movie_editor/backend/timeline.py @@ -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)), ) diff --git a/movie_editor/frontend/api.js b/movie_editor/frontend/api.js index f47b0f52..86a1787d 100644 --- a/movie_editor/frontend/api.js +++ b/movie_editor/frontend/api.js @@ -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) => diff --git a/movie_editor/frontend/models.js b/movie_editor/frontend/models.js index d647d5e2..6fe72b6e 100644 --- a/movie_editor/frontend/models.js +++ b/movie_editor/frontend/models.js @@ -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; } diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index 435032c5..32dade7f 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -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 }), }; })(); diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index b718b818..e71ae418 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -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%; } diff --git a/movie_editor/frontend/timeline.js b/movie_editor/frontend/timeline.js index f5d9cd91..ace791ba 100644 --- a/movie_editor/frontend/timeline.js +++ b/movie_editor/frontend/timeline.js @@ -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); diff --git a/movie_editor/server.py b/movie_editor/server.py index a6618f2c..f80ee11a 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -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())