Movie Editor: fix inspector input/label/control UX papercuts

- Rebuild guard now protects only typed/dragged fields (text/number/range),
  not selects/checkboxes — so dependent controls (custom length input, Ken
  Burns knobs, Steer mode 'absolute', Embed/Mid-scene guide) reveal on the
  toggle instead of only after blur (inspector.js + engine_settings.js).
- Tag blend-length and video 'Source in' fields so non-quiet patchScene no
  longer yanks them mid-type.
- Blur % and Zoom amount % slider labels update live while dragging.
- Surface Width/Height in the Project Output section (out of the collapsed
  Advanced fold); leave Max scenes in Advanced.
- Custom scene length reflects its snapped value back into the field on commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-06-17 12:28:31 +03:00
co-authored by Claude Opus 4.8
parent 0d909f4b18
commit 94ffbb3cda
2 changed files with 59 additions and 23 deletions
+13 -4
View File
@@ -510,15 +510,24 @@
container.append(foot);
}
// Protect a text/number/range field being typed into from autosave rebuilds, but let
// checkboxes and selects rebuild immediately so their dependent controls (absolute
// strength, embed mode/strength, mid-scene strength, …) appear right after the toggle.
function shouldProtect(a, scope) {
if (!a || !a.dataset || !a.dataset.k || !scope || !scope.contains(a)) return false;
if (a.tagName === "TEXTAREA") return true;
if (a.tagName !== "INPUT") return false;
const t = (a.type || "text").toLowerCase();
return t !== "checkbox" && t !== "radio";
}
function render() {
if (!overlay) return;
const content = overlay.querySelector(".modal-content");
if (_editing) {
const content = overlay.querySelector(".modal-content");
const a = document.activeElement;
if (a && content && content.contains(a) && (a.tagName === "INPUT" || a.tagName === "TEXTAREA" || a.tagName === "SELECT") && a.dataset.k) return;
if (shouldProtect(document.activeElement, content)) return;
_editing = false;
}
const content = overlay.querySelector(".modal-content");
const scrollTop = content.scrollTop;
clear(content);
renderContent(content, S.get());
+46 -19
View File
@@ -124,6 +124,9 @@
if (mode === "custom") {
const i = el("input"); i.type = "number"; i.value = effOf(scene, kind); i.dataset.k = "sc-" + kind;
i.oninput = () => S.patchSceneQuiet(scene.id, { [kind]: m.snap(parseInt(i.value || "0", 10)) });
// Stored value is snapped (frames → 9/17/25…, fps → ≥1); reflect that back on commit
// so the field doesn't keep showing an unsnapped number the engine won't actually use.
i.onchange = () => { i.value = m.snap(parseInt(i.value || "0", 10)); };
wrap.append(i);
} else {
const note = el("div", "len-readout");
@@ -220,8 +223,13 @@
};
const blur = el("input"); blur.type = "range"; blur.min = "0"; blur.max = "1"; blur.step = "0.05";
blur.value = fx.blur || 0;
blur.oninput = () => patchFx("blur", parseFloat(blur.value), true);
more.append(field(`Blur (${Math.round((fx.blur || 0) * 100)}%)`, blur));
const blurField = field(`Blur (${Math.round((fx.blur || 0) * 100)}%)`, blur);
const blurLbl = blurField.querySelector("span");
blur.oninput = () => {
patchFx("blur", parseFloat(blur.value), true);
if (blurLbl) blurLbl.textContent = `Blur (${Math.round(parseFloat(blur.value) * 100)}%)`;
};
more.append(blurField);
const lenRow = el("div", "fields-row");
lenRow.append(lengthControl(scene, "frames"));
lenRow.append(lengthControl(scene, "fps"));
@@ -230,7 +238,7 @@
if (r?.media || ref) {
const srcTag = el("div", "insp-tag"); srcTag.textContent = "Source trim (slip)"; more.append(srcTag);
const inRow = el("div", "fields-row");
const iIn = el("input"); iIn.type = "number"; iIn.min = "0"; iIn.step = "0.05";
const iIn = el("input"); iIn.type = "number"; iIn.min = "0"; iIn.step = "0.05"; iIn.dataset.k = "vc-src-in";
iIn.value = scene.source_in || 0;
iIn.oninput = () => S.setSourceTrim(scene.id, { source_in: parseFloat(iIn.value || 0) });
inRow.append(field("Source in (s)", iIn));
@@ -293,8 +301,13 @@
};
const blur = el("input"); blur.type = "range"; blur.min = "0"; blur.max = "1"; blur.step = "0.05";
blur.value = fx.blur || 0; blur.dataset.k = "sc-fx-blur";
blur.oninput = () => patchFx("blur", parseFloat(blur.value), true);
more.append(field(`Blur (${Math.round((fx.blur || 0) * 100)}%)`, blur));
const blurField = field(`Blur (${Math.round((fx.blur || 0) * 100)}%)`, blur);
const blurLbl = blurField.querySelector("span");
blur.oninput = () => {
patchFx("blur", parseFloat(blur.value), true);
if (blurLbl) blurLbl.textContent = `Blur (${Math.round(parseFloat(blur.value) * 100)}%)`;
};
more.append(blurField);
const fadeRow = el("div", "fields-row");
const fi = _num(fx.fade_in || 0, "sc-fx-fi", { min: 0, max: 10, step: 0.1 });
fi.oninput = () => patchFx("fade_in", parseFloat(fi.value || "0"), true);
@@ -332,8 +345,13 @@
const zRow = el("div", "fields-row");
const zRatio = el("input"); zRatio.type = "range"; zRatio.min = "5"; zRatio.max = "50"; zRatio.step = "1";
zRatio.value = Math.round((fx.zoom_ratio != null ? fx.zoom_ratio : 0.15) * 100);
zRatio.oninput = () => patchFxObj({ zoom_ratio: parseFloat(zRatio.value) / 100 }, true);
zRow.append(field(`Zoom amount (${zRatio.value}%)`, zRatio));
const zRatioField = field(`Zoom amount (${zRatio.value}%)`, zRatio);
const zRatioLbl = zRatioField.querySelector("span");
zRatio.oninput = () => {
patchFxObj({ zoom_ratio: parseFloat(zRatio.value) / 100 }, true);
if (zRatioLbl) zRatioLbl.textContent = `Zoom amount (${zRatio.value}%)`;
};
zRow.append(zRatioField);
const zStart = _num(fx.zoom_start_frame != null ? fx.zoom_start_frame : 0, "sc-fx-zstart", {
min: 0, max: Math.max(0, effFrames - 1), step: 1,
});
@@ -413,6 +431,7 @@
body.append(field("Video transition", typeSel));
const fr = el("input"); fr.type = "number"; fr.min = "1"; fr.max = "120"; fr.step = "1";
fr.dataset.k = "sc-blend-frames";
fr.value = type ? frames : "";
fr.disabled = !type;
fr.placeholder = type ? "" : "—";
@@ -505,9 +524,11 @@
const row1 = el("div", "fields-row");
row1.append(numberField("Frames / scene", p.num_frames_per_scene, (v) => S.patchProjectQuiet({ num_frames_per_scene: v }), "pj-frames"));
row1.append(numberField("FPS", p.frame_rate, (v) => S.patchProjectQuiet({ frame_rate: v }), "pj-fps"));
body.append(row1);
const row2 = el("div", "fields-row");
row2.append(numberField("FPS", p.frame_rate, (v) => S.patchProjectQuiet({ frame_rate: v }), "pj-fps"));
row2.append(numberField("Width", p.width != null ? p.width : 768, (v) => S.patchProjectQuiet({ width: v }), "pj-w"));
row2.append(numberField("Height", p.height != null ? p.height : 512, (v) => S.patchProjectQuiet({ height: v }), "pj-h"));
body.append(row2);
const promptTag = el("div", "insp-tag"); promptTag.textContent = "Prompt"; body.append(promptTag);
@@ -525,10 +546,6 @@
foldSection("Advanced project settings", false, (adv) => {
adv.append(numberField("Max scenes", p.max_scenes, (v) => S.patchProjectQuiet({ max_scenes: v }), "pj-max"));
const row3 = el("div", "fields-row");
row3.append(numberField("Width", p.width != null ? p.width : 768, (v) => S.patchProjectQuiet({ width: v }), "pj-w"));
row3.append(numberField("Height", p.height != null ? p.height : 512, (v) => S.patchProjectQuiet({ height: v }), "pj-h"));
adv.append(row3);
});
}
@@ -884,17 +901,27 @@
body.append(sec);
}
// While the user is actively editing one of our fields, DON'T rebuild the inspector —
// autosave fires ~1s and a rebuild would yank the field out, drop the selection and
// make typing append (e.g. "512" + "640" -> "512640"). We defer and re-sync on blur.
// While the user is actively editing one of our text/number/range fields, DON'T rebuild
// the inspector — autosave fires ~1s and a rebuild would yank the field out, drop the
// caret and make typing append (e.g. "512" + "640" -> "512640"). We defer and re-sync on
// blur. Checkboxes and selects are DISCRETE commits, not typing: rebuilding right after
// them is required so their dependent controls (custom length input, Ken Burns knobs,
// absolute strength, …) appear/disappear immediately instead of only after blur.
function shouldProtect(a) {
if (!a || !a.dataset || !a.dataset.k || !body.contains(a)) return false;
if (a.tagName === "TEXTAREA") return true;
if (a.tagName !== "INPUT") return false; // SELECT — let it rebuild to reveal dependents
const t = (a.type || "text").toLowerCase();
return t !== "checkbox" && t !== "radio"; // protect text/number/range/color; rebuild toggles
}
let _editing = false;
function render(st) {
if (_editing) {
// Only skip if a field is actually still focused; else the flag got stuck (focused
// element removed without focusout) — clear it so the inspector resumes updating.
const a = document.activeElement;
if (a && body.contains(a) && (a.tagName === "INPUT" || a.tagName === "TEXTAREA" || a.tagName === "SELECT") && a.dataset.k) return;
// Only skip if a protected (typing) field is actually still focused; else the flag
// got stuck (focused element removed without focusout, or focus is on a discrete
// control) — clear it so the inspector resumes updating.
if (shouldProtect(document.activeElement)) return;
_editing = false;
}