editor: Auto Montage tool — trailer-style cuts from rendered clips

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.
This commit is contained in:
digital-garbage
2026-06-27 01:15:27 +03:00
parent 6336e3f2f2
commit 18cf60f243
5 changed files with 196 additions and 1 deletions
+6
View File
@@ -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);
+1
View File
@@ -120,6 +120,7 @@
<script src="log.js"></script>
<script src="player.js"></script>
<script src="overlay_ui.js"></script>
<script src="montage_dialog.js"></script>
<script src="overlay_preview.js"></script>
<script src="sampler_panel.js"></script>
<script src="engine_settings.js"></script>
+97
View File
@@ -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 };
})();
+90 -1
View File
@@ -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,
+2
View File
@@ -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)); }