Movie Editor: per-scene length/fps modes, pinned global prompt, in-editor Project/Scene switch, single-input links

- Per-scene Frames/FPS gain a 3-way mode (project | timeline | custom): timeline
  follows the trim handle live, custom locks it. Default = timeline length / project fps.
- Global prompt pinned atop the inspector: live view of the combined prompt that
  updates as scenes change; edit + Apply reparses into anchor/scenes/transitions.
- Project/Scene switch is now a segmented control in the inspector (removed from the
  Generate menu).
- Models > Linked inputs now allows single-member links, so a lone node input (e.g.
  FPS) can be tied to a project value without needing a second member.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-07 08:56:53 +03:00
co-authored by Claude Opus 4.8
parent 3b02fccfa3
commit b313bb69ea
10 changed files with 225 additions and 23 deletions
+24
View File
@@ -54,6 +54,13 @@ class Scene:
# Forward-compat per-scene knobs (uniform values still win in V1).
frames: Optional[int] = None
fps: Optional[int] = None
# How `frames`/`fps` are resolved:
# "project" → ignore the per-scene value, use the project global.
# "timeline" → use the per-scene value; the timeline trim handle writes it live.
# "custom" → use the per-scene value; the timeline trim handle is locked.
# Defaults: length follows the timeline (trim-derived), fps follows the project.
frames_mode: str = "timeline"
fps_mode: str = "project"
width: Optional[int] = None
height: Optional[int] = None
source: SceneSource = field(default_factory=SceneSource)
@@ -71,6 +78,8 @@ class Scene:
transition_frames=d.get("transition_frames"),
frames=d.get("frames"),
fps=d.get("fps"),
frames_mode=str(d.get("frames_mode") or "timeline"),
fps_mode=str(d.get("fps_mode") or "project"),
width=d.get("width"),
height=d.get("height"),
source=SceneSource.from_dict(d.get("source")),
@@ -81,12 +90,26 @@ class Scene:
d = asdict(self)
return d
def eff_frames(self, project: "Project") -> int:
if self.frames_mode == "project" or self.frames is None:
return project.num_frames_per_scene
return self.frames
def eff_fps(self, project: "Project") -> int:
if self.fps_mode == "project" or self.fps is None:
return project.frame_rate
return self.fps
@dataclass
class Project:
id: str = field(default_factory=_new_id)
name: str = "Untitled"
anchor: str = "" # text prepended to every scene (character anchor)
# Optional master prompt in Studio combined syntax. When the user edits it and
# hits Apply, the editor reparses it into anchor + scenes + transitions (normal
# Studio behaviour). Stored verbatim so the field round-trips; not used at build.
global_prompt: str = ""
negative_prompt: str = "" # passed to the neg primitive node in the graph
# Marker separating the anchor from the first scene (Studio needs a trigger to
# close segments[0]=anchor). Defaults resolved at assembly from the library.
@@ -116,6 +139,7 @@ class Project:
id=d.get("id") or _new_id(),
name=str(d.get("name", "Untitled")),
anchor=str(d.get("anchor", "")),
global_prompt=str(d.get("global_prompt", "")),
negative_prompt=str(d.get("negative_prompt", "")),
intro_transition=str(d.get("intro_transition", "")),
scenes=[Scene.from_dict(s) for s in d.get("scenes", [])],
+1
View File
@@ -34,6 +34,7 @@
// timeline preview
preview: (id, includeExcluded) =>
j("GET", API(`/projects/${id}/preview?include_excluded=${includeExcluded ? "true" : "false"}`)),
parsePrompt: (id, prompt) => j("POST", API(`/projects/${id}/parse`), { prompt }),
// libraries
transitions: () => j("GET", API("/library/transitions")),
+100 -5
View File
@@ -98,6 +98,50 @@
body.append(field("Feeds node input", tgt));
}
// Per-scene length / fps with a 3-way mode (project | timeline | custom).
const LEN_META = {
frames: { label: "Frames", projKey: "num_frames_per_scene", snap: (v) => S.snapFrames(v) },
fps: { label: "FPS", projKey: "frame_rate", snap: (v) => Math.max(1, v) },
};
const LEN_DEFAULT_MODE = { frames: "timeline", fps: "project" };
function modeOf(scene, kind) { return scene[kind + "_mode"] || LEN_DEFAULT_MODE[kind]; }
function effOf(scene, kind) {
const m = LEN_META[kind], p = S.get().project;
return modeOf(scene, kind) !== "project" && scene[kind] != null ? scene[kind] : p[m.projKey];
}
function lengthControl(scene, kind) {
const m = LEN_META[kind], p = S.get().project;
const mode = modeOf(scene, kind);
const wrap = el("label", "field");
wrap.append(el("span", null, m.label));
const sel = el("select"); sel.dataset.k = "sc-" + kind + "mode";
[["project", "Inherit project global"], ["timeline", "Inherit timeline (trim)"], ["custom", "Custom"]]
.forEach(([v, l]) => { const o = el("option", null, l); o.value = v; if (v === mode) o.selected = true; sel.append(o); });
sel.onchange = () => {
const patch = { [kind + "_mode"]: sel.value };
// Seed the per-scene value from the current effective value when leaving "project".
if (sel.value !== "project" && scene[kind] == null) patch[kind] = effOf(scene, kind);
S.patchScene(scene.id, patch);
};
wrap.append(sel);
if (mode === "custom") {
const i = el("input"); i.type = "number"; i.value = effOf(scene, kind); i.dataset.k = "sc-" + kind;
i.oninput = () => S.patchSceneQuiet(scene.id, { [kind]: m.snap(parseInt(i.value || "0", 10)) });
wrap.append(i);
} else {
const note = el("div", "len-readout");
note.textContent = mode === "project"
? `${p[m.projKey]} · from project`
: `${effOf(scene, kind)} · from timeline`;
wrap.append(note);
}
return wrap;
}
function renderScene(st, scene) {
title.textContent = `Scene · ${st.project.scenes.indexOf(scene) + 1}`;
const tag = el("div", "insp-tag"); tag.textContent = "Clip properties"; body.append(tag);
@@ -118,13 +162,12 @@
(v) => S.patchScene(scene.id, { transition_to_next: v }))));
const p = st.project;
const frames = scene.frames != null ? scene.frames : p.num_frames_per_scene;
const fps = scene.fps != null ? scene.fps : p.frame_rate;
const lenRow = el("div", "fields-row");
lenRow.append(numberField("Frames", frames, (v) => S.patchSceneQuiet(scene.id, { frames: S.snapFrames(v) }), "sc-frames"));
lenRow.append(numberField("FPS", fps, (v) => S.patchSceneQuiet(scene.id, { fps: Math.max(1, v) }), "sc-fps"));
lenRow.append(lengthControl(scene, "frames"));
lenRow.append(lengthControl(scene, "fps"));
body.append(lenRow);
body.append(el("div", "insp-hint", `Duration ≈ ${(frames / (fps || 1)).toFixed(2)}s${scene.frames == null ? " · inheriting project length" : ""}`));
const effFrames = effOf(scene, "frames"), effFps = effOf(scene, "fps") || 1;
body.append(el("div", "insp-hint", `Duration ≈ ${(effFrames / effFps).toFixed(2)}s`));
const row = el("div", "insp-block");
const chk = el("label", "chk"); const cb = el("input"); cb.type = "checkbox"; cb.checked = !!scene.excluded;
@@ -350,6 +393,56 @@
body.append(wrap);
}
// Pinned global-prompt editor (always on top). Backed by the live combined prompt
// so editing any scene flows into it; `gpDraft` holds unapplied direct edits.
let gpDraft = null;
let gpProjectId = null;
function renderGlobalPrompt(st) {
if (st.project.id !== gpProjectId) { gpProjectId = st.project.id; gpDraft = null; } // drop stale edits on project switch
const live = (st.preview && st.preview.combined_prompt) || st.project.global_prompt || "";
const dirty = gpDraft != null && gpDraft !== live;
const val = gpDraft != null ? gpDraft : live;
const sec = el("div", "insp-global");
const head = el("div", "insp-global-head");
head.append(el("span", "insp-global-title", "Global prompt"));
const apply = el("button", "btn primary tiny", "Apply →");
apply.title = "Reparse this prompt into anchor, scenes and transitions (overwrites the timeline)";
apply.disabled = !dirty;
apply.onclick = async () => {
apply.disabled = true; apply.textContent = "Applying…";
await S.applyGlobalPrompt(gpDraft != null ? gpDraft : live);
gpDraft = null;
S.set({}); // re-render so the field returns to its live view
};
head.append(apply);
sec.append(head);
const ta = el("textarea", "insp-global-ta"); ta.rows = 3; ta.value = val; ta.dataset.k = "global-prompt";
ta.placeholder = "Anchor, then scene texts joined by transition markers — the whole montage as one prompt.";
ta.oninput = () => { gpDraft = ta.value; apply.disabled = (gpDraft === live); };
sec.append(ta);
sec.append(el("div", "insp-hint", dirty
? "Edited — press Apply to rebuild the timeline from this prompt."
: "Live view of the assembled prompt. Edit a scene below and it updates here; edit here + Apply to rewrite scenes."));
body.append(sec);
}
function renderSwitch(st, scene) {
const sw = el("div", "insp-switch");
const mk = (label, active, on, disabled) => {
const b = el("button", "insp-seg" + (active ? " active" : ""), label);
if (disabled) b.disabled = true; else b.onclick = on;
return b;
};
sw.append(mk("Project", !scene, () => S.selectScene(null)));
sw.append(mk("Scene", !!scene, () => {
const id = st.selectedSceneId || (st.project.scenes[0] && st.project.scenes[0].id);
if (id) S.selectScene(id);
}, !st.project.scenes.length));
body.append(sw);
}
function render(st) {
// preserve focus + caret of a text/number field across the rebuild (autosave-safe)
const a = document.activeElement;
@@ -360,6 +453,8 @@
clear(body);
if (!st.project) { title.textContent = "Inspector"; body.append(el("div", "pj-meta", "No project open.")); return; }
const scene = st.selectedSceneId ? S.scene(st.selectedSceneId) : null;
renderGlobalPrompt(st);
renderSwitch(st, scene);
if (scene) renderScene(st, scene); else renderProject(st);
renderExposed(st);
renderSplit(st);
-1
View File
@@ -80,7 +80,6 @@
{ label: `Sampler: ${_roleLabel(st.project?.sampler_slot, "FunPack Chain Sampler")}`, disabled: !hasProject(),
action: () => _pickRole("sampler_slot", "Sampler node", st) },
{ sep: true },
{ label: "Project Settings", disabled: !hasProject(), action: () => S.selectScene(null) },
{ label: "Render / Export…", soon: true, disabled: true },
],
Models: [
+5 -5
View File
@@ -569,7 +569,7 @@
const x = el("button", "chip-x", "✕"); x.title = "Remove from link";
x.onclick = async () => {
link.members = (link.members || []).filter((mm) => !(mm.slotId === m.slotId && mm.input === m.input));
if (link.members.length < 2) config.links = (config.links || []).filter((l) => l.id !== link.id);
if (link.members.length < 1) config.links = (config.links || []).filter((l) => l.id !== link.id);
await persist(); render();
};
chip.append(x); mem.append(chip);
@@ -589,9 +589,9 @@
right.append(b);
} else {
const save = el("button", "btn primary tiny", `Save link (${linkSel.length})`);
save.disabled = linkSel.length < 2;
save.disabled = linkSel.length < 1;
save.onclick = async () => {
if (linkSel.length < 2) return;
if (linkSel.length < 1) return;
const def = "size " + ((config.links || []).length + 1);
const nm = prompt("Link name:", def); if (nm == null) return;
const first = linkSel[0]; const s0 = slotById(first.slotId);
@@ -609,10 +609,10 @@
}
head.append(right);
sec.append(head);
if (linkMode) sec.append(el("div", "links-hint", "Click + next to any input below to add it to the link, then Save. (Pick 2+ inputs.)"));
if (linkMode) sec.append(el("div", "links-hint", "Click + next to any input below to add it to the link, then Save. Pick a single input to tie it to a project value (e.g. FPS), or several to drive them together."));
(config.links || []).forEach((l) => sec.append(linkCard(l)));
if (!linkMode && !(config.links || []).length)
sec.append(el("div", "links-empty", "No links yet. Use 'Link inputs' to drive several node inputs (e.g. width/height) from one control."));
sec.append(el("div", "links-empty", "No links yet. Use 'Link inputs' to bind a node input to a project value (e.g. tie a loader's FPS to Project · FPS), or to drive several inputs from one control."));
return sec;
}
+4 -4
View File
@@ -35,8 +35,8 @@
function _buildSegs(st) {
if (!st.project) return [];
const p = st.project;
const sFps = (sc) => (sc.fps != null ? sc.fps : p.frame_rate) || 25;
const sFrames = (sc) => (sc.frames != null ? sc.frames : p.num_frames_per_scene) || 1;
const sFps = (sc) => ((sc.fps_mode !== "project" && sc.fps != null) ? sc.fps : p.frame_rate) || 25;
const sFrames = (sc) => ((sc.frames_mode !== "project" && sc.frames != null) ? sc.frames : p.num_frames_per_scene) || 1;
// Map sceneId → timeline start time
const startOf = {};
@@ -294,8 +294,8 @@
_fpsCur = p?.frame_rate || 25;
// Compute total duration
const sFps = (sc) => (sc.fps != null ? sc.fps : _fpsCur) || 25;
const sFrames = (sc) => (sc.frames != null ? sc.frames : p?.num_frames_per_scene || 1) || 1;
const sFps = (sc) => ((sc.fps_mode !== "project" && sc.fps != null) ? sc.fps : _fpsCur) || 25;
const sFrames = (sc) => ((sc.frames_mode !== "project" && sc.frames != null) ? sc.frames : p?.num_frames_per_scene || 1) || 1;
_totalSecCur = 0;
for (const sc of (p?.scenes || [])) _totalSecCur += sFrames(sc) / sFps(sc);
+37 -2
View File
@@ -172,8 +172,11 @@
function resizeScene(id, durationSec) {
if (!state.project) return;
const s = scene(id); if (!s) return;
const fps = s.fps != null ? s.fps : state.project.frame_rate;
if (s.frames_mode === "custom") return; // custom length is locked against trim
const fps = (s.fps_mode !== "project" && s.fps != null) ? s.fps : state.project.frame_rate;
s.frames = snapFrames(Math.max(1, durationSec) * fps);
// Dragging the trim handle opts the scene into timeline-driven length.
if (s.frames_mode == null || s.frames_mode === "project") s.frames_mode = "timeline";
scheduleSaveSilent();
}
@@ -243,6 +246,38 @@
scheduleSave();
}
// ── global prompt → distribute into anchor / scenes / transitions ──────────────
// Reparses a master prompt (Studio combined syntax) and rebuilds the timeline:
// anchor + one scene per parsed segment + transitions matched from the library.
// Existing per-scene source / length settings are carried over by index.
async function applyGlobalPrompt(text) {
if (!state.project) return;
state.project.global_prompt = text;
let parsed;
try { parsed = (await API.parsePrompt(state.project.id, text)).parsed; }
catch (e) { alert("Could not parse the global prompt: " + e.message); return; }
if (!parsed || !(parsed.scenes || []).length) { alert("Nothing parsed — no scenes detected."); return; }
if (parsed.anchor != null) state.project.anchor = parsed.anchor;
const old = state.project.scenes || [];
const next = (parsed.scenes || []).map((ps, i) => {
const base = old[i] ? JSON.parse(JSON.stringify(old[i])) : { source: { type: "empty" }, excluded: false };
base.text = ps.text || "";
base.transition_to_next = "";
return base;
});
(parsed.transitions || []).forEach((t) => {
const idx = t.after_scene;
if (idx >= 0 && idx < next.length && t.visual_effect) {
const lib = (state.transitions || []).find((tr) => (tr.visual_effect || "none") === t.visual_effect);
if (lib) next[idx].transition_to_next = lib.trigger || lib.name || "";
}
});
state.project.scenes = next;
state.selectedSceneId = null; // show project view so the result is visible
notify(); scheduleSave();
}
// ── preview ──────────────────────────────────────────────────────────────────
let previewTimer = null;
function refreshPreview() {
@@ -440,7 +475,7 @@
refreshProjectList, loadProject, newProject, deleteProject, downloadProject, importProject,
patchProject, patchProjectQuiet, patchScene, patchSceneQuiet, selectScene, addScene, removeScene, moveScene, scene,
resizeScene, splitScene, snapFrames,
refreshPreview, syncFromPreview, generate, loadModels, loadImageTargets, setModelInput, setModelLink,
refreshPreview, syncFromPreview, applyGlobalPrompt, generate, loadModels, loadImageTargets, setModelInput, setModelLink,
setConditioningSlot, setSamplerSlot, setSamplerInput, setSamplerInputNow, setStudioInput, setStudioInputNow,
loadMedia, uploadMedia, deleteMedia, assignMediaToScene,
loadShortcuts, saveShortcut, deleteShortcut, importShortcuts, loadTransitions, saveTransition, deleteTransition, importTransitions,
+27
View File
@@ -266,6 +266,33 @@ input[type=number] { font-family: var(--font-mono); }
.insp-tag { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: 1px; color: var(--amber); text-transform: uppercase; }
.insp-hint { font-size: 11px; color: var(--faint); margin: -4px 0 12px; }
.insp-block { margin-bottom: 16px; }
/* pinned global prompt */
.insp-global { position: sticky; top: 0; z-index: 5; margin: -2px -2px 14px; padding: 10px 10px 8px;
background: linear-gradient(var(--ink-2), var(--ink-1)); border: 1px solid var(--line); border-radius: 9px;
box-shadow: 0 6px 14px -10px #000; }
.insp-global-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
.insp-global-title { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: 1px; text-transform: uppercase; color: var(--amber); }
.insp-global-ta { width: 100%; background: var(--ink-0); border: 1px solid var(--line); border-radius: 7px;
color: var(--ink-text, #e9e2d2); font-size: 12.5px; padding: 7px 8px; resize: vertical; font-family: var(--font-mono); line-height: 1.45; }
.insp-global .insp-hint { margin: 6px 0 0; }
/* project / scene switch */
.insp-switch { display: flex; gap: 0; margin-bottom: 14px; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
.insp-seg { flex: 1; background: var(--ink-1); border: none; color: var(--muted); padding: 7px 0; cursor: pointer;
font-family: var(--font-mono); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; transition: background .12s, color .12s; }
.insp-seg + .insp-seg { border-left: 1px solid var(--line); }
.insp-seg:hover:not(:disabled) { background: var(--ink-3); color: var(--amber-2); }
.insp-seg.active { background: var(--amber-soft); color: var(--amber-2); }
.insp-seg:disabled { opacity: .4; cursor: default; }
/* read-only length/fps value when inheriting */
.len-readout { font-family: var(--font-mono); font-size: 12px; color: var(--muted); padding: 6px 8px;
background: var(--ink-0); border: 1px dashed var(--line); border-radius: 7px; }
/* locked trim handle (scene length is Custom) */
.clip-trim.locked { cursor: not-allowed; }
.clip-trim.locked::after { opacity: 1; background: var(--amber); height: 14px; }
.chk { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; color: var(--muted); cursor: pointer; }
.chk input { width: auto; }
.split-pv { background: var(--ink-0); border: 1px solid var(--line); border-radius: 8px; padding: 10px; }
+10 -5
View File
@@ -21,8 +21,8 @@
let tlFps = 25;
// ── helpers ──────────────────────────────────────────────────────────────────
const sFps = (sc, p) => (sc.fps != null ? sc.fps : p.frame_rate) || 25;
const sFrames = (sc, p) => (sc.frames != null ? sc.frames : p.num_frames_per_scene) || 1;
const sFps = (sc, p) => ((sc.fps_mode !== "project" && sc.fps != null) ? sc.fps : p.frame_rate) || 25;
const sFrames = (sc, p) => ((sc.frames_mode !== "project" && sc.frames != null) ? sc.frames : p.num_frames_per_scene) || 1;
const sDur = (sc, p) => sFrames(sc, p) / sFps(sc, p);
const p2 = (n) => String(n).padStart(2, "0");
@@ -118,10 +118,15 @@
actions.append(mk("✕", "Delete clip", "danger", () => S.removeScene(scene.id)));
clip.append(actions);
// right-edge trim → new duration → frames recomputed (duration × fps)
const handle = el("div", "clip-trim");
handle.title = "Drag to trim · length = duration × fps";
// right-edge trim → new duration → frames recomputed (duration × fps).
// Locked when length is "custom" (the inspector value wins over the timeline).
const locked = scene.frames_mode === "custom";
const handle = el("div", "clip-trim" + (locked ? " locked" : ""));
handle.title = locked
? "Length is Custom — change it in the inspector (set Frames to Inherit timeline to trim here)"
: "Drag to trim · length = duration × fps";
const baseDur = sDur(scene, p);
if (locked) { clip.append(handle); return clip; }
handle.addEventListener("mousedown", (e) => {
clip.classList.add("trimming");
const tip = el("div", "trim-tip"); clip.append(tip);
+17 -1
View File
@@ -235,6 +235,18 @@ if web is not None and PromptServer is not None:
result["parse_error"] = str(e)
return web.json_response(result)
# --- API: parse an arbitrary prompt (global-prompt → anchor/scenes/transitions) ---
@routes.post(UI_PREFIX + "/api/projects/{pid}/parse")
async def _parse(req):
p = _project_or_404(req.match_info["pid"])
body = await req.json() if req.can_read_body else {}
prompt = str(body.get("prompt", ""))
try:
parsed = bridge.parse_timeline(prompt, seed=p.seed)
return web.json_response({"parsed": parsed, "combined_prompt": prompt})
except Exception as e: # noqa: BLE001
return web.json_response({"detail": f"Parse failed: {e}"}, status=502)
# --- API: library (shortcuts + transitions, FunPack in-process) ---
@routes.get(UI_PREFIX + "/api/library/transitions")
async def _transitions(_req):
@@ -375,7 +387,11 @@ if web is not None and PromptServer is not None:
# This makes the timeline trim handle actually affect generation length,
# provided the user has linked EmptyLTXVLatent.num_frames → Project Frames
# in Models → Linked inputs.
trimmed = [s.frames for s in active_scenes if s.frames is not None]
trimmed = [
s.eff_frames(target)
for s in active_scenes
if s.frames_mode != "project" and s.frames is not None
]
effective_frames = (
trimmed[0]
if trimmed and all(f == trimmed[0] for f in trimmed)