Add pauses between timeline clips for beat alignment.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user