rework: Build tab drafts a scene from your best-rated shortcuts; learns from rated scenes in projects

Drops the run log, edit-replay swap and Generate capture calls (core no longer depends on the feature).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-09 17:18:15 +03:00
co-authored by Claude Opus 5.5
parent 39876d871a
commit e877ad08b7
17 changed files with 176 additions and 812 deletions
-1
View File
@@ -39,7 +39,6 @@ app/playwright-report/
/quarantine.json
/shortcut_categories.json
/shortcut_revolver.json
/prompt_composer_memory.json
# Taste keys: what your ratings taught, per rental
/taste/
+43
View File
@@ -0,0 +1,43 @@
// Build: a scene drafted from your own shortcuts, those in scenes you liked first. Rating renders is what teaches it.
import { composer as c } from "../../composer/composer.js";
import { compose } from "../../shell/habits.js";
export function build(app, own) {
const p = app.project;
let alive = true;
own(() => { alive = false; });
const idea = c.textarea.md({ label: "Idea", rows: 3, value: (p.selected && p.selected.text) || "", placeholder: "Optional: a few words or triggers to start from." });
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: "" });
async function preview() { // what Generate would send for the draft, with one draw of each shortcut
const prefix = (app.promptPrefix || []).flatMap((f) => { try { return f(p.selected); } catch { return []; } });
try {
const r = await app.api.expandPrompt({ text: draft.value, anchor: [...prefix, p.anchor].filter((x) => x && String(x).trim()).join(" "), postfix: p.postfix, postfix_enabled: p.postfixEnabled, variables: p.variables });
if (alive) full.setText(r.text || "");
} catch (err) { if (alive) full.setText(`Could not build the full prompt: ${err.message}`); }
}
async function go() {
let library, stats;
try { [library, stats] = await Promise.all([app.api.shortcuts().then((r) => r.shortcuts || []), app.api.suggestionStats()]); }
catch (err) { return said.setText(err.message); }
if (!alive) return;
const { text, added } = compose(library, stats, idea.value);
draft.setValue(text);
said.setText(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.");
preview();
}
const use = () => {
if (!p.selected) return said.setText("Select a scene first.");
p.setText(p.selectedId, draft.value);
said.setText("Put in the selected scene. Edit > Undo takes it back.");
};
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." }),
idea,
c.toolbar.default({ items: [c.button.sm({ label: "Build", tone: "primary", onClick: go })] }),
draft, said,
c.field.default({ label: "Full prompt", control: full }),
c.toolbar.default({ items: [c.button.sm({ label: "Use in selected scene", tone: "neutral", onClick: use })] }),
] });
}
+28
View File
@@ -0,0 +1,28 @@
import test from "node:test";
import assert from "node:assert/strict";
import { setupDom, teardownDom } from "../../composer/tests/_dom.js";
let build;
test.before(async () => { setupDom(); ({ build } = await import("./build.js")); });
test.after(() => teardownDom());
const tick = () => new Promise((r) => setTimeout(r, 0));
test("Build drafts from the idea, shows the full prompt, and Use puts the draft in the selected scene", async () => {
const scene = { id: "s1", text: "a fox" }, sent = [];
const p = { selected: scene, selectedId: "s1", anchor: "", postfix: "", postfixEnabled: true, variables: [], setText: (id, t) => { scene.text = t; } };
const api = {
shortcuts: async () => ({ shortcuts: [{ name: "neon", triggers: ["neon"], replacements: ["neon lights"], category: "light" }] }),
suggestionStats: async () => ({ scores: { neon: 2 } }),
expandPrompt: async (body) => { sent.push(body); return { text: body.text.replace("neon", "neon lights") }; },
};
const page = build({ project: p, api }, () => {});
document.body.append(page.node);
assert.equal(page.node.querySelector('[aria-label="Idea"]').value, "a fox", "starts from the selected scene");
[...page.node.querySelectorAll("button")].find((b) => b.textContent === "Build").click();
await tick(); await tick();
assert.equal(page.node.querySelector('[aria-label="Draft"]').value, "a fox neon");
assert.match(page.node.textContent, /Added: neon/);
assert.match(page.node.textContent, /a fox neon lights/);
[...page.node.querySelectorAll("button")].find((b) => b.textContent === "Use in selected scene").click();
assert.equal(scene.text, "a fox neon");
});
+2 -2
View File
@@ -4,7 +4,7 @@ import { applyStory, clash, joinStory } from "./story.js";
import { shortcuts } from "./shortcuts.js";
import { cuts } from "./cuts.js";
import { enhance } from "./enhance.js";
import { promptBuilder } from "./prompt_builder.js";
import { build } from "./build.js";
import { chat, chatHook } from "./chat.js";
import { files } from "./files.js";
import { pickShortcut, templatesBar, variablesPanel } from "./storytools.js";
@@ -54,7 +54,7 @@ const story = (app, own) => {
] });
};
const sheets = { story, shortcuts, build: promptBuilder, cuts,
const sheets = { story, shortcuts, build, cuts,
enhance, chat, files };
export default {
-138
View File
@@ -1,138 +0,0 @@
import { composer as c } from "../../composer/composer.js";
const requestBody = (app, text) => {
const selected = app.project.selected;
const prefix = (app.promptPrefix || []).flatMap((fn) => { try { return fn(selected); } catch { return []; } });
return {
text,
anchor: [...prefix, app.project.anchor].filter((x) => x && String(x).trim()).join(" "),
postfix: app.project.postfix,
postfix_enabled: app.project.postfixEnabled,
variables: app.project.variables,
seed: 1,
};
};
export function promptBuilder(app, own) {
const page = c.region.stack({ gap: "sm" });
const project = app.project;
let enabled = false, analysis = null, base = "", draft = "", learned = false, ratingStats = {}, reviewNeeded = false, shotStructure = null;
let busy = false, alive = true, builtBody = null, resultEdited = false;
let targetProject = project.project && project.project.id, targetScene = project.selectedId;
own(() => { alive = false; });
const source = c.textarea.md({ label: "Prompt idea", rows: 4,
value: project.selected ? project.selected.text || "" : "",
placeholder: "Describe the scene or start with a shortcut trigger.",
onInput: () => { base = ""; draft = ""; learned = false; builtBody = null; resultEdited = false; } });
const result = c.textarea.md({ label: "Review and edit the draft", rows: 7, value: "",
placeholder: "Build a draft to start. You can edit it here before using or teaching it.",
onInput: (value) => { draft = value; resultEdited = true; } });
const note = c.hint.default({ text: "" });
function draw() {
if (!alive) return;
const info = analysis || {};
const categories = (info.categories || []).slice(0, 5).map((x) => `${x.name} (${x.count})`).join(" · ") || "No shortcut categories yet";
const terms = (info.words || []).slice(0, 8).map((x) => `${x.text} ×${x.count}`).join(" · ") || "No repeated terms yet";
const positions = (info.positions || []).slice(0, 5).map((x) => `${x.text}: ${Object.entries(x).filter(([k]) => k !== "text").map(([k, v]) => `${k} ${v}`).join(", ")}`).join(" · ") || "No position patterns yet";
const neighbors = (info.neighbors || []).slice(0, 5).map((x) => `${x.left} → ${x.right} (${x.count})`).join(" · ") || "No repeated neighbors yet";
const phrases = (info.phrases || []).slice(0, 4).map((x) => `“${x.text}”`).join(" · ") || "No replacement phrases yet";
const targetIsCurrent = () => project.project && project.project.id === targetProject && project.selectedId === targetScene;
const loadScene = c.button.sm({ label: "Load selected scene", tone: "ghost", onClick: () => {
targetProject = project.project && project.project.id; targetScene = project.selectedId;
source.setValue(project.selected ? project.selected.text || "" : "");
base = ""; draft = ""; learned = false; builtBody = null;
resultEdited = false;
result.setValue(""); note.setText("Loaded the currently selected scene.");
} });
const build = c.button.sm({ label: busy ? "Building…" : "Build draft", tone: "primary", disabled: busy,
onClick: async () => {
busy = true; draw();
try {
builtBody = requestBody(app, source.node.value);
targetProject = project.project && project.project.id; targetScene = project.selectedId;
const built = await app.api.promptBuilderDraft(builtBody);
base = built.base; draft = built.draft; learned = built.learned;
resultEdited = false;
reviewNeeded = built.prompt_review_needed === true;
shotStructure = built.shot_structure || null;
result.setValue(draft);
note.setText(reviewNeeded
? `This reviewed version has ${built.prompt_votes.bad} dislikes and ${built.prompt_votes.good} likes. FunPack paused its automatic reuse; review and save a new edit if you want to teach a replacement.`
: learned ? "Loaded your reviewed version for this exact expanded prompt." : "Started with your normal shortcut and variable expansion. Edit the draft, then choose Learn from this edit.");
} catch (err) { note.setText(`Could not build a draft: ${err.message}`); }
finally { busy = false; draw(); }
} });
const generate = c.button.sm({ label: busy ? "Learning…" : "Generate from memory", tone: "neutral", disabled: busy,
onClick: async () => {
busy = true; draw();
try {
const request = requestBody(app, source.node.value);
const generated = await app.api.promptBuilderGenerate(request);
if (!generated.generated) return note.setText(generated.why || "Could not generate a prompt from the current shortcut library.");
builtBody = { ...request, text: generated.source_text };
targetProject = project.project && project.project.id; targetScene = project.selectedId;
base = generated.base; draft = generated.draft; learned = false;
resultEdited = false;
shotStructure = generated.shot_structure || null;
result.setValue(draft);
if (!source.node.value.trim()) source.setValue(generated.source_text);
const basis = generated.training_runs
? `used ${generated.training_runs} rated run${generated.training_runs === 1 ? "" : "s"}`
: "started from the enabled shortcut library; ratings will personalize later drafts";
note.setText(`Generated a prompt from your shortcuts (${basis}). ${generated.reasons.join("; ")}. Review it, then rate the render or teach your edits.`);
} catch (err) { note.setText(`Could not generate a prompt: ${err.message}`); }
finally { busy = false; draw(); }
} });
const use = c.button.sm({ label: "Use in selected scene", tone: "neutral", disabled: !base || !draft,
onClick: () => {
if (!targetIsCurrent()) return note.setText("The selected project or scene changed. Load the selected scene before applying this draft.");
const selected = project.selected;
if (!selected) return note.setText("Select a scene first.");
const sceneText = !learned && !resultEdited && builtBody ? builtBody.text : draft;
project.setText(selected.id, sceneText);
source.setValue(sceneText); base = ""; learned = false; builtBody = null; resultEdited = false;
note.setText("Draft copied into the selected scene. FunPack kept the source variables and shortcut triggers when the expanded draft was unchanged.");
} });
const learn = c.button.sm({ label: "Learn from this edit", tone: "neutral", disabled: !base || !draft || busy,
onClick: async () => {
try {
if (!builtBody) return note.setText("Build a draft before teaching an edit.");
const saved = await app.api.promptBuilderLearn({ ...builtBody, accepted: result.node.value });
note.setText(`Saved ${saved.edit_count} edit${saved.edit_count === 1 ? "" : "s"}. The configured postfix (including sound instructions) is preserved unchanged. FunPack will reuse this review for the same expanded prompt when automatic use is enabled.`);
} catch (err) { note.setText(`Could not save this review: ${err.message}`); }
} });
const toggle = c.toggle.default({ label: "Use learned drafts automatically for exact matches", checked: enabled,
hint: "This changes the prompt sent to generation only when it exactly matches a prompt you reviewed.",
onChange: async (value) => {
try { const state = await app.api.promptBuilderEnabled(value); enabled = state.enabled; draw(); }
catch (err) { note.setText(`Could not update this setting: ${err.message}`); }
} });
const clear = c.button.sm({ label: "Forget learned drafts…", tone: "ghost", disabled: !info.examples,
onClick: async () => {
if (!(await c.modal.dialogue({ title: "Forget learned drafts", message: "Delete the reviewed prompt pairs and turn automatic use off?", tone: "danger", confirmLabel: "Forget" }).result)) return;
try { const state = await app.api.promptBuilderClear(); enabled = state.enabled; ratingStats = state; analysis = { ...info, examples: 0 }; note.setText("Learned prompt pairs deleted."); draw(); }
catch (err) { note.setText(`Could not clear learned prompts: ${err.message}`); }
} });
page.set([
c.label.section({ text: "Prompt builder" }),
c.hint.default({ text: "Builds from your scene text, saved shortcuts and project variables. The postfix is protected when you teach an edit. Shot Camera runs later in the generation pipeline and may add cut times, views, camera moves and liked details to [Shot N] blocks; those actual choices are captured after the run." }),
toggle,
c.field.default({ label: "Shortcut profile", hint: `${info.shortcuts || 0} enabled shortcuts · ${info.spacy ? "spaCy phrase patterns available" : "word and category counts (spaCy model unavailable)"}`, control: c.text.sm({ text: `${categories}\nTerms: ${terms}\nPositions: ${positions}\nNeighbors: ${neighbors}\nCommon phrases: ${phrases}` }) }),
c.toolbar.default({ items: [c.label.section({ text: "Prompt idea" })], trailing: [loadScene] }),
source,
c.toolbar.default({ items: [build, generate], trailing: [clear] }),
shotStructure && shotStructure.shots ? c.hint.default({ text: `Shot structure in this draft: ${shotStructure.shots} [Shot N] block${shotStructure.shots === 1 ? "" : "s"}${shotStructure.cut_times ? ` · ${shotStructure.cut_times} existing cut time${shotStructure.cut_times === 1 ? "" : "s"}` : ""}. Shot Camera may add cuts or views during generation.` }) : null,
result,
c.toolbar.default({ items: [learn, use] }),
note,
c.hint.default({ text: `${info.examples || 0} reviewed edits and ${ratingStats.captured_prompts || 0} recent runs stored locally. Prompt feedback: ${ratingStats.rated_prompt_runs || 0} likes and ${ratingStats.disliked_prompt_runs || 0} prompt/composition dislikes. Shot Camera choices captured: ${ratingStats.shot_camera_runs || 0} runs, ${ratingStats.shot_cuts_added || 0} cuts, ${ratingStats.shot_views_added || 0} views, ${ratingStats.shot_camera_moves_added || 0} camera moves and ${ratingStats.shot_details_added || 0} details. Shortcut order and use are learned from rated runs; exact reviewed edits remain available for matching prompts.` }),
]);
}
Promise.all([app.api.promptBuilderStatus(), app.api.promptBuilderAnalysis()]).then(([state, stats]) => {
enabled = state.enabled; ratingStats = state; analysis = stats; draw();
}).catch((err) => { analysis = {}; note.setText(`Could not load the local prompt profile: ${err.message}`); draw(); });
return page;
}
+1 -14
View File
@@ -92,9 +92,7 @@ export default {
if (group.every((s) => s.excluded) || !isGenerative(root)) continue; // all left out, or not made by the model
// The unit is made once at the length of its clips together (each cut adds one shared frame).
const frames = group.reduce((t, s) => t + effFrames(s, snap), 0) - (group.length - 1);
const promptPrefix = (app.promptPrefix || []).flatMap((f) => { try { return f(root); } catch { return []; } });
const authoredPrompt = [...promptPrefix, snap.anchor, root.text].filter((x) => x && String(x).trim()).join(" ");
const { inputs, unwired, noPrompt, notes } = await buildInputs({ project: snap, scene: root, slots: frozen, expand: same, frames, hooks: app.inputHooks, ...(fixedSeed ? { seed: null } : {}), prefix: promptPrefix });
const { inputs, unwired, noPrompt, notes } = await buildInputs({ project: snap, scene: root, slots: frozen, expand: same, frames, hooks: app.inputHooks, ...(fixedSeed ? { seed: null } : {}), prefix: (app.promptPrefix || []).flatMap((f) => { try { return f(root); } catch { return []; } }) });
app.lastRun.typed = (root.text || "").trim(); // what a Chat comment made now would be about
const seeded = rolesAt(frozen, "generation.seed")[0];
if (seeded) app.lastRun.seed = (inputs[seeded.slot.id] || {})[seeded.role.input] ?? (seeded.slot.inputs || {})[seeded.role.input]; // shown in ⏱ Sampler, to keep a liked one
@@ -118,19 +116,8 @@ export default {
}
if (stopped) g.cancel(); // Stop landed while this one was being queued
if (!made) app.api.newTasteGeneration(g.run.state.promptId).catch(() => {}); // a run really started: the last run's unrated clips can no longer be paired with what it learned
const promptInput = rolesAt(frozen, "generation.prompt")[0];
const promptText = promptInput && (inputs[promptInput.slot.id] || {})[promptInput.role.input];
if (g.run.state.promptId && typeof promptText === "string") {
app.api.promptBuilderCapture(g.run.state.promptId, promptText, authoredPrompt).catch(() => {});
}
made += 1;
const end = await done;
// Shot Camera runs inside ComfyUI after the queue request. Refresh this
// prompt's capture when it has finished so its actual cuts/views/moves
// are attached before the user rates the result.
if (g.run.state.promptId && typeof promptText === "string") {
app.api.promptBuilderCapture(g.run.state.promptId, promptText, authoredPrompt).catch(() => {});
}
if (end === g.CANCELLED) break;
const images = g.run.state.images;
if (end !== g.DONE) { // ComfyUI's own words: which node, and why
-26
View File
@@ -1,26 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { analyze, byHabit, presentIn } from "./ideas.js";
const lib = [
{ name: "rain", triggers: ["rain"], category: "weather" }, { name: "fog", triggers: ["fog"], category: "weather" },
{ name: "dolly", triggers: ["dolly in"], category: "camera" }, { name: "off", triggers: ["zoom"], category: "camera", enabled: false }, { name: "x", triggers: [], category: "none" },
];
test("a trigger counts only as a whole token", () => {
assert.deepEqual(presentIn(lib, "a train in the rain").map((s) => s.name), ["rain"]);
assert.deepEqual(presentIn(lib, "constraint").map((s) => s.name), []);
assert.deepEqual(presentIn(lib, "a DOLLY IN shot").map((s) => s.name), ["dolly"]);
});
test("a category is used when any of its triggers is in the text; disabled and trigger-less shortcuts are ignored", () => {
const { used, missing } = analyze(lib, "rain");
assert.deepEqual(used.map((g) => g.cat), ["weather"]);
assert.deepEqual(missing.map((g) => g.cat), ["camera"]);
});
test("habits: partners of what is here, strongest first, one-offs ignored, nothing already used", () => {
const edges = [["rain", "dolly", 3], ["rain", "fog", 1], ["fog", "dolly", 2]];
const got = byHabit(lib, edges, new Set(["rain"]), new Set(["rain"]), { both: true });
assert.deepEqual(got.map((g) => [g.sc.name, g.n, g.with.name]), [["dolly", 3, "rain"]]);
});
+1 -1
View File
@@ -3,7 +3,7 @@
import { composer as c } from "../../composer/composer.js";
import { claimFor } from "../../composer/internals/zlayer.js";
import { genUnitId, isGenerative, unitRoot } from "../../shell/scenes.js";
import { analyze, byHabit, presentIn, shuffled } from "./ideas.js";
import { analyze, byHabit, presentIn, shuffled } from "../../shell/habits.js";
const BOXES = new Set(["Story", "Prompt"]);
const first = (sc) => String((sc.triggers || [])[0] || "").trim();
-8
View File
@@ -49,14 +49,6 @@ export const api = {
setRevolver: (enabled, random) => call("POST", "/api/shortcuts/revolver", { enabled, random }),
enhancerDefaults: () => call("GET", "/api/m/conditioning_prompt_enhancer/defaults"),
enhancerRuns: () => call("GET", "/api/m/conditioning_prompt_enhancer/runs"),
promptBuilderStatus: () => call("GET", "/api/m/conditioning_prompt_composer/status"),
promptBuilderAnalysis: () => call("GET", "/api/m/conditioning_prompt_composer/analysis"),
promptBuilderDraft: (body) => call("POST", "/api/m/conditioning_prompt_composer/draft", body),
promptBuilderGenerate: (body) => call("POST", "/api/m/conditioning_prompt_composer/generate", body),
promptBuilderLearn: (body) => call("POST", "/api/m/conditioning_prompt_composer/learn", body),
promptBuilderCapture: (promptId, text, sourceText = "") => call("POST", "/api/m/conditioning_prompt_composer/capture", { prompt_id: promptId, text, source_text: sourceText }),
promptBuilderEnabled: (enabled) => call("POST", "/api/m/conditioning_prompt_composer/settings", { enabled }),
promptBuilderClear: () => call("POST", "/api/m/conditioning_prompt_composer/clear", {}),
rateTaste: (promptId, rating, axis) => call("POST", "/api/m/taste/rate", { prompt_id: promptId, rating, axis: axis || null }),
newTasteGeneration: (promptId) => call("POST", "/api/m/taste/generation", promptId ? { prompt_id: promptId } : {}),
shotMemory: () => call("GET", "/api/m/conditioning_shot_camera/memory"),
@@ -47,3 +47,25 @@ export const shuffled = (list, random = Math.random) => {
for (let i = a.length - 1; i > 0; i -= 1) { const j = Math.floor(random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; }
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, drawn at random by weight. A shortcut rated down overall, or rated down alongside
* what is already here, is never drawn. `stats` is /suggestion_stats. -> { text, added: [shortcut] } */
export function compose(library, stats = {}, idea = "", { add = 3, random = Math.random } = {}) {
const here = presentIn(library, idea), names = new Set(here.map((s) => s.name)), cats = new Set(here.map((s) => s.category || "other"));
const scores = stats.scores || {}, counts = stats.counts || {}, near = new Map();
for (const [a, b, n] of stats.rated_pairs || []) for (const [x, y] of [[a, b], [b, a]]) if (names.has(x)) near.set(y, (near.get(y) || 0) + n);
const pool = usable(library).filter((s) => !names.has(s.name) && (s.replacements || []).length && (scores[s.name] || 0) >= 0 && (near.get(s.name) || 0) >= 0)
.map((s) => ({ s, w: 1 + 2 * ((scores[s.name] || 0) + (near.get(s.name) || 0)) + (counts[s.name] || 0) / 4 }));
const added = [];
while (added.length < add) {
const open = pool.filter((x) => !cats.has(x.s.category || "other"));
if (!open.length) break;
let r = random() * open.reduce((t, x) => t + x.w, 0);
const pick = open.find((x) => (r -= x.w) < 0) || open[open.length - 1];
added.push(pick.s);
cats.add(pick.s.category || "other");
}
const trigger = (s) => s.triggers.find((t) => String(t).trim()).trim();
return { text: [String(idea || "").trim(), ...added.map(trigger)].filter(Boolean).join(" "), added };
}
+46
View File
@@ -0,0 +1,46 @@
import test from "node:test";
import assert from "node:assert/strict";
import { analyze, byHabit, compose, presentIn } from "../habits.js";
const lib = [
{ name: "rain", triggers: ["rain"], category: "weather" }, { name: "fog", triggers: ["fog"], category: "weather" },
{ name: "dolly", triggers: ["dolly in"], category: "camera" }, { name: "off", triggers: ["zoom"], category: "camera", enabled: false }, { name: "x", triggers: [], category: "none" },
];
test("a trigger counts only as a whole token", () => {
assert.deepEqual(presentIn(lib, "a train in the rain").map((s) => s.name), ["rain"]);
assert.deepEqual(presentIn(lib, "constraint").map((s) => s.name), []);
assert.deepEqual(presentIn(lib, "a DOLLY IN shot").map((s) => s.name), ["dolly"]);
});
test("a category is used when any of its triggers is in the text; disabled and trigger-less shortcuts are ignored", () => {
const { used, missing } = analyze(lib, "rain");
assert.deepEqual(used.map((g) => g.cat), ["weather"]);
assert.deepEqual(missing.map((g) => g.cat), ["camera"]);
});
test("habits: partners of what is here, strongest first, one-offs ignored, nothing already used", () => {
const edges = [["rain", "dolly", 3], ["rain", "fog", 1], ["fog", "dolly", 2]];
const got = byHabit(lib, edges, new Set(["rain"]), new Set(["rain"]), { both: true });
assert.deepEqual(got.map((g) => [g.sc.name, g.n, g.with.name]), [["dolly", 3, "rain"]]);
});
const pool = [
{ name: "rain", triggers: ["rain"], replacements: ["r"], category: "weather" }, { name: "fog", triggers: ["fog"], replacements: ["f"], category: "weather" },
{ name: "dolly", triggers: ["dolly in"], replacements: ["d"], category: "camera" }, { name: "pan", triggers: ["pan"], replacements: ["p"], category: "camera" },
{ name: "empty", triggers: ["nil"], replacements: [], category: "style" },
];
test("compose keeps the idea, adds one shortcut per unused category, never one with nothing to put in", () => {
const { text, added } = compose(pool, {}, "a fox in the rain", { random: () => 0 });
assert.equal(text, "a fox in the rain dolly in");
assert.deepEqual(added.map((s) => s.name), ["dolly"]);
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");
});
-7
View File
@@ -1079,13 +1079,6 @@ def register(routes, prefix=None):
seed=seed if isinstance(seed, int) and not isinstance(seed, bool) else 0,
commit=body.get("commit") is True,
)
# Optional prompt editors can apply a user's reviewed transformation
# at this final boundary. The feature owns its memory; the core only
# asks registered providers and leaves the ordinary prompt untouched
# when none has a matching learned draft.
transformed = modules().ask("prompt_transform", expanded)
if isinstance(transformed, str):
expanded = transformed
return web.json_response({"text": expanded})
@routes.get(P + "/api/log/funpack")
+19 -2
View File
@@ -1,5 +1,6 @@
"""What a person's own prompting habits say about shortcuts: which ones land in
the same scene (pairs) and which shot follows which (follows). Mined on demand
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
by reading every saved project's scene text for triggers typed verbatim, 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."""
@@ -11,6 +12,14 @@ import re
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():
return 1 if int(v) >= 6 else -1
return -1 if v == "Disliked: bad composition" else 0
def stats() -> dict:
pats = []
for sc in shortcuts.listing():
@@ -23,6 +32,8 @@ def stats() -> dict:
counts: dict[str, int] = {}
pairs: dict[tuple, int] = {}
follows: dict[tuple, int] = {}
scores: dict[str, int] = {}
rated_pairs: dict[tuple, int] = {}
scanned = 0
for meta in projects.listing():
proj = projects.get(meta["id"])
@@ -42,16 +53,22 @@ def stats() -> dict:
continue
present = {k for k, pat in pats if pat.search(text)}
scanned += 1
v = vote(sc.rating)
for k in present:
counts[k] = counts.get(k, 0) + 1
if v:
scores[k] = scores.get(k, 0) + v
for a in present:
for b in present:
if a < b:
pairs[(a, b)] = pairs.get((a, b), 0) + 1
if v:
rated_pairs[(a, b)] = rated_pairs.get((a, b), 0) + v
for a in prev or ():
for b in present:
follows[(a, b)] = follows.get((a, b), 0) + 1
prev = present
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()]}
"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()]}
+14 -1
View File
@@ -28,7 +28,7 @@ def test_pairs_follows_counts_come_from_typed_triggers_only():
def test_no_projects_or_no_shortcuts_is_an_empty_answer():
assert suggestions.stats() == {"scenes": 0, "counts": {}, "pairs": [], "follows": []}
assert suggestions.stats() == {"scenes": 0, "counts": {}, "pairs": [], "follows": [], "scores": {}, "rated_pairs": []}
def test_excluded_scenes_and_sub_clips_are_skipped_and_follows_use_timeline_order():
@@ -41,3 +41,16 @@ def test_excluded_scenes_and_sub_clips_are_skipped_and_follows_use_timeline_orde
s = suggestions.stats()
assert s["scenes"] == 2 and s["counts"] == {"rain": 1, "neon": 1}
assert s["follows"] == [["neon", "rain", 1]]
def test_ratings_score_shortcuts_and_pairs_and_a_bad_image_blames_nothing():
for n in ("rain", "neon", "fox"):
shortcuts.save({"name": n, "triggers": [n], "replacements": ["r"]})
projects.save(projects.Project(name="x", scenes=[
projects.Scene(text="rain neon", rating="10"), projects.Scene(text="rain neon", rating="10|loved"),
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 [suggestions.vote(x) for x in ("1", "6", "5", "Disliked: bad image", "odd")] == [-1, 1, -1, 0, 0]
@@ -1,95 +0,0 @@
"""Local prompt drafting from saved shortcuts and the user's reviewed edits."""
import asyncio
from . import engine
ID = "conditioning_prompt_composer"
TITLE = "Prompt composer"
STAGE = "conditioning"
CATEGORY = "conditioning"
STATUS = "experimental"
def routes(table, base, web):
@table.get(base + "/status")
async def _status(_req):
return web.json_response(engine.status())
@table.get(base + "/analysis")
async def _analysis(_req):
return web.json_response(await asyncio.to_thread(engine.analysis))
@table.post(base + "/draft")
async def _draft(req):
try:
body = await req.json()
except Exception: # noqa: BLE001
return web.json_response({"why": "that is not JSON"}, status=400)
if not isinstance(body, dict):
return web.json_response({"why": "send a prompt object"}, status=400)
try:
return web.json_response(await asyncio.to_thread(engine.draft, body))
except Exception as exc: # noqa: BLE001 -- keep an optional feature from taking down Composer
return web.json_response({"why": str(exc)}, status=400)
@table.post(base + "/generate")
async def _generate(req):
try:
body = await req.json()
except Exception: # noqa: BLE001
return web.json_response({"why": "that is not JSON"}, status=400)
if not isinstance(body, dict):
return web.json_response({"why": "send a prompt object"}, status=400)
try:
return web.json_response(await asyncio.to_thread(engine.generate, body))
except Exception as exc: # noqa: BLE001 -- the prompt feature cannot stop the Composer
return web.json_response({"why": str(exc)}, status=400)
@table.post(base + "/learn")
async def _learn(req):
try:
body = await req.json()
except Exception: # noqa: BLE001
return web.json_response({"why": "that is not JSON"}, status=400)
if not isinstance(body, dict):
return web.json_response({"why": "send a prompt object"}, status=400)
try:
return web.json_response(await asyncio.to_thread(engine.learn, body))
except (ValueError, OSError) as exc:
return web.json_response({"why": str(exc)}, status=400)
@table.post(base + "/capture")
async def _capture(req):
try:
body = await req.json()
return web.json_response(await asyncio.to_thread(
engine.capture, body.get("prompt_id"), body.get("text"), body.get("source_text")))
except (ValueError, AttributeError, OSError) as exc:
return web.json_response({"why": str(exc)}, status=400)
@table.post(base + "/settings")
async def _settings(req):
try:
body = await req.json()
return web.json_response(engine.set_enabled(body.get("enabled")))
except (ValueError, AttributeError, OSError) as exc:
return web.json_response({"why": str(exc)}, status=400)
@table.post(base + "/clear")
async def _clear(_req):
try:
return web.json_response(engine.clear())
except OSError as exc:
return web.json_response({"why": str(exc)}, status=500)
def prompt_transform(prompt):
return engine.transform(prompt)
def on_rating(prompt_id, rating, axis=None):
return engine.rate(prompt_id, rating, axis)
PROVIDES = {"routes": routes, "prompt_transform": prompt_transform, "on_rating": on_rating}
@@ -1,508 +0,0 @@
"""Local, reviewed prompt drafts built from shortcuts and exact user edits."""
from __future__ import annotations
import difflib
import hashlib
import json
import re
import threading
from collections import Counter
from ....core import config, prompt_build, shortcuts
_LOCK = threading.RLock()
_MAX_EXAMPLES = 300
_MAX_TEXT = 32768
_MAX_CAPTURES = 500
_STOP = {"a", "an", "and", "as", "at", "by", "for", "from", "in", "into", "is", "of", "on", "or", "the", "to", "with"}
_WORD = re.compile(r"[\w'-]+", re.UNICODE)
_EDIT_TOKEN = re.compile(r"[\w'-]+|[^\w\s]", re.UNICODE)
_SHOT = re.compile(r"\[Shot\s+\d+\]", re.IGNORECASE)
_CUT_TIME = re.compile(r"\bAt\s+\d{2}:\d{2}(?:\.\d+)?", re.IGNORECASE)
_nlp = None
_nlp_tried = False
def _path():
return config.ROOT / "prompt_composer_memory.json"
def _read():
try:
value = json.loads(_path().read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
value = {}
if not isinstance(value, dict):
value = {}
examples = value.get("examples")
if isinstance(examples, list):
for row in examples[-_MAX_EXAMPLES:]:
if isinstance(row, dict) and not isinstance(row.get("prompt_ratings"), dict):
old = row.get("composition_ratings")
row["prompt_ratings"] = {key: "bad" for key, vote in old.items()
if vote == "bad"} if isinstance(old, dict) else {}
return {"enabled": value.get("enabled") is True,
"examples": examples[-_MAX_EXAMPLES:] if isinstance(examples, list) else [],
"captures": value.get("captures", [])[-_MAX_CAPTURES:] if isinstance(value.get("captures"), list) else []}
def _write(data):
path = _path()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(path)
def _key(text):
return hashlib.sha256(str(text or "").encode("utf-8")).hexdigest()
def _nlp_model():
global _nlp, _nlp_tried
if not _nlp_tried:
_nlp_tried = True
try:
import spacy
_nlp = spacy.load("en_core_web_sm", disable=["ner"])
except Exception: # noqa: BLE001 -- analysis still works without spaCy/model
_nlp = None
return _nlp
def _body_text(body):
body = body if isinstance(body, dict) else {}
return prompt_build.build(
str(body.get("text") or "")[:_MAX_TEXT],
anchor=str(body.get("anchor") or "")[:_MAX_TEXT],
postfix=str(body.get("postfix") or "")[:_MAX_TEXT],
postfix_enabled=body.get("postfix_enabled") is not False,
variables=body.get("variables") if isinstance(body.get("variables"), list) else None,
seed=body.get("seed") if isinstance(body.get("seed"), int) and not isinstance(body.get("seed"), bool) else 1,
)
def _postfix_text(body):
"""Resolve the project's tail by itself so learned edits can never rewrite it."""
body = body if isinstance(body, dict) else {}
if body.get("postfix_enabled") is False:
return ""
return prompt_build.build(
"", postfix=str(body.get("postfix") or "")[:_MAX_TEXT],
variables=body.get("variables") if isinstance(body.get("variables"), list) else None,
seed=body.get("seed") if isinstance(body.get("seed"), int) and not isinstance(body.get("seed"), bool) else 1,
)
def _remove_current_anchor(source, body):
anchor = " ".join(str(body.get("anchor") or "").split())
source = str(source or "").strip()
if anchor and source.startswith(anchor) and (len(source) == len(anchor) or source[len(anchor)].isspace()):
return source[len(anchor):].strip()
return source
def _preserve_postfix(accepted, postfix):
"""Keep a resolved sound/postfix variable byte-for-byte at the end of a review."""
if not postfix or accepted.endswith(postfix):
return accepted
# If the user removed only the end of the tail, replace that partial remnant
# instead of duplicating it. The complete configured tail is always restored.
overlap = min(len(postfix), len(accepted))
while overlap and not accepted.endswith(postfix[:overlap]):
overlap -= 1
if overlap:
accepted = accepted[:-overlap]
return accepted.rstrip() + (" " if accepted.rstrip() else "") + postfix
def _shot_structure(text):
text = str(text or "")
markers = list(_SHOT.finditer(text))
return {"shots": len(markers), "cut_times": len(_CUT_TIME.findall(text)),
"shot_camera_ready": bool(markers)}
def _shot_camera_run(prompt_id):
try:
from ..shot_camera import memory as shot_memory
run = shot_memory.run_for_prompt(prompt_id)
except Exception: # noqa: BLE001 -- optional module and old memory files are non-fatal
return None
if not isinstance(run, dict):
return None
arms = run.get("arms") if isinstance(run.get("arms"), list) else []
views = run.get("views") if isinstance(run.get("views"), list) else []
details = run.get("details") if isinstance(run.get("details"), list) else []
return {"views": [str(row.get("view")) for row in views if isinstance(row, dict) and row.get("view")],
"cuts_added": sum(str(arm) == "split:yes" for arm in arms),
"camera_moves_added": sum(str(arm) == "move:yes" for arm in arms),
"details_added": [str(row.get("phrase")) for row in details if isinstance(row, dict) and row.get("phrase")],
"final_text": run.get("text") if isinstance(run.get("text"), str) else None}
def _shortcut_units(text, items=None):
"""Find the user's actual shortcut triggers in a source prompt, preserving order."""
items = items if items is not None else [s for s in shortcuts.listing() if s.enabled]
candidates = sorted(((trigger, item.name) for item in items
for trigger in item.triggers if trigger), key=lambda row: -len(row[0]))
if not candidates:
return []
pattern = re.compile(r"(?<!\w)(?:" + "|".join(re.escape(trigger) for trigger, _name in candidates) + r")(?!\w)", re.IGNORECASE)
names = {trigger.casefold(): name for trigger, name in candidates}
return [names.get(match.group(0).casefold(), match.group(0)) for match in pattern.finditer(str(text or ""))]
def _shortcut_trigger(name, items):
for item in items:
if item.name.casefold() == str(name).casefold() and item.triggers:
return item.triggers[0]
return str(name)
def _training_counts(data):
"""Rating-weighted shortcut and ordering counts from recorded user runs."""
items = [s for s in shortcuts.listing() if s.enabled]
use = Counter()
transitions = Counter()
neutral = Counter()
for row in data.get("captures", []):
if not isinstance(row, dict):
continue
units = _shortcut_units(row.get("source_text"), items)
rating = row.get("rating")
weight = 2 if rating == "good" else -2 if rating == "bad" else 0
for unit in set(units):
if weight > 0:
use[(unit.casefold(), "good")] += weight
elif weight < 0:
use[(unit.casefold(), "bad")] += -weight
else:
neutral[unit.casefold()] += 1
for left, right in zip(units, units[1:]):
if weight > 0:
transitions[(left.casefold(), right.casefold(), "good")] += weight
elif weight < 0:
transitions[(left.casefold(), right.casefold(), "bad")] += -weight
return use, transitions, neutral
def generate(body):
"""Build a prompt with rated shortcut order and replacement relevance; no language model."""
body = body if isinstance(body, dict) else {}
idea = str(body.get("text") or "")[:_MAX_TEXT].strip()
items = [s for s in shortcuts.listing() if s.enabled and s.replacements and s.triggers]
if not items:
return {"why": "There are no enabled shortcuts with replacements yet.", "draft": _body_text(body), "generated": False}
with _LOCK:
data = _read()
usage, transitions, neutral = _training_counts(data)
units = _shortcut_units(idea, items)
idea_words = {word.casefold() for word in _WORD.findall(idea) if word.casefold() not in _STOP}
chosen = []
reasons = []
if units:
chosen.extend(units)
elif not idea:
# With no seed idea, reuse the strongest liked source composition as the
# starting grammar. It remains the user's macro prompt, including $vars.
rated = [r for r in data.get("captures", []) if isinstance(r, dict) and r.get("rating") == "good" and r.get("source_text")]
if rated:
frequency = Counter(r.get("source_text") for r in rated)
last_seen = {r.get("source_text"): i for i, r in enumerate(rated)}
source = max(frequency, key=lambda value: (frequency[value], len(_shortcut_units(value, items)), last_seen[value]))
idea = _remove_current_anchor(str(source or "")[:_MAX_TEXT], body)
chosen.extend(_shortcut_units(idea, items))
units = _shortcut_units(idea, items)
reasons.append("started from a liked prompt structure")
else:
remembered = [r for r in data.get("captures", []) if isinstance(r, dict) and r.get("source_text")]
if remembered:
idea = _remove_current_anchor(str(remembered[-1].get("source_text") or "")[:_MAX_TEXT], body)
chosen.extend(_shortcut_units(idea, items))
units = _shortcut_units(idea, items)
reasons.append("started from a recent prompt structure while ratings are still sparse")
if not chosen and idea_words:
candidates = []
for item in items:
replacement_words = {word.casefold() for phrase in item.replacements
for word in _WORD.findall(phrase) if word.casefold() not in _STOP}
overlap = len(idea_words & replacement_words) / max(1, len(idea_words | replacement_words) ** 0.5)
good = usage[(item.name.casefold(), "good")]
bad = usage[(item.name.casefold(), "bad")]
score = overlap + 0.08 * (good - bad) + 0.015 * neutral[item.name.casefold()]
if overlap > 0:
candidates.append((score, item))
candidates.sort(key=lambda row: (-row[0], row[1].name.casefold()))
for _score, item in candidates[:2]:
chosen.append(item.name)
if candidates:
reasons.append("matched shortcut actions to the idea and rating history")
if chosen:
last = chosen[-1].casefold()
next_candidates = []
for item in items:
name = item.name.casefold()
if name in {x.casefold() for x in chosen}:
continue
good = transitions[(last, name, "good")]
bad = transitions[(last, name, "bad")]
score = good - bad
if good or bad:
next_candidates.append((score, good, item))
next_candidates.sort(key=lambda row: (-row[0], -row[1], row[2].name.casefold()))
if next_candidates and next_candidates[0][0] > 0 and len(chosen) < 3:
chosen.append(next_candidates[0][2].name)
reasons.append("continued a shortcut order that received positive ratings")
if not idea:
# A cold start still gives a useful, editable draft from the available
# library; rated runs will gradually replace this inventory-based pick.
item = max(items, key=lambda row: (sum(len(x) for x in row.replacements), row.name.casefold()))
chosen = [item.name]
idea = _shortcut_trigger(item.name, items)
units = list(chosen)
reasons.append("cold start: selected from the enabled shortcut library")
elif chosen:
additions = chosen[len(units):]
if additions:
triggers = [_shortcut_trigger(name, items) for name in additions]
idea = " ".join(part for part in (idea, " ".join(triggers)) if part).strip()
base = _body_text({**body, "text": idea})
return {"base": base, "draft": base, "generated": True, "source_text": idea,
"shortcuts": chosen, "reasons": reasons,
"training_runs": sum(isinstance(r, dict) and r.get("rating") in ("good", "bad") for r in data.get("captures", [])),
"shot_structure": _shot_structure(base),
"postfix_preserved": bool(_postfix_text(body))}
def _learned(base, data=None):
data = data or _read()
key = _key(base)
for row in reversed(data["examples"]):
if isinstance(row, dict) and row.get("key") == key:
return row
return None
def _prompt_votes(row):
ratings = row.get("prompt_ratings", {}) if isinstance(row, dict) else {}
if not isinstance(ratings, dict):
return {"good": 0, "bad": 0}
return {"good": sum(value == "good" for value in ratings.values()),
"bad": sum(value == "bad" for value in ratings.values())}
def _needs_review(row):
votes = _prompt_votes(row)
return votes["bad"] - votes["good"] >= 2
def transform(prompt):
"""Optional core hook: replay a reviewed edit only for the exact same prompt."""
with _LOCK:
data = _read()
if not data["enabled"]:
return None
row = _learned(prompt, data)
# A single dislike can be seed luck. Repeated negative prompt feedback
# stops automatic replay until the user reviews and saves a new edit.
return (_preserve_postfix(row.get("accepted", prompt), row.get("postfix", ""))
if row and not _needs_review(row) else None)
def draft(body):
base = _body_text(body)
with _LOCK:
data = _read()
row = _learned(base, data)
votes = _prompt_votes(row)
review_needed = row is not None and _needs_review(row)
use_learned = data["enabled"] and row is not None and not review_needed
accepted = _preserve_postfix(row.get("accepted", base), _postfix_text(body)) if use_learned else base
return {"base": base, "draft": accepted,
"learned": use_learned, "enabled": data["enabled"],
"edits": row.get("edits", []) if use_learned else [],
"prompt_votes": votes,
"prompt_review_needed": review_needed,
"shot_structure": _shot_structure(base),
"postfix_preserved": bool(_postfix_text(body))}
def _edit_summary(before, after):
a, b = _EDIT_TOKEN.findall(before), _EDIT_TOKEN.findall(after)
matcher = difflib.SequenceMatcher(a=a, b=b, autojunk=False)
out = []
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag != "equal":
out.append({"change": tag, "before": " ".join(a[i1:i2]),
"after": " ".join(b[j1:j2]), "position": i1})
return out[:100]
def learn(body):
body = body if isinstance(body, dict) else {}
accepted = body.get("accepted")
if not isinstance(accepted, str) or not accepted.strip():
raise ValueError("The reviewed prompt cannot be empty.")
accepted = accepted[:_MAX_TEXT]
base = _body_text(body)
if not base.strip():
raise ValueError("Enter a prompt idea before saving a learned edit.")
accepted = _preserve_postfix(accepted, _postfix_text(body))
row = {"key": _key(base), "base": base, "accepted": accepted,
"postfix": _postfix_text(body),
"edits": _edit_summary(base, accepted), "prompt_ratings": {}}
with _LOCK:
data = _read()
data["examples"] = [r for r in data["examples"]
if not isinstance(r, dict) or r.get("key") != row["key"]]
data["examples"].append(row)
data["examples"] = data["examples"][-_MAX_EXAMPLES:]
_write(data)
return {"learned": True, "edit_count": len(row["edits"]),
"examples": len(data["examples"]), "enabled": data["enabled"]}
def capture(prompt_id, prompt_text, source_text=None):
"""Keep the exact prompt sent to ComfyUI until its render is rated."""
if not isinstance(prompt_id, str) or not prompt_id:
return {"captured": False}
prompt_text = str(prompt_text or "")[:_MAX_TEXT]
if not prompt_text.strip():
return {"captured": False}
with _LOCK:
data = _read()
example = next((r for r in reversed(data["examples"])
if isinstance(r, dict) and r.get("accepted") == prompt_text), None)
camera = _shot_camera_run(prompt_id)
record = {"prompt_id": prompt_id, "text": prompt_text,
"source_text": str(source_text or "")[:_MAX_TEXT],
"rating": None,
"example_key": example.get("key") if example else None,
"example_text": example.get("accepted") if example else None,
"shot_structure": _shot_structure(prompt_text),
"shot_camera": {key: value for key, value in (camera or {}).items() if key != "final_text"}}
data["captures"] = [r for r in data["captures"]
if not isinstance(r, dict) or r.get("prompt_id") != prompt_id]
data["captures"].append(record)
data["captures"] = data["captures"][-_MAX_CAPTURES:]
_write(data)
return {"captured": True, "linked": True,
"shot_structure": record["shot_structure"],
"shot_camera": record["shot_camera"]}
def rate(prompt_id, rating, axis=None):
"""Attach prompt-relevant ratings to the reviewed prompt that ran."""
if not isinstance(prompt_id, str) or not prompt_id:
return {"linked": False}
with _LOCK:
data = _read()
capture_row = next((r for r in reversed(data["captures"])
if isinstance(r, dict) and r.get("prompt_id") == prompt_id), None)
if not capture_row:
return {"linked": False}
value = ("good" if rating == "liked" else
"bad" if rating == "disliked" and axis != "image" else None)
capture_row["rating"] = value
example = next((r for r in data["examples"]
if isinstance(r, dict) and r.get("key") == capture_row.get("example_key")
and r.get("accepted") == capture_row.get("example_text")), None)
if example:
ratings = example.setdefault("prompt_ratings", {})
else:
ratings = None
if ratings is not None and value == "good":
ratings[prompt_id] = "good"
elif ratings is not None and value == "bad":
# A general dislike may include bad composition. An explicit bad
# image is excluded because it does not identify a prompt problem.
ratings[prompt_id] = "bad"
elif ratings is not None:
# Clearing or an image-only dislike removes this run's prompt vote.
ratings.pop(prompt_id, None)
_write(data)
return {"linked": True, "prompt_votes": _prompt_votes(example) if example else {"good": 0, "bad": 0},
"prompt_review_needed": _needs_review(example) if example else False,
"shot_camera": capture_row.get("shot_camera", {})}
def set_enabled(enabled):
if not isinstance(enabled, bool):
raise ValueError("enabled must be true or false")
with _LOCK:
data = _read()
data["enabled"] = enabled
_write(data)
return status(data)
def clear():
with _LOCK:
data = {"enabled": False, "examples": [], "captures": []}
_write(data)
return status(data)
def status(data=None):
data = data or _read()
return {"enabled": data["enabled"], "examples": len(data["examples"]),
"captured_prompts": len(data["captures"]),
"prompt_likes": sum(_prompt_votes(row)["good"] for row in data["examples"] if isinstance(row, dict)),
"prompt_dislikes": sum(_prompt_votes(row)["bad"] for row in data["examples"] if isinstance(row, dict)),
"prompt_review_needed": sum(_needs_review(row) for row in data["examples"] if isinstance(row, dict)),
"shot_camera_runs": sum(bool(row.get("shot_camera")) for row in data["captures"] if isinstance(row, dict)),
"shot_views_added": sum(len(row.get("shot_camera", {}).get("views", [])) for row in data["captures"] if isinstance(row, dict)),
"shot_cuts_added": sum(int(row.get("shot_camera", {}).get("cuts_added", 0)) for row in data["captures"] if isinstance(row, dict)),
"shot_camera_moves_added": sum(int(row.get("shot_camera", {}).get("camera_moves_added", 0)) for row in data["captures"] if isinstance(row, dict)),
"shot_details_added": sum(len(row.get("shot_camera", {}).get("details_added", [])) for row in data["captures"] if isinstance(row, dict)),
"rated_prompt_runs": sum(row.get("rating") == "good" for row in data["captures"] if isinstance(row, dict)),
"disliked_prompt_runs": sum(row.get("rating") == "bad" for row in data["captures"] if isinstance(row, dict))}
def analysis():
items = [s for s in shortcuts.listing() if s.enabled and s.replacements]
categories, phrases, words, patterns, positions, neighbors = Counter(), Counter(), Counter(), Counter(), {}, Counter()
phrase_rows = []
for item in items:
category = " / ".join(x for x in (item.category, item.sub_category) if x) or "Uncategorised"
categories[category] += 1
for replacement in item.replacements:
clean = " ".join(replacement.split())
if not clean:
continue
phrases[clean.casefold()] += 1
phrase_rows.append((clean, category))
tokens = [x.casefold() for x in _WORD.findall(clean) if x.casefold() not in _STOP]
words.update(tokens)
for i, token in enumerate(tokens):
bucket = "single" if len(tokens) == 1 else "start" if i == 0 else "end" if i == len(tokens) - 1 else "middle"
positions.setdefault(token, Counter())[bucket] += 1
if i:
neighbors[(tokens[i - 1], token)] += 1
nlp = _nlp_model()
if nlp is not None:
pattern = " ".join(t.pos_ or "X" for t in nlp(clean) if not t.is_punct)
if pattern:
patterns[pattern] += 1
top_phrases = []
seen = set()
for phrase, category in sorted(phrase_rows, key=lambda r: (-phrases[r[0].casefold()], r[0].casefold())):
key = phrase.casefold()
if key in seen:
continue
seen.add(key)
top_phrases.append({"text": phrase, "category": category, "count": phrases[key]})
if len(top_phrases) == 12:
break
with _LOCK:
state = status()
return {"shortcuts": len(items), "categories": [{"name": k, "count": v} for k, v in categories.most_common(12)],
"phrases": top_phrases, "words": [{"text": k, "count": v} for k, v in words.most_common(16)],
"positions": [{"text": word, **counts} for word, counts in sorted(
positions.items(), key=lambda item: (-sum(item[1].values()), item[0]))[:8]],
"neighbors": [{"left": left, "right": right, "count": count}
for (left, right), count in neighbors.most_common(10)],
"patterns": [{"pattern": k, "count": v} for k, v in patterns.most_common(10)],
"spacy": _nlp is not None, **state}
@@ -378,15 +378,6 @@ def record_run(prompt_id, chose):
_save(data)
@_locked
def run_for_prompt(prompt_id):
"""Return the shot-camera choices made for one queued prompt, if they are ready."""
if not isinstance(prompt_id, str) or not prompt_id:
return None
run = (_read().get("runs") or {}).get(prompt_id)
return dict(run) if isinstance(run, dict) else None
def _sign(rating, axis):
"""+1 liked; -1 disliked for the camera; 0 for no rating, or a dislike that blames the picture alone."""
return 1 if rating == "liked" else -1 if rating == "disliked" and axis != "image" else 0