fix: ratings keep teaching after a regenerate, a cleared text or a left-out clip; Build never assembles a rated-down shortcut or pair
Flush waits for a save already on its way; a field removed by a redraw no longer commits on blur. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7b52643271
commit
7917e4070e
@@ -13,7 +13,8 @@ function wire(node, { onInput, onCommit }) {
|
||||
if (onCommit) {
|
||||
let last = node.dataset.committed = node.value; // region.stack carries an edit not committed yet across a redraw
|
||||
const commit = () => { if (node.value !== last) { last = node.dataset.committed = node.value; onCommit(node.value); } };
|
||||
node.addEventListener("blur", commit);
|
||||
// Not from a field being removed (a redraw): committing mid-redraw would redraw inside it. region.stack carries the edit.
|
||||
node.addEventListener("blur", () => { if (node.isConnected) commit(); });
|
||||
// And `change`, which is what autofill, a password manager and a script
|
||||
// setting the value all fire without ever taking focus. commit() only acts
|
||||
// on a real difference, so the pair costs nothing when both arrive.
|
||||
@@ -94,7 +95,7 @@ define("number", "md", ({ value = 0, min, max, step = 1, precision, unit, snap,
|
||||
if (skipped) { skipped = false; commit(); }
|
||||
};
|
||||
input.addEventListener("change", () => { if (held) skipped = true; else commit(); });
|
||||
input.addEventListener("blur", () => { held = skipped = false; commit(); }); // also ends a hold whose release never came
|
||||
input.addEventListener("blur", () => { held = skipped = false; if (input.isConnected) commit(); }); // ends a lost hold; not from a removal (see wire)
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); commit(); }
|
||||
else if (e.repeat && /^(ArrowUp|ArrowDown|PageUp|PageDown)$/.test(e.key)) held = true;
|
||||
|
||||
@@ -723,3 +723,11 @@ test("Enter is a newline in a textarea, not a commit", () => {
|
||||
fire(box.node, "blur");
|
||||
assert.deepEqual(committed, ["first line"], "blur is what commits a textarea");
|
||||
});
|
||||
|
||||
test("a field blurred as a redraw removes it saves nothing then; the redrawn field carries the edit", () => {
|
||||
const seen = [];
|
||||
const n = mount(composer.number.md({ value: 3, onChange: (v) => seen.push(v) }));
|
||||
const input = n.node.querySelector("input") || n.node;
|
||||
input.value = "9"; input.remove(); fire(input, "blur");
|
||||
assert.deepEqual(seen, []);
|
||||
});
|
||||
|
||||
@@ -29,12 +29,13 @@ export function build(app, own) {
|
||||
const go = async () => {
|
||||
const sc = root();
|
||||
if (!sc || !isGenerative(sc)) return said.setText(sc ? "This clip is an imported video: it has no prompt." : "Select a scene first.");
|
||||
const words = idea.value; // as it reads at the click: the selection may change while this works
|
||||
button.setBusy(true);
|
||||
try {
|
||||
await p.flush(); // a rating or edit made a moment ago is in the projects the ratings are read from
|
||||
const [library, stats] = await Promise.all([app.api.shortcuts().then((r) => r.shortcuts || []), app.api.suggestionStats()]);
|
||||
if (!alive) return;
|
||||
const { text, added } = compose(library, stats, idea.value);
|
||||
const { text, added } = compose(library, stats, words);
|
||||
target = sc.id;
|
||||
draft.setValue(text);
|
||||
said.setText(`For scene ${number(sc)}. ${added.length ? `Added: ${added.map((s) => s.name).join(", ")}` : library.length ? "Nothing to add: every category is used, or what is left was rated down." : "No shortcuts yet: add some in the Shortcuts tab."}`);
|
||||
|
||||
@@ -28,11 +28,11 @@ test("Build drafts from the selected scene, shows the full prompt, and Use puts
|
||||
const { page, q, press, writes } = rig();
|
||||
assert.equal(q("Idea").value, "a fox");
|
||||
press("Build"); await tick(); await tick();
|
||||
assert.equal(q("Draft").value, "a fox neon");
|
||||
assert.equal(q("Draft").value, "a fox, neon");
|
||||
assert.match(page.node.textContent, /For scene 1\. Added: neon/);
|
||||
assert.match(page.node.textContent, /a fox neon lights/);
|
||||
assert.match(page.node.textContent, /a fox, neon lights/);
|
||||
press("Use in scene");
|
||||
assert.deepEqual(writes, [["a", "a fox neon"]]);
|
||||
assert.deepEqual(writes, [["a", "a fox, neon"]]);
|
||||
});
|
||||
|
||||
test("Use before Build, or with an emptied draft, never wipes the scene", async () => {
|
||||
@@ -57,7 +57,7 @@ test("the idea follows the selection until typed in; the draft goes to the scene
|
||||
select("a");
|
||||
assert.equal(q("Idea").value, "a fox", "another scene starts the idea again");
|
||||
press("Use in scene");
|
||||
assert.deepEqual(writes, [["b", "my words neon"]], "the scene it was built for, not the one selected since");
|
||||
assert.deepEqual(writes, [["b", "my words, neon"]], "the scene it was built for, not the one selected since");
|
||||
});
|
||||
|
||||
test("an imported video clip is refused: it has no prompt", async () => {
|
||||
|
||||
+15
-13
@@ -51,21 +51,23 @@ export const shuffled = (list, random = Math.random) => {
|
||||
* A shortcut rated down overall, or rated down alongside what is already here, is never drawn. `stats` is
|
||||
* /suggestion_stats. -> { text, added: [shortcut] } */
|
||||
export function compose(library, stats = {}, idea = "", { add = 3, random = Math.random } = {}) {
|
||||
const cat = (s) => s.category || `\0${s.name}`;
|
||||
const here = presentIn(library, idea), names = new Set(here.map((s) => s.name)), cats = new Set(here.map(cat));
|
||||
const scores = stats.scores || {}, near = new Map();
|
||||
for (const [a, b, n] of stats.rated_pairs || []) for (const [x, y] of [[a, b], [b, a]]) if (names.has(x)) near.set(y, (near.get(y) || 0) + n);
|
||||
const pool = usable(library).filter((s) => !names.has(s.name) && (s.replacements || []).length && (scores[s.name] || 0) >= 0 && (near.get(s.name) || 0) >= 0)
|
||||
.map((s) => ({ s, w: 1 + 2 * ((scores[s.name] || 0) + (near.get(s.name) || 0)) }));
|
||||
const added = [];
|
||||
const cat = (s) => s.category || `\0${s.name}`, key = (t) => String(t).trim().toLowerCase().replace(/\s+/g, " ");
|
||||
const owners = new Map(); // a trigger two shortcuts share fires whichever the expander meets first: never offered
|
||||
for (const s of usable(library)) for (const t of s.triggers) if (key(t)) owners.set(key(t), (owners.get(key(t)) || 0) + 1);
|
||||
const trigger = (s) => s.triggers.find((t) => key(t) && owners.get(key(t)) === 1);
|
||||
const here = presentIn(library, idea), cats = new Set(here.map(cat)), taken = new Set(here.map((s) => s.name));
|
||||
const scores = stats.scores || {}, near = new Map(), added = [];
|
||||
const meet = (name) => { for (const [a, b, n] of stats.rated_pairs || []) for (const [x, y] of [[a, b], [b, a]]) if (x === name) near.set(y, (near.get(y) || 0) + n); };
|
||||
here.forEach((s) => meet(s.name));
|
||||
const pool = usable(library).filter((s) => (s.replacements || []).some((r) => String(r).trim()) && (scores[s.name] || 0) >= 0 && trigger(s));
|
||||
while (added.length < add) {
|
||||
const open = pool.filter((x) => !cats.has(cat(x.s)));
|
||||
const open = pool.filter((s) => !taken.has(s.name) && !cats.has(cat(s)) && (near.get(s.name) || 0) >= 0) // nor rated down beside one already in
|
||||
.map((s) => ({ s, w: 1 + 2 * ((scores[s.name] || 0) + (near.get(s.name) || 0)) }));
|
||||
if (!open.length) break;
|
||||
let r = random() * open.reduce((t, x) => t + x.w, 0);
|
||||
const pick = open.find((x) => (r -= x.w) < 0) || open[open.length - 1];
|
||||
added.push(pick.s);
|
||||
cats.add(cat(pick.s));
|
||||
const { s } = open.find((x) => (r -= x.w) < 0) || open[open.length - 1];
|
||||
added.push(s); taken.add(s.name); cats.add(cat(s)); meet(s.name);
|
||||
}
|
||||
const trigger = (s) => s.triggers.find((t) => String(t).trim()).trim();
|
||||
return { text: [String(idea || "").trim(), ...added.map(trigger)].filter(Boolean).join(" "), added };
|
||||
// Commas between: two triggers side by side must not read as a third ("golden" + "hour" = "golden hour").
|
||||
return { text: [String(idea || "").trim(), ...added.map((s) => trigger(s).trim())].filter(Boolean).join(", "), added };
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ export function createProject({ onChange, onError, onOpen, keepConsistent, befor
|
||||
|
||||
async function flush(target = project) {
|
||||
if (timer) { clearTimeout(timer); timer = null; }
|
||||
if (!target || !dirty) return;
|
||||
if (!target || !dirty) return saving; // nothing new to write, but one on its way is waited for
|
||||
// One PUT at a time. Two overlapping writes of a whole project can land in
|
||||
// either order, and the loser is a version of the project the user has
|
||||
// already moved past.
|
||||
|
||||
@@ -33,7 +33,7 @@ const pool = [
|
||||
|
||||
test("compose keeps the idea, adds one shortcut per unused category, never one with nothing to put in", () => {
|
||||
const { text, added } = compose(pool, {}, "a fox in the rain", { random: () => 0 });
|
||||
assert.equal(text, "a fox in the rain dolly in");
|
||||
assert.equal(text, "a fox in the rain, dolly in");
|
||||
assert.deepEqual(added.map((s) => s.name), ["dolly"]);
|
||||
assert.equal(compose(pool, {}, "", { random: () => 0 }).added.length, 2, "weather + camera; the empty style is skipped");
|
||||
});
|
||||
@@ -58,3 +58,15 @@ test("a trigger is matched as the expander matches it", () => {
|
||||
assert.deepEqual(presentIn(lib, "GOLDEN\n hour").map((s) => s.name), ["gh"]);
|
||||
assert.deepEqual(presentIn(lib, "(fox)").map((s) => s.name), ["fox"]);
|
||||
});
|
||||
|
||||
test("compose never builds what was rated down: not by two triggers side by side, not as a pair it makes itself, not via a shared trigger", () => {
|
||||
const lib = [{ name: "G", triggers: ["golden"], replacements: ["g"], category: "a" }, { name: "H", triggers: ["hour"], replacements: ["h"], category: "b" },
|
||||
{ name: "GH", triggers: ["golden hour"], replacements: ["gh"], category: "c" }];
|
||||
const { text } = compose(lib, { scores: { GH: -3 } }, "", { random: () => 0, add: 2 });
|
||||
assert.doesNotMatch(text, /golden\s+hour/i, "joined with a comma, so the rated-down one cannot fire");
|
||||
const pq = [{ name: "P", triggers: ["p"], replacements: ["p"], category: "x" }, { name: "Q", triggers: ["q"], replacements: ["q"], category: "y" }];
|
||||
assert.equal(compose(pq, { rated_pairs: [["P", "Q", -4]] }, "").added.length, 1, "the second would make the disliked pair");
|
||||
const twins = [{ name: "bad", triggers: ["x"], replacements: ["b"], category: "u" }, { name: "good", triggers: ["x"], replacements: ["g"], category: "v" }];
|
||||
assert.deepEqual(compose(twins, { scores: { bad: -1 } }, "").added, [], "its trigger would fire the other one");
|
||||
assert.deepEqual(compose([{ name: "cut", triggers: ["cut"], replacements: [""], category: "w" }], {}, "").added, [], "removes itself: adds nothing");
|
||||
});
|
||||
|
||||
+6
-2
@@ -387,13 +387,17 @@ def matcher(items=None):
|
||||
compiled = [re.compile(pattern, re.IGNORECASE) for _, pattern, _, _ in candidates]
|
||||
needs = [set(_TOKEN.findall(trigger.lower())) for trigger, *_rest in candidates]
|
||||
by_first: dict[str, list[int]] = {}
|
||||
always = [] # a trigger lower() reshapes ("İ" -> "i̇") matches case-blind in ways tokens cannot tell
|
||||
for i, (trigger, *_rest) in enumerate(candidates):
|
||||
by_first.setdefault(_TOKEN.findall(trigger.lower())[0], []).append(i)
|
||||
if len(trigger.lower()) != len(trigger):
|
||||
always.append(i)
|
||||
else:
|
||||
by_first.setdefault(_TOKEN.findall(trigger.lower())[0], []).append(i)
|
||||
|
||||
def fired(text) -> set:
|
||||
text = str(text or "")
|
||||
tokens = set(_TOKEN.findall(text.lower()))
|
||||
near = [i for t in tokens for i in by_first.get(t, ()) if needs[i] <= tokens]
|
||||
near = [i for t in tokens for i in by_first.get(t, ()) if needs[i] <= tokens] + always
|
||||
# As the expander's one pattern does: the leftmost match wins, the longest trigger (lowest i) first at a tie,
|
||||
# and the search goes on after it.
|
||||
out, pos = set(), 0
|
||||
|
||||
+19
-11
@@ -36,27 +36,35 @@ def stats() -> dict:
|
||||
order = [i for i in proj.timeline_order if i in by_id] or [sc.id for sc in proj.scenes]
|
||||
for sid in order:
|
||||
sc = by_id[sid]
|
||||
# Editorial cuts share the root's text: only the root owns the prompt.
|
||||
if sc.excluded or (sc.gen_unit_id and sc.cut_offset_frames):
|
||||
# Editorial cuts share the root's text and rating: only the root owns them.
|
||||
if sc.gen_unit_id and sc.cut_offset_frames:
|
||||
continue
|
||||
if not (sc.text or "").strip():
|
||||
# Every rating counts for the text it was given to (older ones carry none: the text as it is now): the
|
||||
# clip's own, and each earlier take's -- regenerating moves a rating there. A clip left out still was rated.
|
||||
now = proj.scene_renders.get(sc.id) if isinstance(proj.scene_renders.get(sc.id), dict) else {}
|
||||
on_clip = (lambda t: t.get("promptId") == now.get("promptId")) if now.get("promptId") else \
|
||||
(lambda t: (t.get("media") or {}).get("filename") == (now.get("media") or {}).get("filename"))
|
||||
takes = [(t.get("rating"), t.get("rated_text")) for t in proj.scene_variants.get(sc.id) or []
|
||||
if isinstance(t, dict) and isinstance(t.get("rated_text"), str) and not (now and on_clip(t))]
|
||||
for label, text in [(sc.rating, sc.rated_text or sc.text), *takes]:
|
||||
v = vote(label)
|
||||
rated = fired(text) if v and str(text or "").strip() else ()
|
||||
for k in rated:
|
||||
scores[k] = scores.get(k, 0) + v
|
||||
for b in rated:
|
||||
if k < b:
|
||||
rated_pairs[(k, b)] = rated_pairs.get((k, b), 0) + v
|
||||
# Habits: what the cut uses now.
|
||||
if sc.excluded or not (sc.text or "").strip():
|
||||
continue
|
||||
present = fired(sc.text)
|
||||
scanned += 1
|
||||
v = vote(sc.rating)
|
||||
# A rating is about the text it was given to (older ratings carry none: the text as it is now).
|
||||
rated = fired(sc.rated_text) if v and sc.rated_text.strip() else present
|
||||
for k in present:
|
||||
counts[k] = counts.get(k, 0) + 1
|
||||
for a in present:
|
||||
for b in present:
|
||||
if a < b:
|
||||
pairs[(a, b)] = pairs.get((a, b), 0) + 1
|
||||
for k in rated if v else ():
|
||||
scores[k] = scores.get(k, 0) + v
|
||||
for b in rated:
|
||||
if k < b:
|
||||
rated_pairs[(k, b)] = rated_pairs.get((k, b), 0) + v
|
||||
for a in prev or ():
|
||||
for b in present:
|
||||
follows[(a, b)] = follows.get((a, b), 0) + 1
|
||||
|
||||
@@ -75,3 +75,24 @@ def test_the_miner_finds_exactly_what_the_expander_replaces():
|
||||
text = " ".join(rng.choice(words) for _ in range(12))
|
||||
out = shortcuts.expand(text, shortcuts=items, seed=1)
|
||||
assert fired(text) == {f"n{i}" for i in range(len(items)) if f"<{i}>" in out}, text
|
||||
|
||||
|
||||
def test_ratings_outlive_a_regenerate_a_cleared_text_and_a_left_out_clip():
|
||||
for n in ("rain", "neon", "fox"):
|
||||
shortcuts.save({"name": n, "triggers": [n], "replacements": ["r"]})
|
||||
shown = {"media": {"filename": "3.mp4"}, "promptId": "p3"}
|
||||
projects.save(projects.Project(name="x", scenes=[
|
||||
projects.Scene(id="a", text="fox", rating="", rated_text=""),
|
||||
projects.Scene(id="b", text="", rating="10", rated_text="neon"),
|
||||
projects.Scene(id="c", text="fox", rating="10", rated_text="fox", excluded=True)],
|
||||
scene_renders={"a": shown},
|
||||
scene_variants={"a": [{"media": {"filename": "1.mp4"}, "promptId": "p1", "rating": "1", "rated_text": "rain"},
|
||||
{**shown, "rating": "10", "rated_text": "fox"}]})) # the take on the clip: its head speaks for it
|
||||
assert suggestions.stats()["scores"] == {"rain": -1, "neon": 1, "fox": 1}
|
||||
|
||||
|
||||
def test_a_trigger_lower_reshapes_is_still_found_as_the_expander_finds_it():
|
||||
items = [shortcuts.Shortcut(name="big", triggers=["İstanbul"], replacements=["<b>"]),
|
||||
shortcuts.Shortcut(name="small", triggers=["istanbul"], replacements=["<s>"])]
|
||||
out = shortcuts.expand("istanbul", shortcuts=items, seed=1)
|
||||
assert shortcuts.matcher(items)("istanbul") == {"big" if "<b>" in out else "small"}
|
||||
|
||||
Reference in New Issue
Block a user