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