From e10ed05a20bfaf4154f247cebdc78f41d0c063ca Mon Sep 17 00:00:00 2001
From: digital-garbage <133010426+digital-garbage@users.noreply.github.com>
Date: Fri, 4 Sep 2026 21:47:02 +0300
Subject: [PATCH] feat: Save video button + H3 block-repeat sweep panel in
Editor
Save video (transport bar, next to Save frame): copies the current clip's
render straight into the Media bin server-side via the existing import-clip
route -- no download-then-reupload round trip through the local machine.
H3 Block Repeat Sweep (Settings > Learning): runs one scene through several
block-repeat configs back to back on one fixed seed, gallery of labelled
results. One config per line: blocks;seam|noseam;times. Each run is a
one-off node_overrides on the built graph (same mechanism as the existing
anchor-guide i2v bypass) -- project Engine Settings are never touched, so
nothing needs restoring after and a crash mid-sweep leaves no residue.
---
movie_editor/frontend/block_sweep.js | 113 ++++++++++++++++++
.../frontend/block_sweep_config.test.js | 47 ++++++++
movie_editor/frontend/index.html | 1 +
movie_editor/frontend/player.js | 19 +++
movie_editor/frontend/store.js | 72 ++++++++++-
movie_editor/frontend/styles.css | 7 +-
6 files changed, 255 insertions(+), 4 deletions(-)
create mode 100644 movie_editor/frontend/block_sweep.js
create mode 100644 movie_editor/frontend/block_sweep_config.test.js
diff --git a/movie_editor/frontend/block_sweep.js b/movie_editor/frontend/block_sweep.js
new file mode 100644
index 00000000..a809b523
--- /dev/null
+++ b/movie_editor/frontend/block_sweep.js
@@ -0,0 +1,113 @@
+// H3 block-repeat sweep: one scene, one fixed seed, several repeat configs run back to back
+// and captured into a labelled gallery — "what option produced what output" at a glance.
+// Each run is a one-off node_overrides on the built graph (store.runBlockRepeatSweep); the
+// project's own Engine Settings are never touched. A section of the unified Settings window,
+// sibling to Refinement & Taste — this IS the research tooling that rule asks for.
+(function () {
+ const { el, clear } = window.dom;
+ const API = window.MovieEditorAPI;
+
+ function _activeScenes() {
+ const st = window.Store.get();
+ return (st.project?.scenes || []).filter((s) => !s.excluded);
+ }
+
+ function _sceneLabel(s, i) {
+ return `Scene ${i + 1}` + (s.text ? ": " + s.text.substring(0, 30) : "");
+ }
+
+ function _card(item) {
+ const card = el("div", "media-card tmp-card");
+ const thumb = el("div", "media-thumb");
+ if (item.media) {
+ const vid = document.createElement("video");
+ vid.src = API.resultUrl(window.Store.get().project.id, item.media);
+ vid.controls = true; vid.muted = true; vid.playsInline = true; vid.preload = "metadata";
+ thumb.append(vid);
+ } else {
+ thumb.append(el("span", "media-icon", "✕"));
+ }
+ card.append(thumb);
+ const nameEl = el("div", "media-name", item.label);
+ nameEl.title = item.label;
+ card.append(nameEl);
+ return card;
+ }
+
+ function mount(container, ctx) {
+ const S = window.Store;
+ let sceneId = (_activeScenes()[0] || {}).id || null;
+ let configText = "";
+
+ container.append(el("div", "es-hint",
+ "Runs ONE scene through several H3 block-repeat configs back to back, same frame and "
+ + "seed throughout, so results are directly comparable. Nothing here touches your Engine "
+ + "Settings — every run is a one-off override on that run only."));
+
+ const sceneRow = el("div", "sw-stack");
+ const sceneSel = el("select");
+ _activeScenes().forEach((s, i) => sceneSel.append(new Option(_sceneLabel(s, i), s.id)));
+ if (sceneId) sceneSel.value = sceneId;
+ sceneSel.onchange = () => { sceneId = sceneSel.value; };
+ sceneRow.append(el("label", "sw-label", "Scene to sweep"), sceneSel);
+ container.append(sceneRow);
+
+ const expCb = el("input"); expCb.type = "checkbox";
+ const expLabel = el("label", "sw-hint");
+ expLabel.append(expCb, document.createTextNode(
+ " Show experimental config — unvalidated, results will not match any prior sweep 1:1 "
+ + "(different checkpoint/steps/sampler port nothing over, see the block-repeat research notes)"));
+ container.append(expLabel);
+
+ const cfgWrap = el("div", "sw-stack");
+ cfgWrap.hidden = true;
+ const cfgHint = el("div", "es-hint",
+ "One config per line: blocks;seam|noseam;times — e.g. 10,11,13;seam;5 or 40-41;noseam;1. "
+ + "Blocks accept a single number, a range (31-40), or a comma list; times is clamped to 1-4.");
+ const cfgArea = document.createElement("textarea");
+ cfgArea.className = "sw-textarea";
+ cfgArea.rows = 6;
+ cfgArea.placeholder = "10,11,13;seam;5\n40-41;noseam;1";
+ cfgArea.oninput = () => { configText = cfgArea.value; };
+ const runBtn = el("button", "btn primary tiny", "▶ Run sweep");
+ cfgWrap.append(cfgHint, cfgArea, runBtn);
+ container.append(cfgWrap);
+ expCb.onchange = () => { cfgWrap.hidden = !expCb.checked; };
+
+ const statusEl = el("div", "pj-meta");
+ container.append(statusEl);
+ const grid = el("div", "media-grid tmp-grid");
+ container.append(grid);
+
+ function render() {
+ const bs = S.get().blockSweep || {};
+ runBtn.disabled = !!bs.running;
+ runBtn.textContent = bs.running ? `Running ${bs.current || 0}/${bs.total || 0}…` : "▶ Run sweep";
+ statusEl.textContent = bs.running
+ ? `Sweeping (seed ${bs.seed}): ${bs.label || ""}`
+ : bs.error
+ ? "Stopped: " + bs.error
+ : (bs.results && bs.results.length ? `${bs.results.length} result(s), seed ${bs.seed}` : "");
+ clear(grid);
+ (bs.results || []).forEach((item) => grid.append(_card(item)));
+ }
+
+ runBtn.onclick = () => {
+ if (!sceneId) { alert("No scene to sweep."); return; }
+ S.runBlockRepeatSweep(sceneId, configText);
+ };
+
+ const unsub = S.subscribe(render);
+ render();
+ return unsub;
+ }
+
+ window.SettingsWindow.register({
+ id: "block_sweep", group: "Learning", order: 1, title: "H3 Block Repeat Sweep",
+ subtitle: "Run one scene through several block-repeat configs, compare results side by side.",
+ keywords: "h3 block repeat sweep span loop seam experimental research gallery batch",
+ iconBg: "linear-gradient(180deg,#b48bff,#6d3fd8)",
+ icon: '',
+ mount,
+ });
+})();
diff --git a/movie_editor/frontend/block_sweep_config.test.js b/movie_editor/frontend/block_sweep_config.test.js
new file mode 100644
index 00000000..236340dc
--- /dev/null
+++ b/movie_editor/frontend/block_sweep_config.test.js
@@ -0,0 +1,47 @@
+// parseBlockSweepConfig: the "blocks;seam|noseam;times" line format for the H3 block-repeat
+// sweep panel, exercised rather than syntax-checked (see detail_verdict.test.js for why).
+const test = require("node:test");
+const assert = require("node:assert");
+const fs = require("node:fs");
+
+const src = fs.readFileSync(__dirname + "/store.js", "utf8");
+const grab = (name) => src.match(new RegExp(`function ${name}[\\s\\S]*?\\n \\}\\n`))[0];
+const { parseBlockSweepConfig } = new Function(
+ grab("parseBlockSweepConfig") + "; return { parseBlockSweepConfig };")();
+
+test("parses blocks, seam and times off a semicolon line", () => {
+ const [c] = parseBlockSweepConfig("10,11,13;seam;5");
+ assert.equal(c.blocks, "10,11,13");
+ assert.equal(c.spanLoop, true);
+ assert.equal(c.times, 4); // clamped to the widget's max
+});
+
+test("noseam and in-range times pass through untouched", () => {
+ const [c] = parseBlockSweepConfig("40-41;noseam;1");
+ assert.equal(c.blocks, "40-41");
+ assert.equal(c.spanLoop, false);
+ assert.equal(c.times, 1);
+});
+
+test("multiple lines, blank lines and stray whitespace are skipped/trimmed", () => {
+ const out = parseBlockSweepConfig("\n 10,11;seam;2 \n\n40-41;noseam;1\n");
+ assert.equal(out.length, 2);
+ assert.equal(out[0].label, "10,11;seam;2");
+ assert.equal(out[1].label, "40-41;noseam;1");
+});
+
+test("a line with no blocks field is dropped, not crashed on", () => {
+ assert.deepEqual(parseBlockSweepConfig(";seam;2"), []);
+});
+
+test("missing/garbage times default to 1 rather than NaN", () => {
+ const [c] = parseBlockSweepConfig("35;seam;");
+ assert.equal(c.times, 1);
+ const [c2] = parseBlockSweepConfig("35;seam;abc");
+ assert.equal(c2.times, 1);
+});
+
+test("empty input yields no configs", () => {
+ assert.deepEqual(parseBlockSweepConfig(""), []);
+ assert.deepEqual(parseBlockSweepConfig(null), []);
+});
diff --git a/movie_editor/frontend/index.html b/movie_editor/frontend/index.html
index b03327fd..db971c43 100644
--- a/movie_editor/frontend/index.html
+++ b/movie_editor/frontend/index.html
@@ -146,6 +146,7 @@
+
diff --git a/movie_editor/frontend/player.js b/movie_editor/frontend/player.js
index 2871ed39..6f50a1bf 100644
--- a/movie_editor/frontend/player.js
+++ b/movie_editor/frontend/player.js
@@ -1238,6 +1238,25 @@
};
_transportEl.append(anchorSel, anchorBtn);
+
+ if (S.clipSaveableToMediaBin(_currentClip.sceneId)) {
+ const saveVidBtn = el("button", "btn ghost tiny", "🎬 Save video");
+ saveVidBtn.title = "Save this clip's render to the Media bin — server-side, no download/re-upload.";
+ saveVidBtn.onclick = async () => {
+ saveVidBtn.disabled = true; saveVidBtn.textContent = "Saving…";
+ try {
+ const entry = await S.saveClipToMediaBin(_currentClip.sceneId);
+ saveVidBtn.textContent = entry ? "✓ Saved" : "🎬 Save video";
+ } catch (e) {
+ alert("Save failed: " + e.message);
+ saveVidBtn.textContent = "🎬 Save video";
+ } finally {
+ saveVidBtn.disabled = false;
+ setTimeout(() => _renderTransport(), 1600);
+ }
+ };
+ _transportEl.append(saveVidBtn);
+ }
}
}
}
diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js
index 124d3348..13c1e634 100644
--- a/movie_editor/frontend/store.js
+++ b/movie_editor/frontend/store.js
@@ -3628,12 +3628,12 @@
return [{ node: cfg.node, input: cfg.input, value: cfg.value }];
}
- async function _generateRun(sceneIds, onlyScene, prefix, resetSession) {
+ async function _generateRun(sceneIds, onlyScene, prefix, resetSession, extraOverrides) {
_interrupted = false;
_markGenInFlight(sceneIds);
set({ gen: { state: "queuing", promptId: null, media: [], msg: `${prefix}: queuing…`, step: 0, maxStep: 0 } });
try {
- const overrides = _anchorGuideNodeOverrides(sceneIds);
+ const overrides = [...(_anchorGuideNodeOverrides(sceneIds) || []), ...(extraOverrides || [])];
const r = await _retryOnTunnel(
() => API.generate(state.project.id, onlyScene || null, onlyScene ? null : sceneIds, !!resetSession, overrides),
10,
@@ -3681,6 +3681,55 @@
}
}
+ // H3 block-repeat sweep: one scene, one fixed seed, several repeat configs run back to
+ // back, each captured into a gallery labelled with the config that produced it. Every run
+ // is a one-off node_overrides on the BUILT graph (same mechanism as the anchor-guide i2v
+ // bypass above) — the project's own Engine Settings are never touched, so nothing needs
+ // restoring after and a crash mid-sweep leaves no residue.
+ // Line format: "blocks;seam|noseam;times", e.g. "10,11,13;seam;5" / "40-41;noseam;1".
+ function parseBlockSweepConfig(text) {
+ return String(text || "").split("\n").map((l) => l.trim()).filter(Boolean).map((line) => {
+ const [blocks, seam, times] = line.split(";").map((s) => (s || "").trim());
+ if (!blocks) return null;
+ return {
+ label: line, blocks,
+ spanLoop: seam.toLowerCase() === "seam",
+ times: Math.min(4, Math.max(1, parseInt(times, 10) || 1)),
+ };
+ }).filter(Boolean);
+ }
+
+ async function runBlockRepeatSweep(sceneId, configText) {
+ if (!state.project || !sceneId) return;
+ const configs = parseBlockSweepConfig(configText);
+ if (!configs.length) {
+ set({ blockSweep: { running: false, results: [], error: "No valid config lines — one per line, blocks;seam|noseam;times." } });
+ return;
+ }
+ // No seed variability: one seed for the whole sweep, chosen once, never persisted.
+ const seed = state.project.sampler_inputs?.seed ?? Math.floor(Math.random() * 0x7fffffff);
+ const results = [];
+ set({ blockSweep: { running: true, results, error: null, total: configs.length, current: 0, seed } });
+ for (let i = 0; i < configs.length; i++) {
+ const c = configs[i];
+ set({ blockSweep: { running: true, results: [...results], error: null, total: configs.length, current: i + 1, label: c.label, seed } });
+ const overrides = [
+ { node: "sampler", input: "seed", value: seed },
+ { node: "sampler", input: "h3_block_repeat", value: c.blocks },
+ { node: "sampler", input: "h3_block_repeat_span_loop", value: c.spanLoop },
+ { node: "sampler", input: "h3_block_repeat_times", value: c.times },
+ ];
+ const ok = await _generateRun([sceneId], sceneId, `Sweep ${i + 1}/${configs.length}: ${c.label}`, false, overrides);
+ if (!ok) {
+ set({ blockSweep: { running: false, results, error: `Stopped at "${c.label}": ${state.gen.msg}`, total: configs.length, seed } });
+ return;
+ }
+ const media = state.sceneRenders[sceneId]?.media;
+ results.push({ label: c.label, media });
+ }
+ set({ blockSweep: { running: false, results, error: null, total: configs.length, seed } });
+ }
+
async function generate(onlyScene) {
if (!state.project) return;
try {
@@ -4007,6 +4056,22 @@
}
}
+ // Save one clip (by scene id) straight to the Media bin — server-side copy via the same
+ // import-clip route saveSelectedToMediaBin uses, no browser download/re-upload round trip.
+ // Used by the transport bar's "Save video" button for whatever clip is under the playhead.
+ async function saveClipToMediaBin(sceneId) {
+ if (!state.project) return null;
+ const spec = _clipExportSpecForScene(sceneId);
+ if (!spec) throw new Error("Nothing to save — generate this clip first.");
+ try {
+ const res = await API.importClipToMediaBin(spec.clip, spec.name);
+ await loadMedia();
+ return res?.media || res;
+ } catch (e) {
+ throw new Error(_friendlyGenError(e.message));
+ }
+ }
+
async function saveSelectedToMediaBin() {
if (!state.project) return;
if (!_hasTimelineClipSelection()) { alert("Select a clip first."); return; }
@@ -4499,7 +4564,8 @@
isOverlayAudioTrack, isSeparatedAudioTrack,
resizeScene, setSceneGapAfter, splitScene, autoMontage, hasPlayableRender, snapFrames, snapFramesFloor, snapFramesCeil, sceneEffFrames, sceneEffFps, scenesOverridingProjectFrames, useProjectFramesEverywhere, setSourceTrim, trimSceneLeft, slipScene,
applyEnginePreset, ENGINE_PRESETS, undo, redo,
- refreshPreview, syncFromPreview, applyGlobalPromptQuiet, scheduleGlobalPromptApply, globalPromptApplyPending, buildGlobalPromptFromTimeline, syncGlobalPromptFromTimeline, generate: _clockedGenerate, generateMontage: _clockedMontage, generateSelected: _clockedSelected, genElapsed, selectedSceneCount, renderFinal, exportSelected, saveSelectedToMediaBin, clipSaveableToMediaBin, interrupt, loadModels, loadImageTargets, setModelInput, setModelBypass, setModelLink, clearNotice,
+ refreshPreview, syncFromPreview, applyGlobalPromptQuiet, scheduleGlobalPromptApply, globalPromptApplyPending, buildGlobalPromptFromTimeline, syncGlobalPromptFromTimeline, generate: _clockedGenerate, generateMontage: _clockedMontage, generateSelected: _clockedSelected, genElapsed, selectedSceneCount, renderFinal, exportSelected, saveSelectedToMediaBin, saveClipToMediaBin, clipSaveableToMediaBin,
+ runBlockRepeatSweep, parseBlockSweepConfig, interrupt, loadModels, loadImageTargets, setModelInput, setModelBypass, setModelLink, clearNotice,
projectVariables, setProjectVariables, promptTemplates, savePromptTemplate, deletePromptTemplate, applyPromptTemplate,
renamePromptTemplate, clearGlobalPrompt,
setConditioningSlot, setSamplerSlot, setSamplerInput, setSamplerInputNow, unsetSamplerInput, setStudioInput, setStudioInputNow,
diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css
index ecffae29..b2fdd6a1 100644
--- a/movie_editor/frontend/styles.css
+++ b/movie_editor/frontend/styles.css
@@ -2175,8 +2175,13 @@ body.tour-mode { overflow: hidden; }
.sw-body { flex: 1; overflow: auto; padding: 16px 18px 24px; }
/* grouped macOS-style rows (Refinement & Taste, Updates & ComfyUI) */
-.sw-stack { display: flex; flex-direction: column; }
+.sw-stack { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
.sw-hint { font-size: 11px; line-height: 1.5; color: var(--muted); margin-bottom: 12px; }
+.sw-label { font-size: 10px; font-weight: 700; letter-spacing: .5px; text-transform: uppercase;
+ color: var(--faint); }
+.sw-textarea { width: 100%; box-sizing: border-box; font-family: var(--font-mono, monospace);
+ font-size: 12px; padding: 8px; border: 1px solid var(--line); border-radius: 8px;
+ background: var(--ink-2); color: var(--text); resize: vertical; }
.sw-rows-label { margin: 16px 4px 5px; font-size: 10px; font-weight: 700; letter-spacing: .6px;
text-transform: uppercase; color: var(--faint); }
.sw-rows { display: flex; flex-direction: column; border: 1px solid var(--line); border-radius: 10px;