fix: montage pieces do not feed Shortcut Ideas habits; tiny clips are not montage material; truthful Update, blend, key and erase hints

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-15 14:25:21 +03:00
co-authored by Claude Sonnet 5.5
parent 6a03ebe045
commit fd3224d9a4
10 changed files with 27 additions and 9 deletions
+1
View File
@@ -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;
+7 -1
View File
@@ -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);
});
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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"));
+1 -1
View File
@@ -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({
+2 -2
View File
@@ -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() {
+1 -1
View File
@@ -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
+11
View File
@@ -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"]})
+1 -1
View File
@@ -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")],
+1 -1
View File
@@ -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."},