From 69ae9bacf439c46e8cd615021d25b229fa670bc8 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:22:16 +0300 Subject: [PATCH] Add pauses between timeline clips for beat alignment. --- movie_editor/backend/timeline.py | 3 ++ movie_editor/frontend/inspector.js | 12 ++++++ movie_editor/frontend/player.js | 9 ++++- movie_editor/frontend/store.js | 25 ++++++++++++- movie_editor/frontend/styles.css | 38 +++++++++++++++++++ movie_editor/frontend/timeline.js | 59 ++++++++++++++++++++++++++++-- movie_editor/frontend/view_bus.js | 3 +- movie_editor/server.py | 19 ++++++++++ 8 files changed, 161 insertions(+), 7 deletions(-) diff --git a/movie_editor/backend/timeline.py b/movie_editor/backend/timeline.py index 7bfa8886..4770d843 100644 --- a/movie_editor/backend/timeline.py +++ b/movie_editor/backend/timeline.py @@ -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: diff --git a/movie_editor/frontend/inspector.js b/movie_editor/frontend/inspector.js index afa169ef..9435515b 100644 --- a/movie_editor/frontend/inspector.js +++ b/movie_editor/frontend/inspector.js @@ -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) { diff --git a/movie_editor/frontend/player.js b/movie_editor/frontend/player.js index c6b3c65a..2450dd60 100644 --- a/movie_editor/frontend/player.js +++ b/movie_editor/frontend/player.js @@ -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(); diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index 5c5dcccd..f476ce4c 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -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, diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index 2b365a1b..2e71d342 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -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 { diff --git a/movie_editor/frontend/timeline.js b/movie_editor/frontend/timeline.js index 5ce5fd97..78168e2d 100644 --- a/movie_editor/frontend/timeline.js +++ b/movie_editor/frontend/timeline.js @@ -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); diff --git a/movie_editor/frontend/view_bus.js b/movie_editor/frontend/view_bus.js index 72e52cfc..fb7f69e3 100644 --- a/movie_editor/frontend/view_bus.js +++ b/movie_editor/frontend/view_bus.js @@ -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, diff --git a/movie_editor/server.py b/movie_editor/server.py index 5a5f9492..b492a8e2 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -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