Add pauses between timeline clips for beat alignment.

This commit is contained in:
digital-garbage
2026-06-12 16:22:16 +03:00
parent 17f1a5a7dc
commit 69ae9bacf4
8 changed files with 161 additions and 7 deletions
+3
View File
@@ -535,6 +535,8 @@ class Scene:
# Saved generative state when this clip is converted to a locked ``video`` clip.
# Restored on Convert to scene (scene → video → scene round-trip).
scene_archive: Optional[dict] = None
# Editorial pause (black/silent) after this clip before the next timeline segment.
gap_after_sec: float = 0.0
@staticmethod
def from_dict(d: dict) -> "Scene":
@@ -563,6 +565,7 @@ class Scene:
source_in=float(d.get("source_in") or 0),
source_dur=d.get("source_dur"),
scene_archive=d.get("scene_archive"),
gap_after_sec=float(d.get("gap_after_sec") or 0),
)
def to_dict(self) -> dict:
+12
View File
@@ -426,6 +426,18 @@
(v) => S.patchScene(scene.id, { transition_to_next: v }),
{ noneLabel: "— default cut —",
title: "Prompt marker before the next scene when Studio splits the montage (not the video blend)" })));
const gapIn = el("input");
gapIn.type = "number";
gapIn.min = "0";
gapIn.step = "0.05";
gapIn.value = scene.gap_after_sec || 0;
gapIn.oninput = () => S.patchSceneQuiet(scene.id, {
gap_after_sec: Math.max(0, parseFloat(gapIn.value || "0")),
});
body.append(field("Pause after (s)", gapIn));
body.append(el("div", "insp-hint",
"Black/silent gap before the next clip. Drag the pause block on the timeline or the seam handle."));
}
function renderSceneCharacters(st, scene, parent) {
+8 -1
View File
@@ -202,6 +202,12 @@
for (const seg of S.buildPreviewSegments()) {
const dur = seg.durationSec;
const startSec = seg.offsetSec;
if (seg.kind === "gap") {
out.push({
blank: true, gap: true, startSec, durationSec: dur,
});
continue;
}
if (seg.kind === "ghost") {
const g = seg.ghost;
if (!g.media) {
@@ -685,7 +691,8 @@
const within = _phSec - _currentClip.startSec + (1 / 60);
if (within >= _currentClip.durationSec - 0.001) {
_phSec = _currentClip.startSec + _currentClip.durationSec;
_pause();
if (_currentClip.gap) _advance();
else _pause();
} else {
_phSec = _currentClip.startSec + within;
_syncInsAudio();
+23 -2
View File
@@ -1310,6 +1310,7 @@
function segmentDurationSec(seg) {
const p = state.project;
if (!p || !seg) return 0;
if (seg.kind === "gap") return Math.max(0, +(seg.gapSec || 0));
if (seg.kind === "scene") {
const sc = seg.scene;
if (isVideoClip(sc) && sc.source_dur != null) return sc.source_dur;
@@ -1343,6 +1344,10 @@
for (const sc of (p.scenes || [])) {
ordered.push({ kind: "scene", scene: sc, id: sc.id });
for (const g of (byAnchor.get(sc.id) || [])) pushGhost(g);
const gapSec = Math.max(0, +(sc.gap_after_sec || 0));
if (gapSec > 0.001) {
ordered.push({ kind: "gap", id: `gap:${sc.id}`, afterSceneId: sc.id, gapSec });
}
}
// Orphans (stale afterSceneId) still show — appended after live clips.
for (const g of ghosts) { if (!placed.has(g.id)) pushGhost(g); }
@@ -1481,6 +1486,16 @@
return frames / fps;
}
// Insert or adjust a black/silent pause after this clip (seconds).
function setSceneGapAfter(id, sec) {
if (!state.project) return;
const s = scene(id);
if (!s) return;
const gap = Math.max(0, +sec || 0);
if (Math.abs((s.gap_after_sec || 0) - gap) < 0.001) return;
patchScene(id, { gap_after_sec: gap });
}
// Resize a clip by setting its frame count from a target duration (seconds) × fps.
function resizeScene(id, durationSec) {
if (!state.project) return;
@@ -2966,6 +2981,7 @@
tdur: tFrames > 0 ? tFrames / fps : 0,
sceneId: sc.id,
volume: sc.audio_separated ? 0 : (sc.audio_volume != null ? sc.audio_volume : 1),
gap_after: Math.max(0, +(sc.gap_after_sec || 0)),
};
if (r && r.media) {
out.push({ media: r.media, ...base });
@@ -3193,6 +3209,7 @@
in: +c.inSec.toFixed(3), dur: +c.durationSec.toFixed(3),
fx: c.fx, fps: c.fps, w: c.w, h: c.h, transition: c.transition, tdur: +(c.tdur || 0).toFixed(3),
volume: c.volume, scene_id: c.sceneId,
gap_after: +(c.gap_after || 0).toFixed(3),
}));
await flushSave(); // audio tracks / keep-original live on the project — render reads it from disk
set({
@@ -3215,7 +3232,11 @@
state.sceneGhosts = [];
_syncEditorStateToProject();
let acc = 0;
for (const sc of order) { state.sceneRenders[sc.id] = { media: r.media, inSec: acc }; acc += sceneDurationSec(sc); }
for (const sc of order) {
state.sceneRenders[sc.id] = { media: r.media, inSec: acc };
const dur = sc.source_dur != null ? sc.source_dur : sceneDurationSec(sc);
acc += dur + Math.max(0, +(sc.gap_after_sec || 0));
}
}
set({
gen: {
@@ -3545,7 +3566,7 @@
bringOverlayToFront, sendOverlayToBack, bringOverlayForward, sendOverlayBackward,
addImageOverlay, addTextOverlay, updateOverlayTrack, removeOverlayTrack, removeSelectedOverlay,
isOverlayAudioTrack, isSeparatedAudioTrack,
resizeScene, splitScene, snapFrames, snapFramesFloor, snapFramesCeil, sceneEffFrames, sceneEffFps, setSourceTrim, trimSceneLeft, slipScene,
resizeScene, setSceneGapAfter, splitScene, 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, setModelLink, clearNotice,
setConditioningSlot, setSamplerSlot, setSamplerInput, setSamplerInputNow, unsetSamplerInput, setStudioInput, setStudioInputNow,
+38
View File
@@ -988,6 +988,44 @@ body.tl-reordering { cursor: grabbing; user-select: none; }
background: var(--line-2); border-radius: 1px;
}
.seam-cut.has-vt { background: color-mix(in srgb, var(--teal) 55%, var(--line)); }
.tl-seam-gap-handle {
position: absolute; top: 0; bottom: 0; left: 0; width: 10px;
transform: translateX(-50%); cursor: ew-resize; pointer-events: auto;
}
.tl-seam-gap-handle:hover { background: color-mix(in srgb, var(--amber) 25%, transparent); }
/* editorial pause between clips */
.tl-gap {
position: absolute; top: 10px; bottom: 10px; z-index: 1;
background: repeating-linear-gradient(
-45deg,
color-mix(in srgb, var(--ink-2) 85%, transparent),
color-mix(in srgb, var(--ink-2) 85%, transparent) 4px,
color-mix(in srgb, var(--ink-1) 85%, transparent) 4px,
color-mix(in srgb, var(--ink-1) 85%, transparent) 8px
);
border: 1px dashed color-mix(in srgb, var(--faint) 60%, transparent);
border-radius: 4px; box-sizing: border-box; cursor: pointer;
}
.tl-gap:hover, .tl-gap.resizing { border-color: var(--amber); }
.tl-gap-label {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
font-size: 9px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--faint);
pointer-events: none; user-select: none;
}
.tl-gap-handle {
position: absolute; top: 0; right: 0; bottom: 0; width: 8px; cursor: ew-resize; z-index: 2;
}
.tl-gap-handle::after {
content: ""; position: absolute; right: 2px; top: 50%; transform: translateY(-50%);
width: 2px; height: 20px; background: var(--amber); border-radius: 2px; opacity: 0.6;
}
.tl-gap:hover .tl-gap-handle::after, .tl-gap.resizing .tl-gap-handle::after { opacity: 1; }
.tl-gap-tip {
position: absolute; bottom: 2px; right: 2px; background: var(--ink-0); border: 1px solid var(--amber);
color: var(--amber); font-family: var(--font-mono); font-size: 10px; padding: 1px 5px;
border-radius: 4px; white-space: nowrap; pointer-events: none; z-index: 3;
}
/* transition tail on the outgoing clip (NLE-style overlap) */
.clip-vt-tail {
+56 -3
View File
@@ -816,6 +816,41 @@
}
// ── cut line at each clip boundary (NLE-style; transition lives on clip tail) ──
function gapEl(st, p, seg, leftPx, widthPx) {
const sceneId = seg.afterSceneId;
const gap = el("div", "tl-gap");
gap.style.left = leftPx + "px";
gap.style.width = Math.max(widthPx, 6) + "px";
gap.title = "Pause before next clip - drag edge to adjust";
gap.addEventListener("click", (e) => {
e.stopPropagation();
if (sceneId) S.selectScene(sceneId);
});
const handle = el("div", "tl-gap-handle");
handle.addEventListener("mousedown", (e) => {
e.stopPropagation();
_tlEditing = true;
gap.classList.add("resizing");
const tip = el("div", "tl-gap-tip");
gap.append(tip);
const baseW = widthPx;
coalescedDrag(e, (dx) => {
const w = Math.max(0, baseW + dx);
gap.style.width = Math.max(w, 4) + "px";
tip.textContent = `${(w / pxPerSec).toFixed(2)}s pause`;
}, () => {
gap.classList.remove("resizing");
tip.remove();
_tlEditing = false;
const newGap = Math.max(0, (parseFloat(gap.style.width) || widthPx) / pxPerSec);
S.setSceneGapAfter(sceneId, newGap);
});
});
gap.append(handle);
gap.append(el("span", "tl-gap-label", "pause"));
return gap;
}
function seamEl(st, p, scene, seamPx) {
const vt = videoTransitionState(scene, p);
const seam = el("div", "seam-cut" + (vt.active ? " has-vt vt-" + vt.type : ""));
@@ -823,6 +858,23 @@
if (vt.active) {
seam.title = `${VT_SHORT[vt.type] || vt.type} · ${vt.frames}f — drag the clip edge or use inspector`;
}
const gapSec = +(scene.gap_after_sec || 0);
if (gapSec <= 0.001) {
const gapHandle = el("div", "tl-seam-gap-handle");
gapHandle.title = "Drag right to add a pause before the next clip";
gapHandle.addEventListener("mousedown", (e) => {
e.stopPropagation();
_seamDragging = true;
let finalGap = 0;
coalescedDrag(e, (dx) => {
finalGap = Math.max(0, dx / pxPerSec);
}, () => {
_seamDragging = false;
if (finalGap > 0.02) S.setSceneGapAfter(scene.id, finalGap);
});
});
seam.append(gapHandle);
}
return seam;
}
@@ -1862,7 +1914,7 @@
// Click empty timeline space (not a clip/seam) to clear the selection.
scroll.addEventListener("click", (e) => {
const cur = S.get();
if ((cur.selectedSceneId || (cur.selectedSceneIds || []).length) && !e.target.closest(".clip") && !e.target.closest(".seam-cut") && !e.target.closest(".tl-ruler2") && !e.target.closest(".tl-aud-clip") && !e.target.closest(".tl-ov-clip"))
if ((cur.selectedSceneId || (cur.selectedSceneIds || []).length) && !e.target.closest(".clip") && !e.target.closest(".seam-cut") && !e.target.closest(".tl-gap") && !e.target.closest(".tl-ruler2") && !e.target.closest(".tl-aud-clip") && !e.target.closest(".tl-ov-clip"))
S.selectScene(null);
if (cur.selectedOverlayId && !e.target.closest(".tl-ov-clip")) S.selectOverlay(null);
if (cur.selectedAudioTrackId && !e.target.closest(".tl-aud-clip.ins")) S.selectAudioTrack(null);
@@ -1926,11 +1978,12 @@
});
lay.forEach(({ seg, o, d }) => {
if (seg.kind === "ghost") track.append(ghostClipEl(st, p, seg.ghost, o * pxPerSec, d * pxPerSec));
else if (seg.kind === "gap") track.append(gapEl(st, p, seg, o * pxPerSec, d * pxPerSec));
else track.append(clipEl(st, p, seg.scene, scenes.indexOf(seg.scene), o * pxPerSec, d * pxPerSec));
});
for (let i = 0; i < scenes.length - 1; i++) {
const nextSeg = segs.find((s) => s.kind === "scene" && s.scene.id === scenes[i + 1].id);
if (nextSeg) track.append(seamEl(st, p, scenes[i], nextSeg.offsetSec * pxPerSec));
const prevSeg = segs.find((s) => s.kind === "scene" && s.scene.id === scenes[i].id);
if (prevSeg) track.append(seamEl(st, p, scenes[i], (prevSeg.offsetSec + prevSeg.durationSec) * pxPerSec));
}
if (!lay.length) track.append(el("div", "tl-emptyhint", "No clips yet — add one from the toolbar."));
tracks.append(track);
+2 -1
View File
@@ -14,6 +14,7 @@
s.video_transition, s.transition_frames, s.transition_to_next, s.effects,
s.gen_unit_id, s.cut_offset_frames, s.source?.type, s.source?.media_ref,
s.source_in, s.source_dur, s.rating, s.audio_volume, s.audio_separated, s.scene_archive,
s.gap_after_sec,
]),
renders: st.sceneRenders,
ghosts: st.sceneGhosts,
@@ -86,7 +87,7 @@
scenes: (p.scenes || []).map((s) => [
s.id, s.excluded, s.frames, s.fps, s.frames_mode, s.fps_mode,
s.audio_volume, s.audio_separated, s.source_in, s.source_dur, s.effects,
s.source?.type, s.source?.media_ref,
s.source?.type, s.source?.media_ref, s.gap_after_sec,
]),
renders: st.sceneRenders,
ghosts: st.sceneGhosts,
+19
View File
@@ -627,6 +627,25 @@ def _build_render_filter(clips: list, tracks: Optional[list] = None,
acc_dur = float(clips[0].get("dur") or 0) or 0.0
for i in range(1, n):
prev = clips[i - 1]
gap = float(prev.get("gap_after") or 0)
if gap > 0.001:
gv = f"[vgap{i}]"
ga = f"[agap{i}]"
parts.append(
f"color=c=black:s={cw}x{ch}:r={cfps:g}:d={gap:.3f},format=yuv420p,setsar=1{gv}"
)
parts.append(
f"anullsrc=r=48000:cl=stereo:d={gap:.3f},"
f"aformat=sample_fmts=fltp:channel_layouts=stereo{ga}"
)
ov = f"[vcgap{i}]"
parts.append(f"{acc_v}{gv}concat=n=2:v=1:a=0{ov}")
if keep_original:
oa = f"[acgap{i}]"
parts.append(f"{acc_a}{ga}concat=n=2:v=0:a=1{oa}")
acc_a = oa
acc_v = ov
acc_dur += gap
trans = (prev.get("transition") or "").strip()
td = float(prev.get("tdur") or 0)
dur_i = float(clips[i].get("dur") or 0) or 0.0