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)); }