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:
@@ -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), []);
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user