Fix timeline pause UX and player crash on gap segments.

Drag clips apart to create pauses automatically; dragging back flush removes them. Guard preview minibar against gap segments so transport controls load.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
digital-garbage
2026-06-12 16:39:21 +03:00
co-authored by Cursor
parent 656d948e0b
commit ac6e7fe91d
4 changed files with 67 additions and 124 deletions
+2 -2
View File
@@ -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) {
+4
View File
@@ -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
+5 -2
View File
@@ -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; }
+56 -120
View File
@@ -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++) {