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:
co-authored by
Claude Opus 4.8
parent
3b02fccfa3
commit
b313bb69ea
@@ -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", [])],
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user