diff --git a/app/ui/composer/elements/input.js b/app/ui/composer/elements/input.js index b9a161e4..b2a5983a 100644 --- a/app/ui/composer/elements/input.js +++ b/app/ui/composer/elements/input.js @@ -7,14 +7,14 @@ import { define } from "../internals/register.js"; import { el } from "../internals/el.js"; import { uid } from "../internals/ids.js"; +import { swapping } from "../internals/swapping.js"; function wire(node, { onInput, onCommit }) { if (onInput) node.addEventListener("input", () => onInput(node.value)); 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); } }; - // 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(); }); + const commit = () => { if (!swapping.depth && node.value !== last) { last = node.dataset.committed = node.value; onCommit(node.value); } }; + node.addEventListener("blur", 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. @@ -76,10 +76,11 @@ define("number", "md", ({ value = 0, min, max, step = 1, precision, unit, snap, // input to "", and Number("") is 0 -- which is finite, so it sails past any // isFinite guard and lands on min, silently discarding what the user had. const commit = () => { + if (swapping.depth) return; const raw = input.value.trim(); const parsed = raw === "" ? NaN : Number(raw); // `snap`: the values the owner can use (a model's frame grid), so the field shows what is used, not what was typed. - const next = Number.isFinite(parsed) ? clamp(snap ? snap(clamp(parsed)) : parsed) : committed, changed = next !== committed; + const next = Number.isFinite(parsed) ? clamp(snap ? snap(clamp(parsed), committed) : parsed) : committed, changed = next !== committed; committed = next; input.value = input.dataset.committed = String(next); if (changed && onChange) onChange(next); @@ -95,13 +96,14 @@ 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; if (input.isConnected) commit(); }); // ends a lost hold; not from a removal (see wire) + input.addEventListener("blur", () => { held = skipped = false; commit(); }); // also ends a hold whose release never came input.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); commit(); } else if (e.repeat && /^(ArrowUp|ArrowDown|PageUp|PageDown)$/.test(e.key)) held = true; }); input.addEventListener("keyup", () => { if (held) release(); }); - input.addEventListener("pointerdown", () => { + input.addEventListener("pointerdown", (e) => { + if (e.button) return; // a right-click's release may go to its menu held = true; skipped = false; window.addEventListener("pointerup", release, true); window.addEventListener("pointercancel", release, true); }); diff --git a/app/ui/composer/elements/layout.js b/app/ui/composer/elements/layout.js index ca501af6..d2b3dbe6 100644 --- a/app/ui/composer/elements/layout.js +++ b/app/ui/composer/elements/layout.js @@ -8,6 +8,7 @@ import { define } from "../internals/register.js"; import { el } from "../internals/el.js"; import { setText } from "../internals/text.js"; import { uid } from "../internals/ids.js"; +import { swapping } from "../internals/swapping.js"; import { drag } from "../internals/drag.js"; import { roving } from "../internals/focus.js"; @@ -129,7 +130,9 @@ define("region", "stack", ({ children = [], gap = "md", label, fill = false } = const before = was ? twins() : [], at = before.indexOf(was); // Handles, not nodes, like everywhere else -- so a caller cannot slip a // document node in through the one element that takes children late. - node.replaceChildren(...next.filter(Boolean).map((c) => nodeOf(c, "region.stack"))); + const kids = next.filter(Boolean).map((c) => nodeOf(c, "region.stack")); + swapping.depth += 1; + try { node.replaceChildren(...kids); } finally { swapping.depth -= 1; } const after = at >= 0 ? twins() : [], twin = after.length === before.length && after[at]; // a field appearing or going: no guessing which if (!twin || twin === was) return; if ("committed" in was.dataset && was.value !== was.dataset.committed && twin.value === was.dataset.committed) twin.value = was.value; diff --git a/app/ui/composer/internals/swapping.js b/app/ui/composer/internals/swapping.js new file mode 100644 index 00000000..ef8d6c79 --- /dev/null +++ b/app/ui/composer/internals/swapping.js @@ -0,0 +1,4 @@ +// Set while region.stack swaps its content. Chromium fires change/blur on the focused field as it is removed (still +// attached): a commit then would redraw the owner inside the swap, under the stale content the swap goes on to insert. +// A field skips committing meanwhile; region.stack carries its edit into the redrawn field instead. +export const swapping = { depth: 0 }; diff --git a/app/ui/composer/tests/elements_behaviour.test.js b/app/ui/composer/tests/elements_behaviour.test.js index 5319c3d0..0e80f11f 100644 --- a/app/ui/composer/tests/elements_behaviour.test.js +++ b/app/ui/composer/tests/elements_behaviour.test.js @@ -434,6 +434,15 @@ test("a redraw keeps typed text in a text field too, and moves the caret only wh assert.notEqual(document.activeElement && document.activeElement.getAttribute("aria-label"), "Name"); }); +test("snap is told the value it moves from: a rule can depend on the direction", () => { + const n = mount(composer.number.md({ value: 0.1, step: 0.05, snap: (v, was) => (v > 0 && v < 0.1 ? (v < was ? 0 : 0.1) : v) })); + const input = n.node.querySelector("input") || n.node; + input.value = "0.05"; fire(input, "change"); + assert.equal(input.value, "0"); + input.value = "0.05"; fire(input, "change"); + assert.equal(input.value, "0.1"); +}); + test("a number field ignores unparseable text on commit", () => { const n = mount(composer.number.md({ value: 5, min: 0, max: 10 })); const input = n.node.querySelector("input") || n.node; @@ -724,10 +733,25 @@ test("Enter is a newline in a textarea, not a commit", () => { 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, []); +test("a field Chromium blurs mid-redraw (still attached) does not commit then; the redrawn field keeps the edit and commits it", () => { + for (const kind of ["number", "text"]) { + let state = kind === "number" ? 100 : "old"; + const region = mount(composer.region.stack()); + const field = () => kind === "number" ? composer.number.md({ label: "F", value: state, onChange: (v) => { state = v; redraw(); } }) + : composer.input.md({ label: "F", value: state, onCommit: (v) => { state = v; redraw(); } }); + const redraw = () => { const kids = [field()]; region.set(kids); }; // children built before the swap, as the inspector does + redraw(); + const real = region.node.replaceChildren.bind(region.node); + region.node.replaceChildren = (...kids) => { const f = document.activeElement; if (region.node.contains(f)) { fire(f, "change"); fire(f, "blur"); } real(...kids); }; + const box = () => region.node.querySelector('[aria-label="F"]'); + box().focus(); box().value = kind === "number" ? "150" : "old plus typed"; + redraw(); // something else redraws the panel + assert.equal(state, kind === "number" ? 100 : "old", `${kind}: nothing committed mid-swap`); + assert.equal(box().value, kind === "number" ? "150" : "old plus typed", `${kind}: the redrawn field holds the edit`); + assert.equal(document.activeElement, box()); + delete region.node.replaceChildren; + box().dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + assert.equal(state, kind === "number" ? 150 : "old plus typed", `${kind}: and commits it afterwards`); + region.node.remove(); + } }); diff --git a/app/ui/features/composer/build.js b/app/ui/features/composer/build.js index bf3672eb..81a82cfc 100644 --- a/app/ui/features/composer/build.js +++ b/app/ui/features/composer/build.js @@ -5,7 +5,7 @@ import { genUnitId, isGenerative, unitRoot } from "../../shell/scenes.js"; export function build(app, own) { const p = app.project; - let alive = true, typed = false, shown = null, target = null, asked = 0; + let alive = true, typed = false, shown, target = null, asked = 0; // The scene whose prompt a clip runs on: a cut's is its unit's first clip. const root = () => p.selected && unitRoot(p.project, genUnitId(p.selected)) || p.selected; const number = (sc) => p.scenes.indexOf(sc) + 1; @@ -13,7 +13,7 @@ export function build(app, own) { const draft = c.textarea.md({ label: "Draft", rows: 4, value: "", placeholder: "Press Build.", onCommit: () => preview() }); const said = c.hint.default({ text: "" }), full = c.text.sm({ text: "" }); // The idea follows the selected scene until you type in it; another scene starts it again. - const follow = () => { const sc = root(); if (sc !== shown) typed = false; shown = sc; if (!typed) idea.setValue((sc && sc.text) || ""); }; + const follow = () => { const sc = root(); if ((sc && sc.id) !== shown) typed = false; shown = sc && sc.id; if (!typed) idea.setValue((sc && sc.text) || ""); }; // by id: Undo puts a copy of the same scene in follow(); own(app.on(() => { if (alive) follow(); })); own(() => { alive = false; }); @@ -38,7 +38,7 @@ export function build(app, own) { 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."}`); + said.setText(`For scene ${number(sc)}. ${added.length ? `Added: ${added.map((s) => s.name).join(", ")}` : library.length ? "Nothing to add: every category is already used." : "No shortcuts yet: add some in the Shortcuts tab."}`); preview(); } catch (err) { if (alive) said.setText(err.message); } finally { if (alive) button.setBusy(false); } @@ -51,7 +51,7 @@ export function build(app, own) { }; const button = c.button.sm({ label: "Build", tone: "primary", onClick: go }); return c.region.stack({ gap: "sm", children: [ - c.hint.default({ text: "Drafts a scene from your shortcuts, favouring those in scenes you liked. Rate renders to teach it." }), + c.hint.default({ text: "Drafts a scene from your shortcuts. Each like makes one come up more often; each dislike less, and dislikes in a row much less." }), idea, c.toolbar.default({ items: [button] }), draft, said, diff --git a/app/ui/features/composer/build.test.js b/app/ui/features/composer/build.test.js index b01c9f5c..ead03cb9 100644 --- a/app/ui/features/composer/build.test.js +++ b/app/ui/features/composer/build.test.js @@ -14,14 +14,15 @@ function rig({ expand } = {}) { const p = { project: { scenes }, scenes, selectedId: "a", get selected() { return scenes.find((s) => s.id === this.selectedId); }, anchor: "", postfix: "", postfixEnabled: true, variables: [], flush: async () => {}, setText: (id, t) => { writes.push([id, t]); scenes.find((s) => s.id === id).text = t; heard.forEach((f) => f()); } }; - const api = { shortcuts: async () => ({ shortcuts: LIB }), suggestionStats: async () => ({ scores: { neon: 2 } }), + const api = { shortcuts: async () => ({ shortcuts: LIB }), suggestionStats: async () => ({ scores: { neon: 2.25 } }), expandPrompt: expand || (async (body) => ({ text: body.text.replace("neon", "neon lights") })) }; const page = build({ project: p, api, on: (f) => { heard.add(f); return () => heard.delete(f); } }, () => {}); document.body.replaceChildren(page.node); const q = (label) => page.node.querySelector(`[aria-label="${label}"]`); const press = (label) => [...page.node.querySelectorAll("button")].find((b) => b.textContent === label).click(); const select = (id) => { p.selectedId = id; heard.forEach((f) => f()); }; - return { page, q, press, select, writes }; + const undo = () => { scenes.splice(0, scenes.length, ...scenes.map((x) => ({ ...x }))); heard.forEach((f) => f()); }; // Undo: copies of the same scenes + return { page, q, press, select, writes, undo }; } test("Build drafts from the selected scene, shows the full prompt, and Use puts the draft in that scene", async () => { @@ -75,3 +76,10 @@ test("a slow full-prompt answer never replaces a newer one", async () => { assert.match(page.node.textContent, /X\(second\)/); assert.doesNotMatch(page.node.textContent, /X\(first\)/); }); + +test("Undo (copies of the same scenes) keeps what was typed in the idea", () => { + const { q, undo } = rig(); + q("Idea").value = "typed"; q("Idea").dispatchEvent(new window.Event("input")); + undo(); + assert.equal(q("Idea").value, "typed"); +}); diff --git a/app/ui/features/generate/index.js b/app/ui/features/generate/index.js index d1caab30..7390e752 100644 --- a/app/ui/features/generate/index.js +++ b/app/ui/features/generate/index.js @@ -11,7 +11,7 @@ const expand = (body) => fetch("/funpack/api/prompt/expand", { method: "POST", h body: JSON.stringify({ ...body, seed: Math.floor(Math.random() * 2 ** 31) || 1 }) }).then((r) => (r.ok ? r.json() : null)); /** The render a scene gets from its unit's one clip: a cut half plays from where its half begins. */ -export const renderFor = (sc, p, media, unitSec, promptId) => ({ media, ...(promptId ? { promptId } : {}), inSec: (sc.cut_offset_frames || 0) / effFps(sc, p), +export const renderFor = (sc, p, media, unitSec, promptId, text) => ({ media, ...(promptId ? { promptId } : {}), ...(typeof text === "string" ? { text } : {}), inSec: (sc.cut_offset_frames || 0) / effFps(sc, p), ...((sc.frames_mode || "project") === "project" ? { durationSec: unitSec } : {}) }); export default { @@ -27,7 +27,7 @@ export default { g.on("hold", () => { held = true; draw(); }); g.on("release", () => { held = false; draw(); }); // while the page asks ComfyUI whether a run is already going // the pipeline check's refusals: said where the person is looking let busy = false, stopped = false, held = false; - const record = (pid, unit, image, frames, promptId) => { app.lastRun.n += 1; return p.editFor(pid, (pr) => { + const record = (pid, unit, image, frames, promptId, text) => { app.lastRun.n += 1; return p.editFor(pid, (pr) => { const group = pr.scenes.filter((s) => genUnitId(s) === unit); if (!group.length) return false; const secs = (frames || group.reduce((t, s) => t + effFrames(s, pr), 0) - (group.length - 1)) / (pr.frame_rate || 25); // as queued, not as the project reads now @@ -37,14 +37,14 @@ export default { const was = (pr.scene_renders || {})[head.id]; const sameTake = (t) => was && (was.promptId ? t.promptId === was.promptId : t.media.filename === (was.media || {}).filename); const old = was && was.media && takes.find(sameTake); - if (old) Object.assign(old, { rating: head.rating || "", rated_text: head.rated_text || "" }); - else if (was && was.media) takes.push({ media: was.media, ...(was.promptId ? { promptId: was.promptId } : {}), rating: head.rating || "", rated_text: head.rated_text || "", ...(was.durationSec ? { secs: was.durationSec } : {}) }); // a render from before takes is a take too - takes.push({ media, ...(promptId ? { promptId } : {}), rating: "", secs }); + if (old) Object.assign(old, { rating: head.rating || "", rated: head.rated || {} }); + else if (was && was.media) takes.push({ media: was.media, ...(was.promptId ? { promptId: was.promptId } : {}), rating: head.rating || "", rated: head.rated || {}, ...(was.durationSec ? { secs: was.durationSec } : {}), ...(was.text !== undefined ? { text: was.text } : {}) }); // a render from before takes is a take too + takes.push({ media, ...(promptId ? { promptId } : {}), rating: "", secs, ...(typeof text === "string" ? { text } : {}) }); // the prompt that made it: what a rating of it is about while (takes.length > MAX_TAKES) { const at = takes.findIndex((t) => !t.rating); takes.splice(at < 0 || at === takes.length - 1 ? 0 : at, 1); } // the oldest unrated goes first; a rated take stays as long as anything else can go group.forEach((s) => { - (pr.scene_renders ||= {})[s.id] = renderFor(s, pr, media, secs, promptId); + (pr.scene_renders ||= {})[s.id] = renderFor(s, pr, media, secs, promptId, text); if (!isVideoClip(s)) s.source_in = 0; - if (!(s.cut_offset_frames > 0)) s.rating = s.rated_text = ""; // a new render has not been rated yet // a fresh render was made at this length: an earlier trim's window no longer applies + if (!(s.cut_offset_frames > 0)) Object.assign(s, { rating: "", rated: {} }); // a new render has not been rated yet // a fresh render was made at this length: an earlier trim's window no longer applies }); return true; }).catch(() => tell("The result could not be saved to its project.")); }; @@ -126,7 +126,7 @@ export default { break; } if (!images.length) { tell("ComfyUI finished without a result. Try again."); break; } - const kept = await record(pid, unit, images[images.length - 1], frames, g.run.state.promptId); // the next shot may continue from this one: it must be on the clip first + const kept = await record(pid, unit, images[images.length - 1], frames, g.run.state.promptId, root.text || ""); // the next shot may continue from this one: it must be on the clip first if (kept === false) tell(`Scene ${snap.scenes.findIndex((s) => s.id === root.id) + 1} was removed while it was being made, so its result is not on the timeline. It is in ComfyUI's output folder as ${images[images.length - 1].filename}.`); snap.scene_renders = structuredClone(p.project.scene_renders || {}); // ...and in what the next shot reads } diff --git a/app/ui/features/inspector/scene.js b/app/ui/features/inspector/scene.js index 853a8f4e..6ca2c7df 100644 --- a/app/ui/features/inspector/scene.js +++ b/app/ui/features/inspector/scene.js @@ -39,7 +39,8 @@ function trimRows(p, sc, open) { if (!((open.scene_renders || {})[sc.id] || {}).media) return []; return [c.label.section({ text: "Source trim (slip)" }), card(c.field.row({ fields: [ c.field.default({ label: "Source in (s)", control: c.number.md({ label: "Source in", value: sc.source_in || 0, min: 0, step: 0.05, onChange: (v) => p.setScene(sc.id, "source_in", v) }) }), - c.field.default({ label: "Source dur (s, 0 = full)", control: c.number.md({ label: "Source duration", value: sc.source_dur != null ? sc.source_dur : 0, min: 0, step: 0.05, onChange: (v) => p.setScene(sc.id, "source_dur", v > 0 ? Math.max(0.1, v) : null) }) })] }), + c.field.default({ label: "Source dur (s, 0 = full)", control: c.number.md({ label: "Source duration", value: sc.source_dur != null ? sc.source_dur : 0, min: 0, step: 0.05, + snap: (v, was) => (v > 0 && v < 0.1 ? (v < was ? 0 : 0.1) : v), onChange: (v) => p.setScene(sc.id, "source_dur", v > 0 ? v : null) }) })] }), // 0 = full; nothing shorter than 0.1 s c.button.sm({ label: "Reset source trim", tone: "ghost", disabled: !sc.source_in && sc.source_dur == null, onClick: () => { p.setScene(sc.id, "source_in", 0); p.setScene(sc.id, "source_dur", null); } }))]; } diff --git a/app/ui/features/rating/index.js b/app/ui/features/rating/index.js index 48e027aa..c65e9f2f 100644 --- a/app/ui/features/rating/index.js +++ b/app/ui/features/rating/index.js @@ -31,8 +31,8 @@ export default { const rate = async (value) => { const t = target(); if (!t) return; - // The text is kept with the rating: what was rated is the prompt that ran, even after the scene is rewritten. - p.edit((pr) => { const s = pr.scenes.find((x) => x.id === t.root.id); if (s) { s.rating = value; s.rated_text = nameOf(value) ? s.text || "" : ""; } return s; }); + // What was rated and when: the prompt that made the render (the scene's text, for a render that did not keep it); when, for a run of dislikes. + p.edit((pr) => { const s = pr.scenes.find((x) => x.id === t.root.id); if (s) { s.rating = value; s.rated = nameOf(value) ? { text: t.render.text ?? s.text ?? "", at: Date.now() } : {}; } return s; }); const taste = tasteOf(value); if (!t.render.promptId || taste === null) return; // no run to pair it with, or a word that teaches nothing: it stays a label const body = taste === "clear" ? { rating: null, axis: null } : taste; diff --git a/app/ui/shell/edits.js b/app/ui/shell/edits.js index adeeae4f..dacca4e7 100644 --- a/app/ui/shell/edits.js +++ b/app/ui/shell/edits.js @@ -43,7 +43,7 @@ export function removeScene(p, id, { pending = false } = {}) { const next = mates.find((s) => s.id !== id); next.cut_offset_frames = 0; next.text = sc.text || next.text; - if (sc.rating) { next.rating = sc.rating; next.rated_text = sc.rated_text || ""; } + if (sc.rating) { next.rating = sc.rating; next.rated = sc.rated || {}; } next.source = clone(sc.source || next.source || {}); if (p.scene_variants && p.scene_variants[id]) { p.scene_variants[next.id] = p.scene_variants[id]; delete p.scene_variants[id]; } // the takes follow the unit's new root const removedFrames = sc.frames || 0; @@ -181,7 +181,7 @@ export function split(p, id, atFrames, grid = LTX_GRID) { second.cut_offset_frames = (sc.cut_offset_frames || 0) + cut; second.frames = snapFrames(frames - cut, "round", grid); second.text = ""; - second.rating = second.rated_text = ""; + second.rating = ""; second.rated = {}; if (sc.audio_separated) { const lane = trackFor(p, sc.id); second.audio_separated = false; second.audio_volume = lane && lane.volume != null ? lane.volume : 1; } // the lane stays with the first half; the second plays its own sound sc.gen_unit_id = unit; // The seam between the halves is an internal hard cut: the outgoing edge now belongs to the second half. diff --git a/app/ui/shell/habits.js b/app/ui/shell/habits.js index a022290c..0069f452 100644 --- a/app/ui/shell/habits.js +++ b/app/ui/shell/habits.js @@ -46,10 +46,9 @@ export const shuffled = (list, random = Math.random) => { return a; }; -/** A scene from the person's shortcuts, the best-rated first: the idea's own triggers kept, plus up to `add` more, one per - * category the text does not use yet (an uncategorised shortcut is its own), drawn at random weighted by ratings alone. - * A shortcut rated down overall, or rated down alongside what is already here, is never drawn. `stats` is - * /suggestion_stats. -> { text, added: [shortcut] } */ +/** A scene from the person's shortcuts: the idea's own triggers kept, plus up to `add` more, one per category the text + * does not use yet (an uncategorised shortcut is its own), drawn at random. Ratings set the odds: `stats.scores` and + * `stats.rated_pairs` (beside what is already in) multiply a shortcut's chance (/suggestion_stats). -> { text, added } */ export function compose(library, stats = {}, idea = "", { add = 3, random = Math.random } = {}) { 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 @@ -57,12 +56,13 @@ export function compose(library, stats = {}, idea = "", { add = 3, random = Math 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); }; + 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) ?? 1) * 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)); + const pool = usable(library).filter((s) => (s.replacements || []).some((r) => String(r).trim()) && trigger(s)); while (added.length < add) { - 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)) })); + // Below 1 (disliked), a shortcut sits a draw out that often, even with no rival; above, it outweighs the rest. + const open = pool.filter((s) => !taken.has(s.name) && !cats.has(cat(s))).map((s) => ({ s, w: (scores[s.name] ?? 1) * (near.get(s.name) ?? 1) })) + .filter((x) => x.w >= 1 || random() < x.w).map((x) => ({ s: x.s, w: Math.max(x.w, 1) })); if (!open.length) break; let r = random() * open.reduce((t, x) => t + x.w, 0); const { s } = open.find((x) => (r -= x.w) < 0) || open[open.length - 1]; diff --git a/app/ui/shell/project.js b/app/ui/shell/project.js index ba16e6dd..1c16402f 100644 --- a/app/ui/shell/project.js +++ b/app/ui/shell/project.js @@ -166,7 +166,7 @@ export function createProject({ onChange, onError, onOpen, keepConsistent, befor // 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. - if (saving) { await saving.catch(() => {}); if (!dirty) return; } + if (saving) { await saving.catch(() => {}); if (!dirty) return saving; } // another flush wrote it meanwhile: wait for that write dirty = false; const body = { ...target }; saving = put(body).then((saved) => { diff --git a/app/ui/shell/takes.js b/app/ui/shell/takes.js index 95ed8c71..4fcf2fd5 100644 --- a/app/ui/shell/takes.js +++ b/app/ui/shell/takes.js @@ -14,13 +14,13 @@ export function takesOf(p, scene) { export function pickTake(p, scene, to) { const { head, list, at } = takesOf(p, scene); if (at < 0 || to < 0 || to >= list.length || to === at) return false; - list[at].rating = head.rating || ""; list[at].rated_text = head.rated_text || ""; + list[at].rating = head.rating || ""; list[at].rated = head.rated || {}; const take = list[to]; p.scenes.filter((s) => genUnitId(s) === genUnitId(head)).forEach((s) => { const r = (p.scene_renders || {})[s.id]; - if (r) p.scene_renders[s.id] = { ...r, media: take.media, ...(take.promptId ? { promptId: take.promptId } : {}), ...(take.secs && r.durationSec !== undefined ? { durationSec: take.secs } : {}) }; // a take made at another length plays at its own + if (r) p.scene_renders[s.id] = { ...r, media: take.media, ...(take.promptId ? { promptId: take.promptId } : {}), text: take.text, ...(take.secs && r.durationSec !== undefined ? { durationSec: take.secs } : {}) }; // a take made at another length plays at its own }); - head.rating = take.rating || ""; head.rated_text = take.rated_text || ""; + head.rating = take.rating || ""; head.rated = take.rated || {}; return true; } diff --git a/app/ui/shell/tests/habits.test.js b/app/ui/shell/tests/habits.test.js index a70a2ffd..7a051293 100644 --- a/app/ui/shell/tests/habits.test.js +++ b/app/ui/shell/tests/habits.test.js @@ -38,17 +38,19 @@ test("compose keeps the idea, adds one shortcut per unused category, never one w assert.equal(compose(pool, {}, "", { random: () => 0 }).added.length, 2, "weather + camera; the empty style is skipped"); }); -test("compose favours what was liked and never draws what was rated down, alone or beside the idea", () => { - assert.equal(compose(pool, { scores: { fog: 5 } }, "", { random: () => 0.5, add: 1 }).added[0].name, "fog", "11 against 1, either order"); - assert.equal(compose(pool, { scores: { rain: -1 } }, "", { random: () => 0, add: 1 }).added[0].name, "fog"); - assert.deepEqual(compose(pool, { rated_pairs: [["dolly", "rain", -1]] }, "rain", { random: () => 0 }).added.map((s) => s.name), ["pan"]); - assert.deepEqual(compose(pool, { scores: { dolly: -1, pan: -2 } }, "rain").added, [], "nothing left to add"); +test("ratings set the odds: liked comes up more, disliked sits out as often as it was disliked, even with no rival, but is never banned", () => { + assert.equal(compose(pool, { scores: { fog: 4 } }, "", { random: () => 0.3, add: 1 }).added[0].name, "fog", "4 against 1 each"); + assert.deepEqual(compose(pool, { scores: { dolly: 0.25 } }, "rain", { random: () => 0.5 }).added.map((s) => s.name), ["pan"]); + assert.deepEqual(compose(pool, { scores: { dolly: 0.25 } }, "rain", { random: () => 0.1 }).added.map((s) => s.name), ["dolly"], "still possible"); + assert.deepEqual(compose(pool, { rated_pairs: [["dolly", "rain", 0.25]] }, "rain", { random: () => 0.5 }).added.map((s) => s.name), ["pan"], "disliked beside the idea"); + const lone = [{ name: "x", triggers: ["x"], replacements: ["x"], category: "c" }]; + assert.deepEqual(compose(lone, { scores: { x: 0.25 } }, "", { random: () => 0.5 }).added, [], "alone in its category, still sits out"); }); test("compose: only ratings weigh, never how often a shortcut is typed; uncategorised shortcuts are each their own", () => { const two = [{ name: "liked", triggers: ["liked"], replacements: ["l"] }, { name: "habit", triggers: ["habit"], replacements: ["h"] }]; - const one = (r) => compose(two.map((s) => ({ ...s, category: "same" })), { scores: { liked: 1 }, counts: { habit: 500 } }, "", { add: 1, random: () => r }).added[0].name; - assert.equal(one(0.7), "liked", "3 against 1: typing a shortcut 500 times teaches nothing"); + const one = (r) => compose(two.map((s) => ({ ...s, category: "same" })), { scores: { liked: 1.5 }, counts: { habit: 500 } }, "", { add: 1, random: () => r }).added[0].name; + assert.equal(one(0.55), "liked", "typing a shortcut 500 times teaches nothing"); assert.equal(compose(two, {}, "").added.length, 2); }); @@ -59,14 +61,14 @@ test("a trigger is matched as the expander matches it", () => { 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", () => { +test("compose never assembles a third shortcut from two side by side, weighs the pairs it makes, never offers 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 { text } = compose(lib, { scores: { GH: 1e-12 } }, "", { random: () => 0, add: 2 }); + assert.doesNotMatch(text, /golden\s+hour/i, "joined with a comma, so the disliked 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"); + assert.equal(compose(pq, { rated_pairs: [["P", "Q", 0.25]] }, "", { random: () => 0.5 }).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(twins, {}, "").added, [], "its trigger could fire the other one"); assert.deepEqual(compose([{ name: "cut", triggers: ["cut"], replacements: [""], category: "w" }], {}, "").added, [], "removes itself: adds nothing"); }); diff --git a/app/ui/shell/tests/takes.test.js b/app/ui/shell/tests/takes.test.js index b841002a..0fd946da 100644 --- a/app/ui/shell/tests/takes.test.js +++ b/app/ui/shell/tests/takes.test.js @@ -3,9 +3,9 @@ import assert from "node:assert/strict"; import { takesOf, stepTake } from "../takes.js"; const proj = () => ({ - scenes: [{ id: "a", rating: "10", rated_text: "now" }, { id: "b", gen_unit_id: "a", cut_offset_frames: 8 }], + scenes: [{ id: "a", rating: "10", rated: { text: "now", at: 2 } }, { id: "b", gen_unit_id: "a", cut_offset_frames: 8 }], scene_renders: { a: { media: { filename: "3.mp4" }, promptId: "p3", inSec: 0 }, b: { media: { filename: "3.mp4" }, promptId: "p3", inSec: 2 } }, - scene_variants: { a: [{ media: { filename: "1.mp4" }, promptId: "p1", rating: "1", rated_text: "then" }, { media: { filename: "2.mp4" }, promptId: "p2", rating: "" }, { media: { filename: "3.mp4" }, promptId: "p3", rating: "" }] }, + scene_variants: { a: [{ media: { filename: "1.mp4" }, promptId: "p1", rating: "1", rated: { text: "then", at: 1 } }, { media: { filename: "2.mp4" }, promptId: "p2", rating: "" }, { media: { filename: "3.mp4" }, promptId: "p3", rating: "" }] }, }); test("the clip's render is found among its takes, from any clip of the unit", () => { @@ -22,7 +22,7 @@ test("stepping puts another take on every clip of the unit, keeps each clip's ow assert.equal(p.scene_renders.b.media.filename, "2.mp4"); assert.equal(p.scene_renders.b.inSec, 2, "the cut half still plays from where it begins"); assert.equal(p.scene_variants.a[2].rating, "10", "the take that left keeps what it was rated"); - assert.equal(p.scene_variants.a[2].rated_text, "now", "and the text it was rated with"); + assert.deepEqual(p.scene_variants.a[2].rated, { text: "now", at: 2 }, "and what it was rated on, and when"); assert.equal(stepTake(p, p.scenes[0], -1), true); assert.equal(p.scenes[0].rating, "1", "the take that arrived brings its rating"); assert.equal(stepTake(p, p.scenes[0], -1), false, "nothing before the first"); @@ -50,5 +50,5 @@ test("any take can be picked by its number; the one already showing and numbers assert.equal(pickTake(p, p.scenes[0], 0), true); assert.equal(p.scene_renders.a.media.filename, "1.mp4"); assert.equal(p.scenes[0].rating, "1"); - assert.equal(p.scenes[0].rated_text, "then"); + assert.deepEqual(p.scenes[0].rated, { text: "then", at: 1 }); }); diff --git a/core/projects.py b/core/projects.py index 28f9c1af..7794d9be 100644 --- a/core/projects.py +++ b/core/projects.py @@ -166,8 +166,8 @@ class Scene: #: timeline decision and a regenerate is a new scene. length: int | None = None rating: str = "" - #: The scene text when it was rated: what the rating is about, even after the text is rewritten. - rated_text: str = "" + #: {text, at}: the scene text when it was rated (what the rating is about, even after a rewrite) and when (ms). + rated: dict = field(default_factory=dict) #: A media library id, picked to size this scene's generation -- not #: included in it. v4's "drop a picture on the timeline" only ever fed a #: resolution/aspect-ratio node; the picture's own pixels went nowhere. Per @@ -239,7 +239,7 @@ class Scene: result=result if isinstance(result, str) else None, length=_whole(d.get("length")), rating=_str(rating, "", MAX_RATING), - rated_text=_str(d.get("rated_text")), + rated=_blob(d.get("rated"), dict), source_image=source_image if media.is_id(source_image) else None, references=[r for r in raw_refs if media.is_id(r)] if isinstance(raw_refs, list) else [], transition_to_next=_str(d.get("transition_to_next"), "", 200), diff --git a/core/shortcuts.py b/core/shortcuts.py index ea154aa3..d7113a9f 100644 --- a/core/shortcuts.py +++ b/core/shortcuts.py @@ -16,6 +16,7 @@ no-repeat cycling (a stateful commit-vs-preview split). from __future__ import annotations import hashlib +import heapq import json import random import re @@ -385,26 +386,32 @@ def matcher(items=None): fire when every one of its tokens stands whole in the text, so each text is searched for those triggers alone.""" candidates, _ = _combined(listing() if items is None else items) compiled = [re.compile(pattern, re.IGNORECASE) for _, pattern, _, _ in candidates] - needs = [set(_TOKEN.findall(trigger.lower())) for trigger, *_rest in candidates] + folded = [trigger.casefold() for trigger, *_rest in candidates] + needs = [set(_TOKEN.findall(f)) for f in folded] 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): - if len(trigger.lower()) != len(trigger): - always.append(i) - else: - by_first.setdefault(_TOKEN.findall(trigger.lower())[0], []).append(i) + for i, f in enumerate(folded): + by_first.setdefault(_TOKEN.findall(f)[0], []).append(i) + # Folding that reshapes a letter ("İ" -> "i̇") makes tokens unreliable: such a trigger or text is searched in full. + always = [i for i, (trigger, *_rest) in enumerate(candidates) if len(folded[i]) != len(trigger)] 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] + 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. + low = text.casefold() + tokens = set(_TOKEN.findall(low)) + near = range(len(candidates)) if len(low) != len(text) else \ + {i for t in tokens for i in by_first.get(t, ()) if needs[i] <= tokens}.union(always) + # The expander's one pattern, replayed: the leftmost match wins, the longest trigger (lowest i) at a tie, and + # the search goes on from its end. Each trigger's next match is kept and searched again once passed. + heap = [(m.start(), i, m) for i in near if (m := compiled[i].search(text))] + heapq.heapify(heap) out, pos = set(), 0 - for start, i, end in sorted((m.start(), i, m.end()) for i in near for m in compiled[i].finditer(text)): + while heap: + start, i, m = heapq.heappop(heap) if start >= pos: out.add(candidates[i][3]) - pos = end + pos = m.end() + if (m := compiled[i].search(text, pos)): + heapq.heappush(heap, (m.start(), i, m)) return out return fired diff --git a/core/suggestions.py b/core/suggestions.py index e2fbe325..7144b6b9 100644 --- a/core/suggestions.py +++ b/core/suggestions.py @@ -1,6 +1,6 @@ """What a person's own prompting habits say about shortcuts: which ones land in the same scene (pairs), which shot follows which (follows), and how the scenes -using them were rated (scores, rated_pairs: liked +1, disliked -1). Mined on demand +using them were rated (scores, rated_pairs: how much likelier to pick, see weigh). Mined on demand by reading every saved project's scene text for the triggers the expander would replace, so there is nothing learned to store and nothing to go stale. Keyed by shortcut name, which is what the picker identifies a shortcut by.""" @@ -13,18 +13,30 @@ from . import projects, shortcuts def vote(label: str) -> int: """A scene's saved rating as +1 / -1 / 0. A bad-image dislike blames the picture, not the prompt: 0.""" v = str(label or "").removesuffix("|loved").strip() - if v.isdigit(): + if v.isascii() and v.isdigit(): return 1 if int(v) >= 6 else -1 return -1 if v == "Disliked: bad composition" else 0 +def weigh(votes) -> float: + """How much likelier a shortcut (or pair) is to be picked, from its votes oldest first (1 = never rated): each like + x1.5; a dislike x0.5, the next one in a row x0.25, then x0.125... A like ends the run. Nothing reaches zero.""" + w, run = 1.0, 0 + for v in votes: + if v > 0: + w, run = w * 1.5, 0 + else: + run += 1 + w = max(w * 0.5 ** run, 1e-12) # a long run would round to 0: a ban, which no rating is + return w + + def stats() -> dict: fired = shortcuts.matcher() counts: dict[str, int] = {} pairs: dict[tuple, int] = {} follows: dict[tuple, int] = {} - scores: dict[str, int] = {} - rated_pairs: dict[tuple, int] = {} + votes: dict = {} # shortcut name, or (a, b) pair -> [(when, +1/-1)] scanned = 0 for meta in projects.listing(): proj = projects.get(meta["id"]) @@ -44,16 +56,17 @@ def stats() -> dict: 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: + takes = [(t.get("rating"), t.get("rated")) for t in proj.scene_variants.get(sc.id) or [] + if isinstance(t, dict) and isinstance(t.get("rated"), dict) and not (now and on_clip(t))] + for label, rated in [(sc.rating, sc.rated or {"text": sc.text}), *takes]: + v, text, at = vote(label), rated.get("text"), rated.get("at") + hit = fired(text) if v and isinstance(text, str) and text.strip() else () + when = at if isinstance(at, (int, float)) and not isinstance(at, bool) else 0 # undated: oldest + for k in hit: + votes.setdefault(k, []).append((when, v)) + for b in hit: if k < b: - rated_pairs[(k, b)] = rated_pairs.get((k, b), 0) + v + votes.setdefault((k, b), []).append((when, v)) # Habits: what the cut uses now. if sc.excluded or not (sc.text or "").strip(): continue @@ -72,4 +85,5 @@ def stats() -> dict: return {"scenes": scanned, "counts": counts, "pairs": [[a, b, n] for (a, b), n in pairs.items()], "follows": [[a, b, n] for (a, b), n in follows.items()], - "scores": scores, "rated_pairs": [[a, b, n] for (a, b), n in rated_pairs.items()]} + "scores": {k: weigh(v for _, v in sorted(h, key=lambda x: x[0])) for k, h in votes.items() if isinstance(k, str)}, + "rated_pairs": [[*k, weigh(v for _, v in sorted(h, key=lambda x: x[0]))] for k, h in votes.items() if isinstance(k, tuple)]} diff --git a/core/tests/test_suggestions.py b/core/tests/test_suggestions.py index af0bba20..8439398a 100644 --- a/core/tests/test_suggestions.py +++ b/core/tests/test_suggestions.py @@ -51,18 +51,18 @@ def test_ratings_score_shortcuts_and_pairs_and_a_bad_image_blames_nothing(): projects.Scene(text="neon fox", rating="Disliked: bad composition"), projects.Scene(text="fox", rating="Disliked: bad image"), projects.Scene(text="fox", rating="")])) s = suggestions.stats() - assert s["scores"] == {"rain": 2, "neon": 1, "fox": -1} - assert sorted(map(tuple, s["rated_pairs"])) == [("fox", "neon", -1), ("neon", "rain", 2)] + assert s["scores"] == {"rain": 2.25, "neon": 1.125, "fox": 0.5} + assert sorted(map(tuple, s["rated_pairs"])) == [("fox", "neon", 0.5), ("neon", "rain", 2.25)] assert [suggestions.vote(x) for x in ("1", "6", "5", "Disliked: bad image", "odd")] == [-1, 1, -1, 0, 0] def test_a_rating_counts_for_the_text_it_was_given_to_not_a_rewrite(): for n in ("rain", "neon"): shortcuts.save({"name": n, "triggers": [n], "replacements": ["r"]}) - projects.save(projects.Project(name="x", scenes=[projects.Scene(text="neon", rating="10", rated_text="rain")])) + projects.save(projects.Project(name="x", scenes=[projects.Scene(text="neon", rating="10", rated={"text": "rain", "at": 1})])) s = suggestions.stats() - assert s["scores"] == {"rain": 1} and s["counts"] == {"neon": 1} - assert projects.Scene.from_dict({"rated_text": "rain"}).rated_text == "rain" + assert s["scores"] == {"rain": 1.5} and s["counts"] == {"neon": 1} + assert projects.Scene.from_dict({"rated": {"text": "rain", "at": 1}}).rated == {"text": "rain", "at": 1} def test_the_miner_finds_exactly_what_the_expander_replaces(): @@ -82,13 +82,13 @@ def test_ratings_outlive_a_regenerate_a_cleared_text_and_a_left_out_clip(): 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)], + projects.Scene(id="a", text="fox", rating=""), + 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} + 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": 0.5, "neon": 1.5, "fox": 1.5} def test_a_trigger_lower_reshapes_is_still_found_as_the_expander_finds_it(): @@ -96,3 +96,29 @@ def test_a_trigger_lower_reshapes_is_still_found_as_the_expander_finds_it(): shortcuts.Shortcut(name="small", triggers=["istanbul"], replacements=[""])] out = shortcuts.expand("istanbul", shortcuts=items, seed=1) assert shortcuts.matcher(items)("istanbul") == {"big" if "" in out else "small"} + + +def test_each_dislike_in_a_row_cuts_deeper_a_like_lifts_and_ends_the_run_and_nothing_reaches_zero(): + shortcuts.save({"name": "fox", "triggers": ["fox"], "replacements": ["r"]}) + rate = lambda label, at: projects.Scene(text="fox", rating=label, rated={"text": "fox", "at": at}) + projects.save(projects.Project(name="x", scenes=[rate("1", 4), rate("10", 3), rate("1", 1), rate("1", 2)])) # scene order is not time order + assert suggestions.stats()["scores"] == {"fox": 0.5 * 0.25 * 1.5 * 0.5} + assert suggestions.weigh([-1] * 100) > 0 and suggestions.weigh([-1] * 100 + [1]) > suggestions.weigh([-1] * 100) + + +def test_the_miner_agrees_with_the_expander_on_overlapping_triggers_and_folded_letters(): + cases = [(["a b", "b c b"], "a b c b c b"), (["smile"], "ſmile"), (["istanbul"], "İstanbul"), (["İstanbul", "istanbul"], "istanbul")] + for triggers, text in cases: + items = [shortcuts.Shortcut(name=f"n{i}", triggers=[t], replacements=[f"<{i}>"]) for i, t in enumerate(triggers)] + out = shortcuts.expand(text, shortcuts=items, seed=1) + assert shortcuts.matcher(items)(text) == {f"n{i}" for i in range(len(items)) if f"<{i}>" in out}, (triggers, text) + + +def test_an_odd_rating_word_teaches_nothing_and_breaks_nothing(): + assert suggestions.vote("²") == 0 and suggestions.vote("10") == 0 + + +def test_a_rating_given_to_an_empty_prompt_does_not_follow_the_text_written_later(): + shortcuts.save({"name": "neon", "triggers": ["neon"], "replacements": ["r"]}) + projects.save(projects.Project(name="x", scenes=[projects.Scene(text="neon", rating="10", rated={"text": "", "at": 1})])) + assert suggestions.stats()["scores"] == {}