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;