Two pieces of the "port v4's exact UI onto v5's backend" plan: - core/contract.py: new optional ModuleSpec.category field (closed set: continuity, guidance, conditioning, sampling, post, system), validated in core/schema.py, threaded through core/registry.py's FIELDS and to_manifest(). Presentation grouping, independent of STAGE (execution order) -- a module can be stage="sampling" and category="guidance" at once. Only the 4 existing modules that actually render a settings panel (alg, sharpen, appearance, full_control) got a category assigned; the other 12 have none to categorize (no SETTINGS block) and stay uncategorised, which is valid. - v4's frontend (movie_editor/frontend/*, ~29k lines, 61 files) copied verbatim into app/legacy/. core/routes.py's "/" route now serves legacy/index.html instead of the composer entry point; a <base> tag in that file resolves its own relative script/link tags against /funpack/app/legacy/ regardless of what URL it's served at, so every file moved as a unit with zero per-file path edits. Verified live: all 61 assets load 200 OK, the real v4 onboarding screen renders pixel-for- pixel (it's the actual DOM/CSS, not a reimplementation), and several v4 API calls (/api/media, /api/projects, /api/git/status) already succeed against v5's real backend by coincidence of shared route naming -- the rest 404/405 as expected, since api.js's ~90 functions haven't been rewired to v5's actual routes yet (next piece of this plan). The old composer entry point (app/index.html, app/composer/**) stays in the tree, unreferenced, until a later cleanup pass once nothing imports it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
109 lines
4.2 KiB
JavaScript
109 lines
4.2 KiB
JavaScript
// A knob left at its default is not a setting — it is the absence of one.
|
|
//
|
|
// Reported from a rental: the H3 gain dials could not be cleared. Once touched, a value was
|
|
// written into the project file and stayed there; typing the default back in stored the
|
|
// default rather than removing the key, so the value outlived the refinement key it came
|
|
// from and looked like learned state that would not clear.
|
|
//
|
|
// The learned values themselves have always lived only in the refinement key. What leaked
|
|
// into the project was the MANUAL side of the same dials, which from the user's chair is the
|
|
// same knob.
|
|
|
|
const test = require("node:test");
|
|
const assert = require("node:assert");
|
|
|
|
// The rule under test, extracted so it can run without the DOM the renderer needs.
|
|
function writeSamplerInput(store, k, value, immediate) {
|
|
if (value === k.default) {
|
|
store.unsetSamplerInput(k.name);
|
|
if (immediate) store.flushSave?.();
|
|
return;
|
|
}
|
|
(immediate ? store.setSamplerInputNow : store.setSamplerInput)(k.name, value);
|
|
}
|
|
|
|
function fakeStore() {
|
|
const calls = [];
|
|
return {
|
|
calls,
|
|
unsetSamplerInput: (n) => calls.push(["unset", n]),
|
|
setSamplerInput: (n, v) => calls.push(["set", n, v]),
|
|
setSamplerInputNow: (n, v) => calls.push(["setNow", n, v]),
|
|
flushSave: () => calls.push(["flush"]),
|
|
};
|
|
}
|
|
|
|
const GAIN = { name: "h3_gain_video", default: 1.0 };
|
|
const BIAS = { name: "h3_taste_bias", default: 0.0 };
|
|
|
|
test("returning a dial to its default removes it from the project", () => {
|
|
const s = fakeStore();
|
|
writeSamplerInput(s, GAIN, 1.0, true);
|
|
assert.deepStrictEqual(s.calls[0], ["unset", "h3_gain_video"]);
|
|
});
|
|
|
|
test("a default is never stored as a value", () => {
|
|
const s = fakeStore();
|
|
writeSamplerInput(s, GAIN, 1.0, true);
|
|
assert.ok(!s.calls.some((c) => c[0] === "set" || c[0] === "setNow"));
|
|
});
|
|
|
|
test("a real change is still stored", () => {
|
|
const s = fakeStore();
|
|
writeSamplerInput(s, GAIN, 0.85, true);
|
|
assert.deepStrictEqual(s.calls[0], ["setNow", "h3_gain_video", 0.85]);
|
|
});
|
|
|
|
test("a zero default clears too", () => {
|
|
// h3_taste_bias is centred on 0, not 1 — a rule keyed on truthiness would never clear it.
|
|
const s = fakeStore();
|
|
writeSamplerInput(s, BIAS, 0.0, true);
|
|
assert.deepStrictEqual(s.calls[0], ["unset", "h3_taste_bias"]);
|
|
});
|
|
|
|
test("a non-zero value on a zero-default knob is stored", () => {
|
|
const s = fakeStore();
|
|
writeSamplerInput(s, BIAS, -0.2, true);
|
|
assert.deepStrictEqual(s.calls[0], ["setNow", "h3_taste_bias", -0.2]);
|
|
});
|
|
|
|
test("clearing on commit flushes, so the removal reaches disk", () => {
|
|
const s = fakeStore();
|
|
writeSamplerInput(s, GAIN, 1.0, true);
|
|
assert.ok(s.calls.some((c) => c[0] === "flush"));
|
|
});
|
|
|
|
test("clearing while typing does not flush", () => {
|
|
// Quiet while the caret is in the field, same rule the text knobs already follow.
|
|
const s = fakeStore();
|
|
writeSamplerInput(s, GAIN, 1.0, false);
|
|
assert.ok(!s.calls.some((c) => c[0] === "flush"));
|
|
});
|
|
|
|
test("a store without flushSave does not throw", () => {
|
|
const s = fakeStore();
|
|
delete s.flushSave;
|
|
assert.doesNotThrow(() => writeSamplerInput(s, GAIN, 1.0, true));
|
|
});
|
|
|
|
test("booleans clear when set back to their default", () => {
|
|
const s = fakeStore();
|
|
writeSamplerInput(s, { name: "segmented_detailing", default: false }, false, true);
|
|
assert.deepStrictEqual(s.calls[0], ["unset", "segmented_detailing"]);
|
|
});
|
|
|
|
// ── handing ownership back ──────────────────────────────────────────────────
|
|
//
|
|
// Reported from a rental as a question, which is the best kind: "save as manual, load it,
|
|
// switch to learned — and nothing gets cleared. Is it so?" It was. The six values stayed in
|
|
// the project, inert (learned mode reads the key, never the dials) and invisible (the dials
|
|
// hide in learned mode), until the next time the project was opened. Correct output, state
|
|
// nobody could see. That is the failure this whole rule exists to prevent.
|
|
|
|
test("an ordinary knob returning to default clears only itself", () => {
|
|
const s = fakeStore();
|
|
writeSamplerInput(s, GAIN, 1.0, true);
|
|
assert.deepStrictEqual(s.calls.filter((c) => c[0] === "unset"), [["unset", "h3_gain_video"]]);
|
|
});
|
|
|