diff --git a/app/ui/features/montage/build.js b/app/ui/features/montage/build.js index 54b96cc2..84593f4d 100644 --- a/app/ui/features/montage/build.js +++ b/app/ui/features/montage/build.js @@ -10,6 +10,7 @@ const clone = (x) => JSON.parse(JSON.stringify(x)); /** What a clip can play from: {fps, from (seconds into its file), total (seconds), render?} or null when it has nothing yet. */ export function playable(sc, p) { const fps = effFps(sc, p); + if (seconds(sc, p) * fps < 9) return null; // shorter than the shortest piece the model can make if (isVideoClip(sc)) return sc.source && sc.source.media_ref ? { fps, from: sc.source_in || 0, total: seconds(sc, p) } : null; const r = (p.scene_renders || {})[sc.id]; return r && r.media ? { fps, from: (r.inSec || 0) + (sc.source_in || 0), total: seconds(sc, p), render: r } : null; diff --git a/app/ui/features/montage/build.test.js b/app/ui/features/montage/build.test.js index d38c317d..3b98241f 100644 --- a/app/ui/features/montage/build.test.js +++ b/app/ui/features/montage/build.test.js @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { build, chop } from "./build.js"; +import { build, chop, playable } from "./build.js"; import { isGenerative, genUnitId } from "../../shell/scenes.js"; import { removeScene } from "../../shell/edits.js"; @@ -58,3 +58,9 @@ test("build: deleting a piece leaves no ghost, and an off-grid clip is never rea removeScene(p, piece.id); assert.equal((p.scene_ghosts || []).some((g) => g.id === piece.id), false); }); + +test("playable: a clip shorter than the shortest piece offers nothing to cut", () => { + const p = project(); + p.scenes[1].frames = 5; p.scenes[1].frames_mode = "timeline"; + assert.equal(playable(p.scenes[1], p), null); +}); diff --git a/app/ui/features/montage/index.js b/app/ui/features/montage/index.js index a051c127..dd8ea545 100644 --- a/app/ui/features/montage/index.js +++ b/app/ui/features/montage/index.js @@ -34,7 +34,7 @@ export default { c.field.default({ label: "Acceleration", hint: "1.0 = equal segments; lower = the lead clip's segments get shorter toward the end", control: c.slider.readout({ label: "Acceleration", value: decay, min: 0.5, max: 1, step: 0.05, onCommit: (v) => { decay = v; } }) }), ]); draw(); - const win = c.modal.generic({ title: "Auto Montage", subtitle: "Adds a new cut at the end of the timeline: the lead clip in shrinking pieces with pieces of the cutaway clips between them. Your original clips stay as they are.", size: "md", body }); + const win = c.modal.generic({ title: "Auto Montage", subtitle: "Adds a new cut at the end of the timeline: the lead clip in pieces (shorter and shorter, set by Acceleration) with pieces of the cutaway clips between them. Your original clips stay as they are.", size: "md", body }); win.setFooter({ actions: [c.button.sm({ label: "Build montage", tone: "primary", onClick: () => { if (!pool.size) return c.toast.warn({ text: "Pick at least one cutaway clip." }); const added = p.edit((pr) => build(pr, { leadId: lead, poolIds: [...pool], segmentFrames: seg, decay }) || false); diff --git a/app/ui/features/timeline/index.js b/app/ui/features/timeline/index.js index dc10fde0..6ceb13b8 100644 --- a/app/ui/features/timeline/index.js +++ b/app/ui/features/timeline/index.js @@ -97,7 +97,7 @@ const zoom = { needs: ["say"], setup({ host, app }) { const b = (label, what, title) => c.button.sm({ label, tone: "ghost", title, onClick: () => app.say(what) }).node; - const keys = c.hint.default({ text: "J back 1 s · K pause · L play · S split · I / O clip start / end · + − zoom" }).node; + const keys = c.hint.default({ text: "J back 1 s · K pause · L play · S split · I / O jump to clip start / end · + − zoom" }).node; keys.classList.add("fp-keyhint"); // the first thing to go when the row runs out of room host.append(keys, b("−", "zoom.out", "Zoom out"), b("+", "zoom.in", "Zoom in"), b("fit", "zoom.fit", "Fit the cut to the window")); diff --git a/app/ui/features/welcome/index.js b/app/ui/features/welcome/index.js index 63ea4812..5b2d0b6d 100644 --- a/app/ui/features/welcome/index.js +++ b/app/ui/features/welcome/index.js @@ -38,7 +38,7 @@ export default { const recent = app.project.project; const done = () => { markSeen(); if (win) win.close("done"); }; const tiles = c.region.stack({ gap: "sm", children: [ - card("Update", "Pull the newest version from origin; ComfyUI restarts if it changed and nothing is generating", () => app.maintenance.update()), card("Switch branch", "Pick another branch", () => app.maintenance.switchBranch()), + card("Update", "Pull the newest version from origin, install missing requirements and restart ComfyUI (after a running generation finishes)", () => app.maintenance.update()), card("Switch branch", "Pick another branch", () => app.maintenance.switchBranch()), card("Restart ComfyUI", "Reload the server without updating", () => app.maintenance.restart()), card("Rollback update", "Undo the last update", () => app.maintenance.rollback())] }); tiles.node.classList.add("fp-tiles"); win = c.modal.generic({ diff --git a/app/ui/shell/maintenance.js b/app/ui/shell/maintenance.js index 1924c424..8abed63d 100644 --- a/app/ui/shell/maintenance.js +++ b/app/ui/shell/maintenance.js @@ -72,7 +72,7 @@ export function createMaintenance({ api, flush }) { if (!(await ask("Check requirements", "FunPack has local changes, so the code cannot be updated. Check this install's requirements and install any that are missing?"))) return; return run("Checking requirements…", () => api.requirements(), (r) => deps({ requirements: r })); } - if (!(await ask("Update", `Pull the latest "${branch}" from origin, check requirements and install any that are missing, then restart ComfyUI? Any running generation will be lost.${gs.behind > 0 ? ` ${gs.behind} commit(s) available.` : ""}`))) return; + if (!(await ask("Update", `Pull the latest "${branch}" from origin, check requirements and install any that are missing, then restart ComfyUI. If a generation is running, the restart waits until you press Restart after it finishes.${gs.behind > 0 ? ` ${gs.behind} commit(s) available.` : ""}`))) return; // Requirements first and on their own, so a failed pull (no network, a refused merge) does not skip them. const action = async () => { let requirements; try { requirements = await api.requirements(); } catch (err) { requirements = { ran: false, ok: false, detail: err.message }; } @@ -85,7 +85,7 @@ export function createMaintenance({ api, flush }) { if (!(gs.branches || []).length) return tell("No git branches found."); const branch = await c.modal.choice({ title: "Switch FunPack branch", items: gs.branches.map((b) => ({ id: b, label: b, hint: b === gs.branch ? "current" : "" })) }).result; if (!branch || branch === gs.branch) return; - if (!(await ask("Switch branch", `Switch to "${branch}", pull from origin, and restart ComfyUI? Any running generation will be lost.`))) return; + if (!(await ask("Switch branch", `Switch to "${branch}", pull from origin, and restart ComfyUI. If a generation is running, the restart waits until you press Restart after it finishes.`))) return; return run(`Switching to ${branch}…`, () => api.git("checkout", { branch }), (r) => `On ${branch}${r.updated ? ` (${r.before} → ${r.after})` : ", already up to date"}.${deps(r)}`); }, async rollback() { diff --git a/core/suggestions.py b/core/suggestions.py index 72b5e382..c3989a3f 100644 --- a/core/suggestions.py +++ b/core/suggestions.py @@ -120,7 +120,7 @@ def stats() -> dict: if k < b: votes.setdefault((k, b), []).append((when, v)) # Habits: what the cut uses now. - if sc.excluded or not (sc.text or "").strip(): + if sc.excluded or sc.reused or not (sc.text or "").strip(): # a montage piece replays a shot, it is not a new one continue present = fired(sc.text) scanned += 1 diff --git a/core/tests/test_suggestions.py b/core/tests/test_suggestions.py index 16d06e0f..95526b49 100644 --- a/core/tests/test_suggestions.py +++ b/core/tests/test_suggestions.py @@ -44,6 +44,17 @@ def test_excluded_scenes_and_sub_clips_are_skipped_and_follows_use_timeline_orde assert s["follows"] == [["neon", "rain", 1]] +def test_auto_montage_pieces_are_not_shots_the_user_wrote(): + for n in ("rain", "neon"): + shortcuts.save({"name": n, "triggers": [n], "replacements": ["r"]}) + scenes = [projects.Scene(id="a", text="rain"), projects.Scene(id="b", text="neon"), + projects.Scene(id="p1", text="rain", gen_unit_id="p1", reused=True), + projects.Scene(id="p2", text="neon", gen_unit_id="p2", reused=True)] + projects.save(projects.Project(name="x", scenes=scenes, timeline_order=["a", "b", "p1", "p2"])) + s = suggestions.stats() + assert s["scenes"] == 2 and s["counts"] == {"rain": 1, "neon": 1} and s["follows"] == [["rain", "neon", 1]] + + def test_ratings_score_shortcuts_and_pairs_and_a_bad_image_blames_nothing(): for n in ("rain", "neon", "fox"): shortcuts.save({"name": n, "triggers": [n], "replacements": ["r"]}) diff --git a/modules/conditioning/negative_erase/nodes.py b/modules/conditioning/negative_erase/nodes.py index ab3e5385..01dc4296 100644 --- a/modules/conditioning/negative_erase/nodes.py +++ b/modules/conditioning/negative_erase/nodes.py @@ -17,7 +17,7 @@ class FunPackNegativeErase(io.ComfyNode): io.Float.Input("strength", default=0.0, min=0.0, max=2.0, step=0.05, tooltip="0 = off. How much of the negative prompt is taken out of the positive prompt's words (1 = all of it)."), io.Combo.Input("mode", options=list(erase.MODES), default="project", - tooltip="project: removes only the part of each word that points toward the negative. subtract: pulls every word away from the negative by a share of its own size."), + tooltip="project: removes the part of each word that lies along the negative's direction. subtract: pulls every word away from the negative by a share of its own size."), io.Boolean.Input("keep_size", default=True, tooltip="Put each word back on its original size, changing its direction only.")], outputs=[io.Conditioning.Output(display_name="positive"), io.String.Output(display_name="status")], diff --git a/modules/sampling/context_windows/__init__.py b/modules/sampling/context_windows/__init__.py index e6d6b9dd..8953e9a6 100644 --- a/modules/sampling/context_windows/__init__.py +++ b/modules/sampling/context_windows/__init__.py @@ -46,7 +46,7 @@ SETTINGS = { "hint": "standard_uniform: windows shift a little each step. standard_static: the same windows every step. looped_uniform: the end wraps round to the start. batched: back-to-back windows with no overlap.", "options": [{"value": v, "label": v} for v in SCHEDULES], "when": {"enabled": True}}, "fuse": {"type": "enum", "default": "pyramid", "label": "Window blend", - "hint": "How overlapping windows are mixed: pyramid, relative, overlap-linear fade between neighbours; flat averages them equally.", + "hint": "How windows are mixed: pyramid (relative is the same in this ComfyUI) trusts the middle of each window most; overlap-linear fades across the overlap only; flat averages equally.", "options": [{"value": v, "label": v} for v in FUSES], "when": {"enabled": True}}, "freenoise": {"type": "bool", "default": True, "label": "FreeNoise blending", "when": {"enabled": True}, "hint": "Shuffle the starting noise so neighbouring windows share it."},