diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index 5bc555a2..a0616509 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -687,13 +687,15 @@ body.tl-reordering { cursor: grabbing; user-select: none; } .clip-src.carry-only { font-size: 12px; color: var(--teal-soft); opacity: .85; } .pm-segment-badge.mixed-mode { color: #c8fff4; border-color: color-mix(in srgb, var(--teal-soft) 45%, var(--line)); } /* a clip that has a rendered video available (in temp) */ -.clip.rendered::after { content: "●"; position: absolute; top: 4px; right: 6px; font-size: 7px; - color: #57c98e; text-shadow: 0 0 5px rgba(87,201,142,.7); pointer-events: none; z-index: 4; } +.clip.rendered::after { content: "●"; position: absolute; bottom: 5px; right: 10px; top: auto; font-size: 7px; + color: #57c98e; text-shadow: 0 0 5px rgba(87,201,142,.7); pointer-events: none; z-index: 1; } .clip.trimming { border-color: var(--amber); z-index: 5; } -.clip-head { display: flex; align-items: center; gap: 6px; } -.clip-no { font-family: var(--font-mono); font-size: 11px; color: var(--amber); font-weight: 600; } -.clip-src { font-size: 12px; color: var(--faint); } -.clip-dur { margin-left: auto; font-family: var(--font-mono); font-size: 10px; color: var(--faint); } +.clip-head { display: flex; align-items: center; gap: 6px; min-width: 0; flex-shrink: 0; position: relative; z-index: 2; } +.clip-head-bar { margin-left: auto; display: flex; align-items: center; gap: 6px; flex-shrink: 0; } +.clip-no { font-family: var(--font-mono); font-size: 11px; color: var(--amber); font-weight: 600; flex-shrink: 0; } +.clip-src { font-size: 12px; color: var(--faint); flex-shrink: 0; } +.clip-dur { font-family: var(--font-mono); font-size: 10px; color: var(--faint); white-space: nowrap; } +.clip-rm.btn.tiny { padding: 1px 6px; font-size: 10px; line-height: 1.25; flex-shrink: 0; } .clip-text { font-size: 12px; line-height: 1.35; color: var(--text); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } .clip-text.empty { color: var(--faint); font-style: italic; } @@ -722,8 +724,6 @@ body.tl-reordering { cursor: grabbing; user-select: none; } .clip.stale-render.rendered::after { color: var(--amber-2); text-shadow: 0 0 5px rgba(224, 162, 58, .7); } .clip-gen-anchor .clip-gen-prompt-label { color: var(--teal-soft); } .clip-gen-anchor .clip-gen-prompt-text { color: #c8fff4; } -.clip-actions { position: absolute; top: 5px; right: 8px; display: flex; gap: 2px; opacity: 0; transition: opacity .12s; } -.clip:hover .clip-actions { opacity: 1; } .ic-btn { width: 18px; height: 18px; padding: 0; font-size: 11px; line-height: 1; border: 1px solid var(--line); background: var(--ink-1); color: var(--muted); border-radius: 4px; cursor: pointer; } .ic-btn:hover { color: var(--text); border-color: var(--muted); } diff --git a/movie_editor/frontend/timeline.js b/movie_editor/frontend/timeline.js index a04b6e4d..57780482 100644 --- a/movie_editor/frontend/timeline.js +++ b/movie_editor/frontend/timeline.js @@ -46,6 +46,18 @@ } head.append(el("span", "clip-src", SRC_ICON[srcType] || "▦")); } + + function appendClipHeadBar(head, durSec, fps, onRemove, removeTitle) { + const bar = el("div", "clip-head-bar"); + bar.append(el("span", "clip-dur", timecode(durSec, fps))); + const rm = el("button", "clip-rm btn ghost tiny danger", "Remove"); + rm.type = "button"; + rm.title = removeTitle || "Remove clip (Delete / Backspace)"; + rm.onclick = (e) => { e.stopPropagation(); onRemove(); }; + bar.append(rm); + head.append(bar); + } + const ZOOM_KEY = "funpack_tl_zoom"; const GUTTER_W = 56; // sticky lane labels (Video / Audio) — shared time origin in tl-content const AUDIO_LANE_H = 44; @@ -368,16 +380,11 @@ head.append(el("span", "clip-src", "👻")); const fps = (ghost.fps_mode !== "project" && ghost.fps != null) ? ghost.fps : p.frame_rate; const frames = (ghost.frames_mode !== "project" && ghost.frames != null) ? ghost.frames : p.num_frames_per_scene; - head.append(el("span", "clip-dur", timecode((frames || 1) / (fps || 25), fps || 25))); + appendClipHeadBar(head, (frames || 1) / (fps || 25), fps || 25, + () => S.dismissGhost(ghost.id), "Remove ghost from preview"); clip.append(head); const label = ghost.pendingGen ? "removed · generating…" : (ghost.text || "removed scene"); clip.append(el("div", "clip-text ghost-label", label)); - const actions = el("div", "clip-actions"); - const dismiss = el("button", "ic-btn", "✕"); - dismiss.title = "Dismiss ghost from preview"; - dismiss.onclick = (e) => { e.stopPropagation(); S.dismissGhost(ghost.id); }; - actions.append(dismiss); - clip.append(actions); return clip; } @@ -401,7 +408,7 @@ // 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). clip.addEventListener("mousedown", (e) => { - if (e.button !== 0 || e.target.closest(".clip-actions, .clip-trim, .clip-vt-tail, .seam-cut, button")) return; + if (e.button !== 0 || e.target.closest(".clip-head-bar, .clip-rm, .clip-trim, .clip-vt-tail, .seam-cut, button")) return; const startX = e.clientX; let dragging = false, drop = null; const track = clip.parentNode; @@ -476,7 +483,7 @@ const head = el("div", "clip-head"); head.append(el("span", "clip-no", S.isVideoClip(scene) ? "V" : p2(index + 1))); appendSrcBadge(head, srcType); - head.append(el("span", "clip-dur", timecode(sDur(scene, p), sFps(scene, p)))); + appendClipHeadBar(head, sDur(scene, p), sFps(scene, p), () => S.removeScene(scene.id)); clip.append(head); const root = unitCuts > 1 @@ -539,21 +546,6 @@ } } - const actions = el("div", "clip-actions"); - const mk = (label, title, cls, fn) => { const b = el("button", "ic-btn" + (cls ? " " + cls : ""), label); b.title = title; b.onclick = (e) => { e.stopPropagation(); fn(); }; return b; }; - actions.append(mk("⟈", "Split clip at playhead", "", () => { - const offsetSec = leftPx / pxPerSec, durSec = sDur(scene, p), fps = sFps(scene, p); - const ph = window.Player?.getPlayhead() ?? 0; - let at = null; - if (ph > offsetSec + 0.05 && ph < offsetSec + durSec - 0.05) - at = Math.round((ph - offsetSec) * fps); - S.splitScene(scene.id, at); - })); - actions.append(mk("‹", "Move left", "", () => S.moveScene(scene.id, -1))); - actions.append(mk("›", "Move right", "", () => S.moveScene(scene.id, 1))); - actions.append(mk("✕", "Delete clip", "danger", () => S.removeScene(scene.id))); - clip.append(actions); - // right-edge trim → new duration → frames recomputed (duration × fps). const locked = scene.frames_mode === "custom"; const leftHandle = el("div", "clip-trim clip-trim-left" + (locked ? " locked" : "")); @@ -1055,11 +1047,11 @@ // Clip actions on the selected clip (also bound to S / Delete). const selIds = selectedIds(st); const hasSel = selIds.length > 0; - const split = el("button", "btn ghost tiny", "⧅ Split"); + const split = el("button", "btn ghost tiny", "Split"); split.dataset.needsSel = "1"; split.title = "Split the selected clip at the playhead (S)"; split.disabled = !hasSel; split.onclick = () => splitSelectedAtPlayhead(); - const del = el("button", "btn ghost tiny danger", "✕ Remove"); + const del = el("button", "btn ghost tiny danger", "Remove"); del.dataset.needsSel = "1"; del.title = "Remove selected clip(s) (Delete / Backspace)"; del.disabled = !hasSel; del.onclick = () => S.removeSelectedScenes(); @@ -1194,7 +1186,7 @@ track.addEventListener("click", (e) => { const clip = e.target.closest(".clip:not(.ghost)[data-scene-id]"); if (!clip) return; - if (e.target.closest(".clip-actions, .clip-trim, .clip-vt-tail, .seam-cut, button")) return; + if (e.target.closest(".clip-head-bar, .clip-rm, .clip-trim, .clip-vt-tail, .seam-cut, button")) return; onClipSelect(e, clip.dataset.sceneId); }); lay.forEach(({ seg, o, d }) => {