fix: ratings set the odds (each like up, each dislike down, a run of dislikes much more); a redraw never commits mid-swap
A rating keeps the prompt that made the render and when; matcher replays the expander exactly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7917e4070e
commit
57c74268cd
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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); } }))];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
+3
-3
@@ -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),
|
||||
|
||||
+20
-13
@@ -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
|
||||
|
||||
|
||||
+28
-14
@@ -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)]}
|
||||
|
||||
@@ -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=["<s>"])]
|
||||
out = shortcuts.expand("istanbul", shortcuts=items, seed=1)
|
||||
assert shortcuts.matcher(items)("istanbul") == {"big" if "<b>" 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"] == {}
|
||||
|
||||
Reference in New Issue
Block a user