From 18cf60f243e293bc14688e21ba80770603d68e71 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sat, 27 Jun 2026 01:15:27 +0300 Subject: [PATCH] =?UTF-8?q?editor:=20Auto=20Montage=20tool=20=E2=80=94=20t?= =?UTF-8?q?railer-style=20cuts=20from=20rendered=20clips?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splices an already-rendered lead clip into shrinking-length segments (faster cuts toward the end) interleaved with randomly-drawn cutaway clips from a pool. Cutaway selection is random per-clip but always sequential within a clip, so later content in a clip can never surface before its own earlier content. Pure timeline construction, reuses the splitScene source_in/source_dur mechanism — no new generation involved. --- movie_editor/frontend/actionbar.js | 6 ++ movie_editor/frontend/index.html | 1 + movie_editor/frontend/montage_dialog.js | 97 +++++++++++++++++++++++++ movie_editor/frontend/store.js | 91 ++++++++++++++++++++++- movie_editor/frontend/styles.css | 2 + 5 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 movie_editor/frontend/montage_dialog.js diff --git a/movie_editor/frontend/actionbar.js b/movie_editor/frontend/actionbar.js index d8dd287e..4cdd9388 100644 --- a/movie_editor/frontend/actionbar.js +++ b/movie_editor/frontend/actionbar.js @@ -34,6 +34,12 @@ renderBtn.disabled = !hasProject() || busy(st); renderBtn.onclick = () => S.renderFinal(); mount.append(renderBtn); + + const montageBtn = el("button", "btn ghost compact", "⚡ Auto Montage"); + montageBtn.title = "Build a trailer-style cut from already-rendered clips"; + montageBtn.disabled = !hasProject() || busy(st); + montageBtn.onclick = () => window.MontageDialog?.open(); + mount.append(montageBtn); } if (window.ViewBus) window.ViewBus.subscribeActionbar(render); diff --git a/movie_editor/frontend/index.html b/movie_editor/frontend/index.html index 72980903..edf63bf2 100644 --- a/movie_editor/frontend/index.html +++ b/movie_editor/frontend/index.html @@ -120,6 +120,7 @@ + diff --git a/movie_editor/frontend/montage_dialog.js b/movie_editor/frontend/montage_dialog.js new file mode 100644 index 00000000..6a2e39ae --- /dev/null +++ b/movie_editor/frontend/montage_dialog.js @@ -0,0 +1,97 @@ +// Auto Montage dialog: pick a "lead" clip (plays in order, shrinking segments) and a pool +// of other rendered clips to randomly cut away to between segments — trailer-style pacing. +(function () { + const { el, clear } = window.dom; + const S = window.Store; + const O = window.OverlayUI; + + function videoClipScenes() { + const st = S.get(); + return (st.project?.scenes || []).filter((s) => S.isVideoClip(s) && !s.excluded); + } + + function clipLabel(s) { + const dur = S.sceneDurationSec ? S.sceneDurationSec(s) : null; + const frames = dur != null ? Math.round(dur * S.sceneEffFps(s)) : (s.frames || "?"); + const text = (s.text || "").trim().replace(/\s+/g, " "); + const snippet = text ? (text.length > 36 ? text.slice(0, 36) + "…" : text) : "(no prompt)"; + return `${snippet} — ${frames}f`; + } + + function openAutoMontageDialog() { + const clips = videoClipScenes(); + if (clips.length < 2) { + alert("Auto Montage needs at least two already-rendered clips on the timeline: one lead clip and at least one cutaway clip."); + return; + } + const { body, foot, close } = O.openModal({ + title: "Auto Montage", + subtitle: "Cuts the lead clip into shrinking segments and randomly inserts cutaways between them.", + widthClass: "ov-modal-wide", + }); + + let leadId = clips[0].id; + const poolIds = new Set(); + + const leadSel = document.createElement("select"); + leadSel.className = "ov-input"; + clips.forEach((c) => { + const o = document.createElement("option"); + o.value = c.id; + o.textContent = clipLabel(c); + leadSel.appendChild(o); + }); + leadSel.onchange = () => { leadId = leadSel.value; renderPoolList(); }; + body.appendChild(O.field("Lead clip (plays in order)", leadSel, { full: true })); + + const poolWrap = document.createElement("div"); + poolWrap.className = "ov-field full"; + body.appendChild(poolWrap); + + function renderPoolList() { + clear(poolWrap); + const lab = el("span", "ov-label", "Cutaway pool (random draw, in order within each clip)"); + poolWrap.appendChild(lab); + const list = el("div", "ov-checklist"); + clips.filter((c) => c.id !== leadId).forEach((c) => { + const row = document.createElement("label"); + row.className = "ov-check"; + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.checked = poolIds.has(c.id); + cb.onchange = () => { if (cb.checked) poolIds.add(c.id); else poolIds.delete(c.id); }; + const span = el("span", "", clipLabel(c)); + row.append(cb, span); + list.appendChild(row); + }); + poolWrap.appendChild(list); + } + renderPoolList(); + + const segInput = document.createElement("input"); + segInput.type = "number"; segInput.className = "ov-input"; segInput.min = "9"; segInput.step = "1"; segInput.value = "100"; + body.appendChild(O.field("Segment length (frames)", segInput)); + + const decayInput = document.createElement("input"); + decayInput.type = "range"; decayInput.min = "0.5"; decayInput.max = "1"; decayInput.step = "0.05"; decayInput.value = "0.85"; + const decayVal = el("span", "ov-range-val", "0.85"); + decayInput.oninput = () => { decayVal.textContent = decayInput.value; }; + body.appendChild(O.rangeField("Acceleration (1.0 = constant, lower = faster cuts toward the end)", decayInput, decayVal)); + + const buildBtn = el("button", "btn primary", "Build montage"); + buildBtn.onclick = () => { + if (!poolIds.size) { alert("Pick at least one cutaway clip."); return; } + const n = S.autoMontage({ + leadId, + poolIds: Array.from(poolIds), + segmentFrames: parseInt(segInput.value, 10) || 100, + decay: parseFloat(decayInput.value) || 1, + }); + if (!n) alert("Couldn't build the montage — check that the clips have enough rendered length."); + close(); + }; + foot.appendChild(buildBtn); + } + + window.MontageDialog = { open: openAutoMontageDialog }; +})(); diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index d0603143..a5e160a6 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -1839,6 +1839,95 @@ _syncSeparatedAudioTracks(); } + // Auto Montage: chop an already-rendered "lead" clip into shrinking-length segments + // (trailer pacing — cuts get faster toward the end) and interleave a randomly-drawn + // segment from a pool of other rendered clips after each one. Pure timeline construction: + // every built segment reuses an existing source clip's media via source_in/source_dur + // (same mechanism as splitScene), no new generation involved. + function autoMontage({ leadId, poolIds, segmentFrames, decay } = {}) { + if (!state.project) return; + const lead = scene(leadId); + if (!lead || !isVideoClip(lead)) return; + const pool = (poolIds || []).map((id) => scene(id)).filter((s) => s && isVideoClip(s)); + if (!pool.length) return; + + const base = Math.max(9, Math.round(segmentFrames || 100)); + const dk = decay != null ? Math.min(1, Math.max(0.3, decay)) : 1; + + const leadFps = sceneEffFps(lead); + const leadTotal = snapFrames((lead.source_dur != null ? lead.source_dur : sceneDurationSec(lead)) * leadFps); + const leadSrcIn = lead.source_in || 0; + + // Lead segments stay in chronological order (one continuous shot being interrupted), + // shrinking each cycle so cutaways come faster as the take progresses. + const leadSegs = []; + let consumed = 0, i = 0; + while (consumed < leadTotal - 8) { + const len = Math.min(leadTotal - consumed, Math.max(9, Math.round(base * Math.pow(dk, i)))); + leadSegs.push({ start: consumed, frames: snapFrames(len) }); + consumed += len; + i++; + } + if (!leadSegs.length) return; + + // Pool: chop every selected clip into its own ORDERED queue of base-length + // chunks (chronological — never reordered within a clip). Randomness only picks + // WHICH clip's queue to draw from next; each draw advances that clip's own cursor, + // so a clip's later content can never surface before its own earlier content. + const queues = pool.map((s) => { + const fps = sceneEffFps(s); + const total = snapFrames((s.source_dur != null ? s.source_dur : sceneDurationSec(s)) * fps); + const srcIn = s.source_in || 0; + const chunks = []; + let c = 0; + while (c < total - 8) { + const len = Math.min(total - c, base); + if (len >= 9) chunks.push({ scene: s, fps, start: c, frames: snapFrames(len), srcIn }); + c += len; + } + return { chunks, cursor: 0 }; + }).filter((q) => q.chunks.length); + if (!queues.length) return; + + function nextPoolChunk() { + let available = queues.filter((q) => q.cursor < q.chunks.length); + if (!available.length) { + queues.forEach((q) => { q.cursor = 0; }); // every clip exhausted — start each over from its own beginning + available = queues; + } + const q = available[Math.floor(Math.random() * available.length)]; + return q.chunks[q.cursor++]; + } + + function buildSegment(src, srcInSec, lenFrames, fps) { + const out = JSON.parse(JSON.stringify(src)); + out.id = "c" + Math.random().toString(36).slice(2, 10) + Date.now().toString(36); + out.gen_unit_id = src.id; + out.source_in = srcInSec; + out.source_dur = lenFrames / fps; + out.frames = lenFrames; + out.frames_mode = "timeline"; + out.rating = ""; + out.transition_to_next = "cut"; + out.transition_frames = null; + return out; + } + + _historyRecord(); + const built = []; + leadSegs.forEach((seg) => { + built.push(buildSegment(lead, leadSrcIn + seg.start / leadFps, seg.frames, leadFps)); + const chunk = nextPoolChunk(); + built.push(buildSegment(chunk.scene, chunk.srcIn + chunk.start / chunk.fps, chunk.frames, chunk.fps)); + }); + built[built.length - 1].transition_to_next = ""; + + state.project.scenes.push(...built); + window.Timeline?.requestAutoFit?.(); + notify(); scheduleSave(); + return built.length; + } + function moveScene(id, delta) { if (!state.project) return; _historyRecord(); @@ -3911,7 +4000,7 @@ bringOverlayToFront, sendOverlayToBack, bringOverlayForward, sendOverlayBackward, addImageOverlay, addTextOverlay, updateOverlayTrack, removeOverlayTrack, removeSelectedOverlay, isOverlayAudioTrack, isSeparatedAudioTrack, - resizeScene, setSceneGapAfter, splitScene, snapFrames, snapFramesFloor, snapFramesCeil, sceneEffFrames, sceneEffFps, setSourceTrim, trimSceneLeft, slipScene, + resizeScene, setSceneGapAfter, splitScene, autoMontage, snapFrames, snapFramesFloor, snapFramesCeil, sceneEffFrames, sceneEffFps, setSourceTrim, trimSceneLeft, slipScene, applyEnginePreset, ENGINE_PRESETS, undo, redo, refreshPreview, syncFromPreview, applyGlobalPromptQuiet, scheduleGlobalPromptApply, buildGlobalPromptFromTimeline, syncGlobalPromptFromTimeline, generate, generateMontage, generateSelected, selectedSceneCount, renderFinal, exportSelected, saveSelectedToMediaBin, clipSaveableToMediaBin, interrupt, loadModels, loadImageTargets, setModelInput, setModelBypass, setModelLink, clearNotice, projectVariables, setProjectVariables, promptTemplates, savePromptTemplate, deletePromptTemplate, applyPromptTemplate, diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index 991caf33..13104d59 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -793,6 +793,8 @@ input[type=number] { font-family: var(--font-mono); } .ov-style-btn.active { border-color: #c084fc; color: #e9d5ff; background: color-mix(in srgb, #c084fc 12%, var(--ink-2)); } .ov-check { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--muted); height: 40px; } .ov-check input { accent-color: #c084fc; } +.ov-checklist { display: flex; flex-direction: column; gap: 2px; max-height: 220px; overflow-y: auto; border: 1px solid var(--line); border-radius: 10px; padding: 4px 8px; background: var(--ink-2); } +.ov-modal-wide { width: min(560px, 92vw); } .ov-image-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(108px, 1fr)); gap: 10px; } .ov-image-card { display: flex; flex-direction: column; gap: 6px; padding: 8px; border-radius: 12px; border: 1px solid var(--line); background: var(--ink-2); cursor: pointer; text-align: left; } .ov-image-card:hover { border-color: color-mix(in srgb, #c084fc 35%, var(--line)); background: color-mix(in srgb, #c084fc 5%, var(--ink-2)); }