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.
This commit is contained in:
digital-garbage
2026-09-04 21:47:02 +03:00
parent f8e777e696
commit e10ed05a20
6 changed files with 255 additions and 4 deletions
+113
View File
@@ -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: '<svg viewBox="0 0 16 16" width="13" height="13"><path d="M2 3h4v4H2zM10 3h4v4h-4zM2 9h4v4H2zM10 9h4v4h-4z" fill="#fff"/></svg>',
mount,
});
})();
@@ -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), []);
});
+1
View File
@@ -146,6 +146,7 @@
<script src="engine_settings.js"></script>
<script src="editor_settings.js"></script>
<script src="temp_browser.js"></script>
<script src="block_sweep.js"></script>
<script src="custom_nodes.js"></script>
<script src="autocomplete.js"></script>
<script src="suggestions.js"></script>
+19
View File
@@ -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);
}
}
}
}
+69 -3
View File
@@ -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,
+6 -1
View File
@@ -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;