diff --git a/movie_editor/backend/timeline.py b/movie_editor/backend/timeline.py index c71f3e90..d82d7b98 100644 --- a/movie_editor/backend/timeline.py +++ b/movie_editor/backend/timeline.py @@ -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", [])], diff --git a/movie_editor/frontend/api.js b/movie_editor/frontend/api.js index dbdf46e5..752ffee7 100644 --- a/movie_editor/frontend/api.js +++ b/movie_editor/frontend/api.js @@ -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")), diff --git a/movie_editor/frontend/inspector.js b/movie_editor/frontend/inspector.js index 202e1587..7632f6da 100644 --- a/movie_editor/frontend/inspector.js +++ b/movie_editor/frontend/inspector.js @@ -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); diff --git a/movie_editor/frontend/menubar.js b/movie_editor/frontend/menubar.js index 5b9ef2d6..5094196b 100644 --- a/movie_editor/frontend/menubar.js +++ b/movie_editor/frontend/menubar.js @@ -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: [ diff --git a/movie_editor/frontend/models.js b/movie_editor/frontend/models.js index a27da420..ba03fe8c 100644 --- a/movie_editor/frontend/models.js +++ b/movie_editor/frontend/models.js @@ -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; } diff --git a/movie_editor/frontend/player.js b/movie_editor/frontend/player.js index 6e447918..a07e0fdb 100644 --- a/movie_editor/frontend/player.js +++ b/movie_editor/frontend/player.js @@ -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); diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index a7476f5a..cf374dd9 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -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, diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index 3f74f779..5b6779a3 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -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; } diff --git a/movie_editor/frontend/timeline.js b/movie_editor/frontend/timeline.js index 50e31fe6..4c0da144 100644 --- a/movie_editor/frontend/timeline.js +++ b/movie_editor/frontend/timeline.js @@ -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); diff --git a/movie_editor/server.py b/movie_editor/server.py index cfc175ac..1566f471 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -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)