diff --git a/movie_editor/frontend/inspector.js b/movie_editor/frontend/inspector.js index 9435515b..b22d9efe 100644 --- a/movie_editor/frontend/inspector.js +++ b/movie_editor/frontend/inspector.js @@ -435,9 +435,9 @@ 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(field("Pause before next clip (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.")); + "Drag a clip right on the timeline to open space before it. Drag back flush with the previous clip to remove the pause.")); } function renderSceneCharacters(st, scene, parent) { diff --git a/movie_editor/frontend/player.js b/movie_editor/frontend/player.js index 2450dd60..c9351839 100644 --- a/movie_editor/frontend/player.js +++ b/movie_editor/frontend/player.js @@ -1178,8 +1178,12 @@ title = seg.ghost.pendingGen ? "Removed scene — generation in flight" : "Removed scene (preview only — not in next run)"; + } else if (seg.kind === "gap") { + cls += " gap"; + title = `Pause (${d.toFixed(2)}s)`; } else { const sc = seg.scene; + if (!sc) return; const stype = sc.source?.type || "empty"; const isVideo = S.isVideoClip && S.isVideoClip(sc); const rendered = isVideo diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index 46fc4881..f7dc1844 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -461,6 +461,7 @@ input[type=number] { font-family: var(--font-mono); } .pm-chip.rendered.stale { background: linear-gradient(180deg, var(--amber-2), #3ab3a8); opacity: .62; } .pm-chip.pending { background: repeating-linear-gradient(-45deg, var(--ink-3), var(--ink-3) 4px, #1a1a1a 4px, #1a1a1a 8px); opacity: .45; } .pm-chip.ghost { background: repeating-linear-gradient(-45deg, #2a2418, #2a2418 4px, #3d3420 4px, #3d3420 8px); opacity: .65; } +.pm-chip.gap { background: repeating-linear-gradient(-45deg, var(--ink-2), var(--ink-2) 4px, var(--ink-1) 4px, var(--ink-1) 8px); opacity: .35; border-right-style: dashed; } .pm-chip.mixed { background: linear-gradient(180deg, color-mix(in srgb, var(--amber) 55%, #3ab3a8), #2a6a62); opacity: .72; } .pm-chip.carry { background: repeating-linear-gradient(-45deg, #1e2a28, #1e2a28 3px, #243532 3px, #243532 6px); opacity: .5; } .pm-chip.video { background: linear-gradient(180deg, #3a5a8a, #2a4468); opacity: .7; } @@ -843,8 +844,10 @@ input[type=number] { font-family: var(--font-mono); } .clip::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--amber); opacity: .55; } .clip:hover { border-color: #4a4334; } .clip:active { cursor: grabbing; } -.clip.reordering { opacity: .8; z-index: 50; cursor: grabbing; box-shadow: 0 6px 18px rgba(0,0,0,.5); transition: none; } -body.tl-reordering { cursor: grabbing; user-select: none; } +.clip.spacing { opacity: .85; z-index: 50; cursor: grabbing; box-shadow: 0 6px 18px rgba(0,0,0,.5); transition: none; } +body.tl-spacing { cursor: grabbing; user-select: none; } +.clip:not(.ghost) { cursor: grab; } +.clip:not(.ghost).spacing { cursor: grabbing; } .tl-dropline { position: absolute; top: 6px; bottom: 6px; width: 2px; background: var(--amber, #e0a23a); box-shadow: 0 0 6px 1px rgba(224,162,58,.8); z-index: 60; pointer-events: none; } .tl-dd { position: relative; display: inline-block; } diff --git a/movie_editor/frontend/timeline.js b/movie_editor/frontend/timeline.js index 78168e2d..f63b741c 100644 --- a/movie_editor/frontend/timeline.js +++ b/movie_editor/frontend/timeline.js @@ -271,7 +271,7 @@ function attachClipSelect(clip, sceneId) { clip.addEventListener("mousedown", (e) => { if (e.button !== 0 || isClipSelectBlocked(e)) return; - // Modifier clicks select immediately — do not start drag-reorder (it eats the gesture). + // Modifier clicks select immediately — do not start spacing drag (it eats the gesture). if (e.metaKey || e.shiftKey || isAdditiveMod(e)) { e.preventDefault(); clip.dataset.modSelect = "1"; @@ -545,25 +545,7 @@ document.addEventListener("mouseup", up); } - // ── drag-reorder: where in the track does the cursor want to drop? ─────────────── - // Returns {idx, x}: idx = post-removal insertion index among non-dragged clips; x = - // the boundary's x relative to the track (for the insertion line). - function _dropTarget(track, dragged, clientX) { - const others = [...track.querySelectorAll(".clip:not(.ghost)")].filter((c) => c !== dragged); - const tr = track.getBoundingClientRect(); - let idx = 0; - for (const c of others) { - const r = c.getBoundingClientRect(); - if (clientX > r.left + r.width / 2) idx++; else break; - } - let x; - if (!others.length) x = 0; - else if (idx === 0) x = others[0].getBoundingClientRect().left - tr.left; - else x = others[idx - 1].getBoundingClientRect().right - tr.left; - return { idx, x }; - } - - // Removed scene — preview-only ghost (still plays last render until regen). + // ── clip ─────────────────────────────────────────────────────────────────────── function ghostClipEl(st, p, ghost, leftPx, widthPx) { const clip = el("div", "clip ghost"); clip.style.left = leftPx + "px"; @@ -599,53 +581,59 @@ clip.style.left = leftPx + "px"; clip.style.width = Math.max(widthPx, 8) + "px"; - // drag the clip body left/right to reorder it on the timeline (a small threshold keeps - // plain clicks = select; trim handle / action buttons opt out). + // drag clip left/right to add or remove pause before it (flush with previous = no gap). attachClipSelect(clip, scene.id); - clip.addEventListener("mousedown", (e) => { - if (e.button !== 0 || isClipSelectBlocked(e)) return; - if (e.metaKey || e.shiftKey || isAdditiveMod(e)) return; - const startX = e.clientX; - let dragging = false, drop = null; - const track = clip.parentNode; - const onMove = (ev) => { - const dx = ev.clientX - startX; - if (!dragging) { - if (Math.abs(dx) < REORDER_PX) return; - dragging = true; clip.classList.add("reordering"); document.body.classList.add("tl-reordering"); - _reordering = true; - window.EditorHistory?.beginCoalesce(S); - } - clip.style.transform = `translateX(${dx}px)`; - drop = _dropTarget(track, clip, ev.clientX); - const stNow = S.get(); - const pNow = stNow.project; - if (pNow) drop.x = snapPx(drop.x, timelineSnapAnchorsPx(stNow, pNow), 10); - let line = track.querySelector(".tl-dropline"); - if (!line) { line = el("div", "tl-dropline"); track.append(line); } - line.style.left = drop.x + "px"; - }; - const onUp = (ev) => { - document.removeEventListener("mousemove", onMove); - document.removeEventListener("mouseup", onUp); - document.body.classList.remove("tl-reordering"); - const line = track.querySelector(".tl-dropline"); if (line) line.remove(); - const dx = ev.clientX - startX; - if (!dragging) return; - clip.style.transform = ""; clip.classList.remove("reordering"); - _reordering = false; - if (Math.abs(dx) < REORDER_PX) { + if (index > 0) { + clip.title = (clip.title ? clip.title + " · " : "") + "Drag left/right to adjust spacing from the previous clip"; + clip.addEventListener("mousedown", (e) => { + if (e.button !== 0 || isClipSelectBlocked(e)) return; + if (e.metaKey || e.shiftKey || isAdditiveMod(e)) return; + const prevScene = (p.scenes || [])[index - 1]; + if (!prevScene) return; + const gapBefore = +(prevScene.gap_after_sec || 0); + const buttLeftPx = leftPx - gapBefore * pxPerSec; + const startX = e.clientX; + let dragging = false; + const track = clip.parentNode; + const onMove = (ev) => { + const dx = ev.clientX - startX; + if (!dragging) { + if (Math.abs(dx) < REORDER_PX) return; + dragging = true; + clip.classList.add("spacing"); + document.body.classList.add("tl-spacing"); + _reordering = true; + window.EditorHistory?.beginCoalesce(S); + } + const stNow = S.get(); + const pNow = stNow.project; + const anchors = pNow ? timelineSnapAnchorsPx(stNow, pNow) : []; + const clampedLeft = Math.max(buttLeftPx, snapPx(buttLeftPx + gapBefore * pxPerSec + dx, anchors, 10)); + clip.style.transform = `translateX(${clampedLeft - leftPx}px)`; + }; + const onUp = (ev) => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + document.body.classList.remove("tl-spacing"); + if (!dragging) return; + clip.style.transform = ""; + clip.classList.remove("spacing"); + _reordering = false; + ev.preventDefault(); + const dx = ev.clientX - startX; + const stNow = S.get(); + const pNow = stNow.project; + const anchors = pNow ? timelineSnapAnchorsPx(stNow, pNow) : []; + const clampedLeft = Math.max(buttLeftPx, snapPx(buttLeftPx + gapBefore * pxPerSec + dx, anchors, 10)); + const finalGap = (clampedLeft - buttLeftPx) / pxPerSec; + S.setSceneGapAfter(prevScene.id, finalGap < 0.02 ? 0 : finalGap); window.EditorHistory?.endCoalesce(S); - return; - } - ev.preventDefault(); - if (drop) S.moveSceneTo(scene.id, drop.idx); - window.EditorHistory?.endCoalesce(S); - track.addEventListener("click", (ce) => { ce.stopPropagation(); ce.preventDefault(); }, { capture: true, once: true }); - }; - document.addEventListener("mousemove", onMove); - document.addEventListener("mouseup", onUp); - }); + track.addEventListener("click", (ce) => { ce.stopPropagation(); ce.preventDefault(); }, { capture: true, once: true }); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }); + } // accept a media asset dragged from the bin → sets this clip's source clip.addEventListener("dragover", (e) => { if (e.dataTransfer.types.includes("application/funpack-media")) { e.preventDefault(); clip.classList.add("drop-target"); } }); @@ -816,41 +804,6 @@ } // ── 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 : "")); @@ -858,23 +811,6 @@ 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; } @@ -1879,7 +1815,7 @@ // Don't rebuild the timeline while the user is interacting with one of its controls // (e.g. the rating dropdown) — a store notify (autosave/progress) would close it. let _tlEditing = false; - let _reordering = false; // a clip is being drag-reordered — never rebuild mid-drag + let _reordering = false; // clip spacing drag in progress — never rebuild mid-drag let _seamDragging = false; // adjusting a video transition — never rebuild mid-drag function render(st) { @@ -1914,7 +1850,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-gap") && !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-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); @@ -1977,8 +1913,8 @@ } }); lay.forEach(({ seg, o, d }) => { + if (seg.kind === "gap") return; 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++) {