From c26cb92f7ecb5b6837337c6a173dd78be9755bbf Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:05:35 +0300 Subject: [PATCH] Improve overlay preview editing and fix overlays vanishing on pause. Add pixel-based sizing with keep-proportion, preview resize/flip, sticky timeline ruler, and refresh overlays after player rebuilds. --- movie_editor/backend/nle_overlays.py | 59 ++++++- movie_editor/frontend/inspector.js | 23 ++- movie_editor/frontend/overlay_preview.js | 186 ++++++++++++++++++++--- movie_editor/frontend/overlay_ui.js | 92 ++++++++++- movie_editor/frontend/player.js | 4 +- movie_editor/frontend/store.js | 32 +++- movie_editor/frontend/styles.css | 39 +++-- movie_editor/frontend/timeline.js | 30 +--- movie_editor/tests/test_nle_overlays.py | 41 ++++- 9 files changed, 430 insertions(+), 76 deletions(-) diff --git a/movie_editor/backend/nle_overlays.py b/movie_editor/backend/nle_overlays.py index bc569630..53180bd7 100644 --- a/movie_editor/backend/nle_overlays.py +++ b/movie_editor/backend/nle_overlays.py @@ -65,6 +65,33 @@ def _resolve_fontfile(font_family: str | None) -> str | None: return None +def _image_target_size(ov: dict, cw: int, ch: int) -> tuple[int, int | None]: + """Return (width_px, height_px or None for keep-aspect).""" + wpx = ov.get("width_px") + if wpx is not None: + tw = max(8, int(wpx)) + if ov.get("keep_aspect", True) is not False: + return tw, None + th = max(8, int(ov.get("height_px") or tw)) + return tw, th + scale = max(0.05, min(1.5, float(ov.get("scale") if ov.get("scale") is not None else 0.35))) + return max(8, int(scale * cw)), None + + +def _apply_flip(label: str, flip_h: bool, flip_v: bool, seq: int) -> tuple[list[str], str]: + parts: list[str] = [] + cur = label + if flip_h: + nxt = f"[ovfh{seq}]" + parts.append(f"{cur}hflip{nxt}") + cur = nxt + if flip_v: + nxt = f"[ovfv{seq}]" + parts.append(f"{cur}vflip{nxt}") + cur = nxt + return parts, cur + + def sort_overlays_for_composite( overlays: list[dict], lanes: list[dict] | None = None, @@ -100,6 +127,7 @@ def build_overlay_video_filter( cur = base_label img_i = 0 cw = max(1, int(canvas_w)) + ch = max(1, int(canvas_h)) seq = 0 for ov in overlays or []: @@ -113,6 +141,8 @@ def build_overlay_video_filter( nx = max(0.0, min(1.0, float(ov.get("x") if ov.get("x") is not None else 0.5))) ny = max(0.0, min(1.0, float(ov.get("y") if ov.get("y") is not None else 0.5))) opacity = max(0.0, min(1.0, float(ov.get("opacity") if ov.get("opacity") is not None else 1.0))) + flip_h = bool(ov.get("flip_h")) + flip_v = bool(ov.get("flip_v")) out = f"[vov{seq}]" seq += 1 @@ -126,10 +156,18 @@ def build_overlay_video_filter( y_expr = f"{ny:.6f}*H-text_h/2" fontfile = _resolve_fontfile(ov.get("font_family")) font_opt = f":fontfile='{fontfile}'" if fontfile else "" - parts.append( - f"{cur}drawtext=text='{text}':fontsize={size}:fontcolor={fontcolor}{alpha}{font_opt}:" - f"x={x_expr}:y={y_expr}:{enable}{out}" + draw = ( + f"drawtext=text='{text}':fontsize={size}:fontcolor={fontcolor}{alpha}{font_opt}:" + f"x={x_expr}:y={y_expr}:{enable}" ) + if flip_h or flip_v: + txt_layer = f"[txtl{seq}]" + parts.append(f"color=c=black@0.0:s={cw}x{ch}:d=1,format=rgba,{draw}{txt_layer}") + flip_parts, flipped = _apply_flip(txt_layer, flip_h, flip_v, seq) + parts.extend(flip_parts) + parts.append(f"{cur}{flipped}overlay=0:0:{enable}{out}") + else: + parts.append(f"{cur}{draw}{out}") cur = out continue @@ -137,18 +175,23 @@ def build_overlay_video_filter( continue in_lbl = image_input_labels[img_i] img_i += 1 - scale = max(0.05, min(1.5, float(ov.get("scale") if ov.get("scale") is not None else 0.35))) - target_w = max(8, int(scale * cw)) + tw, th = _image_target_size(ov, cw, ch) scaled = f"[ovs{seq}]" + if th is None: + scale_expr = f"scale={tw}:-1" + else: + scale_expr = f"scale={tw}:{th}" if opacity < 0.999: parts.append( - f"{in_lbl}scale={target_w}:-1,format=rgba,colorchannelmixer=aa={opacity:.3f}{scaled}" + f"{in_lbl}{scale_expr},format=rgba,colorchannelmixer=aa={opacity:.3f}{scaled}" ) else: - parts.append(f"{in_lbl}scale={target_w}:-1{scaled}") + parts.append(f"{in_lbl}{scale_expr}{scaled}") + flip_parts, flipped = _apply_flip(scaled, flip_h, flip_v, seq) + parts.extend(flip_parts) x_expr = f"{nx:.6f}*W-w/2" y_expr = f"{ny:.6f}*H-h/2" - parts.append(f"{cur}{scaled}overlay=x={x_expr}:y={y_expr}:{enable}{out}") + parts.append(f"{cur}{flipped}overlay=x={x_expr}:y={y_expr}:{enable}{out}") cur = out return parts, cur diff --git a/movie_editor/frontend/inspector.js b/movie_editor/frontend/inspector.js index 3c3da818..37b4fb67 100644 --- a/movie_editor/frontend/inspector.js +++ b/movie_editor/frontend/inspector.js @@ -775,7 +775,7 @@ size.value = ov.font_size != null ? ov.font_size : 42; size.dataset.k = "ov-size"; size.oninput = () => S.updateOverlayTrack(ov.id, { font_size: parseInt(size.value || "42", 10) }, true); - sec.append(field("Font size", size)); + sec.append(field("Font size (px)", size)); const fontSel = window.OverlayUI ? window.OverlayUI.fontSelect(ov.font_family || "system-ui", (v) => S.updateOverlayTrack(ov.id, { font_family: v }, true)) @@ -791,12 +791,12 @@ } else { const asset = (st.mediaBin || []).find((m) => m.id === ov.media_ref); sec.append(el("div", "insp-hint", asset ? `Image: ${asset.name}` : "Missing image in Media Browser")); - const scale = el("input"); - scale.type = "range"; scale.min = "0.05"; scale.max = "1"; scale.step = "0.01"; - scale.value = ov.scale != null ? ov.scale : 0.35; - scale.dataset.k = "ov-scale"; - scale.oninput = () => S.updateOverlayTrack(ov.id, { scale: parseFloat(scale.value) }, true); - sec.append(field("Size", scale)); + const canvas = S.projectCanvasSize ? S.projectCanvasSize() : { w: st.project?.width || 768, h: st.project?.height || 512 }; + if (window.OverlayUI?.pixelSizeFields) { + sec.append(window.OverlayUI.pixelSizeFields(ov, canvas.w, canvas.h, (patch) => { + S.updateOverlayTrack(ov.id, patch, true); + })); + } } const start = el("input"); @@ -820,6 +820,15 @@ op.oninput = () => S.updateOverlayTrack(ov.id, { opacity: parseFloat(op.value) }, true); sec.append(field("Opacity", op)); + const flipRow = el("div", "insp-layer-actions"); + const mkFlip = (label, key) => { + const b = el("button", "btn ghost tiny" + (ov[key] ? " active" : ""), label); + b.onclick = () => S.updateOverlayTrack(ov.id, { [key]: !ov[key] }, true); + return b; + }; + flipRow.append(mkFlip("Flip H", "flip_h"), mkFlip("Flip V", "flip_v")); + sec.append(flipRow); + const lane = S.overlayLaneById ? S.overlayLaneById(ov.lane_id) : null; const laneIdx = S.overlayLaneIndex ? S.overlayLaneIndex(ov.lane_id) : -1; const laneCount = (st.project?.overlay_lanes || []).length; diff --git a/movie_editor/frontend/overlay_preview.js b/movie_editor/frontend/overlay_preview.js index 2103dbbb..4114aa0e 100644 --- a/movie_editor/frontend/overlay_preview.js +++ b/movie_editor/frontend/overlay_preview.js @@ -42,12 +42,40 @@ return rect; } + function canvasSize() { + return S.projectCanvasSize ? S.projectCanvasSize() : { w: 768, h: 512 }; + } + + function previewScale(rect) { + const { w } = canvasSize(); + return rect.width / Math.max(1, w); + } + + function imageWidthPx(ov) { + const { w } = canvasSize(); + return OUI?.overlayWidthPx ? OUI.overlayWidthPx(ov, w) : Math.max(8, Math.round((ov.scale ?? 0.35) * w)); + } + + function imageHeightPx(ov) { + const { w, h } = canvasSize(); + return OUI?.overlayHeightPx ? OUI.overlayHeightPx(ov, w, h) : imageWidthPx(ov); + } + + function boxTransform(ov) { + const sx = ov.flip_h ? -1 : 1; + const sy = ov.flip_v ? -1 : 1; + let t = "translate(-50%, -50%)"; + if (sx !== 1 || sy !== 1) t += ` scale(${sx}, ${sy})`; + return t; + } + function placeBox(box, ov, rect) { const nx = ov.x != null ? ov.x : 0.5; const ny = ov.y != null ? ov.y : 0.5; box.style.left = (clamp01(nx, 0.5) * rect.width) + "px"; box.style.top = (clamp01(ny, 0.5) * rect.height) + "px"; box.style.opacity = ov.opacity != null ? ov.opacity : 1; + box.style.transform = boxTransform(ov); } function applyTextStyle(box, ov) { @@ -57,13 +85,135 @@ if (OUI) box.style.fontFamily = OUI.cssFamily(ov.font_family); } + function applyImageSize(img, ov, rect) { + const scale = previewScale(rect); + const wPx = imageWidthPx(ov); + if (ov.keep_aspect !== false) { + img.style.width = Math.max(8, wPx * scale) + "px"; + img.style.height = "auto"; + } else { + const hPx = imageHeightPx(ov); + img.style.width = Math.max(8, wPx * scale) + "px"; + img.style.height = Math.max(8, hPx * scale) + "px"; + img.style.objectFit = "fill"; + } + } + function openSettings(ov) { const fresh = S.overlayTrack(ov.id); if (fresh && window.Timeline?.openOverlaySettings) window.Timeline.openOverlaySettings(fresh); } + function addSelectionChrome(box, ov) { + const handles = document.createElement("div"); + handles.className = "pm-ov-handles"; + ["nw", "ne", "sw", "se"].forEach((corner) => { + const h = document.createElement("div"); + h.className = "pm-ov-handle " + corner; + h.addEventListener("pointerdown", (e) => startResize(e, ov, box, corner)); + handles.append(h); + }); + box.append(handles); + + const bar = document.createElement("div"); + bar.className = "pm-ov-flip-bar"; + const mkFlip = (label, key) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "pm-ov-flip-btn" + (ov[key] ? " active" : ""); + btn.textContent = label; + btn.title = key === "flip_h" ? "Flip horizontal" : "Flip vertical"; + btn.onmousedown = (e) => e.stopPropagation(); + btn.onclick = (e) => { + e.stopPropagation(); + S.updateOverlayTrack(ov.id, { [key]: !ov[key] }, true); + }; + return btn; + }; + bar.append(mkFlip("↔", "flip_h"), mkFlip("↕", "flip_v")); + box.append(bar); + } + + function startResize(e, ov, box, corner) { + if (e.button !== 0 || e.detail > 1) return; + e.preventDefault(); + e.stopPropagation(); + + const rect = layerRect(); + if (!rect) return; + const boxRect = box.getBoundingClientRect(); + const cx = boxRect.left + boxRect.width / 2; + const cy = boxRect.top + boxRect.height / 2; + const startDist = Math.max(12, Math.hypot(e.clientX - cx, e.clientY - cy)); + const startW = ov.kind === "text" ? (ov.font_size || 42) : imageWidthPx(ov); + const startH = ov.kind === "image" ? imageHeightPx(ov) : null; + + dragging = { + mode: "resize", + id: ov.id, + kind: ov.kind, + keepAspect: ov.kind === "text" || ov.keep_aspect !== false, + startDist, + startW, + startH, + cx, + cy, + box, + rect, + img: box.querySelector("img"), + moved: false, + }; + box.classList.add("dragging"); + if (S.get().selectedOverlayId !== ov.id) S.selectOverlay(ov.id); + + const onMove = (ev) => { + if (!dragging || dragging.mode !== "resize") return; + dragging.moved = true; + const dist = Math.max(12, Math.hypot(ev.clientX - dragging.cx, ev.clientY - dragging.cy)); + const ratio = dist / dragging.startDist; + if (dragging.kind === "text") { + const fontSize = Math.round(Math.max(8, Math.min(400, dragging.startW * ratio))); + dragging.box.style.fontSize = fontSize + "px"; + } else if (dragging.img) { + const scale = previewScale(dragging.rect); + const wPx = Math.round(Math.max(8, dragging.startW * ratio)); + if (dragging.keepAspect) { + dragging.img.style.width = Math.max(8, wPx * scale) + "px"; + dragging.img.style.height = "auto"; + } else { + const hPx = Math.round(Math.max(8, (dragging.startH || wPx) * ratio)); + dragging.img.style.width = Math.max(8, wPx * scale) + "px"; + dragging.img.style.height = Math.max(8, hPx * scale) + "px"; + } + } + }; + + const onUp = (ev) => { + if (!dragging || dragging.mode !== "resize") return; + const { id, kind, startDist, startW, startH, keepAspect, cx, cy, moved } = dragging; + const dist = Math.max(12, Math.hypot(ev.clientX - cx, ev.clientY - cy)); + const ratio = dist / startDist; + dragging.box.classList.remove("dragging"); + dragging = null; + document.removeEventListener("pointermove", onMove); + document.removeEventListener("pointerup", onUp); + if (!moved) return; + if (kind === "text") { + S.updateOverlayTrack(id, { font_size: Math.round(Math.max(8, Math.min(400, startW * ratio))) }, true); + } else { + const patch = { width_px: Math.round(Math.max(8, startW * ratio)) }; + if (!keepAspect) patch.height_px = Math.round(Math.max(8, (startH || startW) * ratio)); + S.updateOverlayTrack(id, patch, true); + } + }; + + document.addEventListener("pointermove", onMove); + document.addEventListener("pointerup", onUp); + } + function startDrag(e, ov, box, rect) { if (e.button !== 0 || e.detail > 1) return; + if (e.target.closest(".pm-ov-handle") || e.target.closest(".pm-ov-flip-bar")) return; e.preventDefault(); e.stopPropagation(); @@ -82,12 +232,12 @@ }; }; - dragging = { id: ov.id, grabOffX, grabOffY, rect, box, startX, startY, moved: false }; + dragging = { mode: "move", id: ov.id, grabOffX, grabOffY, rect, box, startX, startY, moved: false }; box.classList.add("dragging"); if (S.get().selectedOverlayId !== ov.id) S.selectOverlay(ov.id); const onMove = (ev) => { - if (!dragging) return; + if (!dragging || dragging.mode !== "move") return; if (Math.hypot(ev.clientX - dragging.startX, ev.clientY - dragging.startY) > 4) { dragging.moved = true; } @@ -98,7 +248,7 @@ dragging.box.style.top = (ny * r.height) + "px"; }; const onUp = (ev) => { - if (!dragging) return; + if (!dragging || dragging.mode !== "move") return; const { id, moved } = dragging; const r = layerRect() || dragging.rect; const { nx, ny } = posAt(ev.clientX, ev.clientY, r); @@ -132,39 +282,40 @@ tracks.forEach((ov) => { if (!overlayVisible(ov, t)) return; + const fresh = S.overlayTrack ? S.overlayTrack(ov.id) : ov; + const item = fresh || ov; const box = document.createElement("div"); - box.className = "pm-ov-box" + (ov.kind === "text" ? " text" : " image") + (sel === ov.id ? " selected" : ""); - box.title = ov.label || ov.text || "Overlay"; + box.className = "pm-ov-box" + (item.kind === "text" ? " text" : " image") + (sel === item.id ? " selected" : ""); + box.title = item.label || item.text || "Overlay"; - if (ov.kind === "text") { - applyTextStyle(box, ov); + if (item.kind === "text") { + applyTextStyle(box, item); } else { - const asset = (st.mediaBin || []).find((m) => m.id === ov.media_ref); + const asset = (st.mediaBin || []).find((m) => m.id === item.media_ref); if (asset?.kind === "image") { const img = document.createElement("img"); img.src = API.mediaUrl(asset.id); img.draggable = false; box.appendChild(img); - const scale = ov.scale != null ? ov.scale : 0.35; img.onload = () => { const r = layerRect(); if (!r) return; - const w = Math.max(24, r.width * scale); - img.style.width = w + "px"; - img.style.height = "auto"; - placeBox(box, ov, r); + applyImageSize(img, item, r); + placeBox(box, item, r); }; + applyImageSize(img, item, rect); } } - placeBox(box, ov, rect); - box.addEventListener("pointerdown", (e) => startDrag(e, ov, box, rect)); + placeBox(box, item, rect); + if (sel === item.id) addSelectionChrome(box, item); + box.addEventListener("pointerdown", (e) => startDrag(e, item, box, rect)); box.addEventListener("dblclick", (e) => { e.preventDefault(); e.stopPropagation(); dragging = null; - S.selectOverlay(ov.id); - openSettings(ov); + S.selectOverlay(item.id); + openSettings(item); }); host.append(box); }); @@ -176,5 +327,6 @@ S.subscribe(render); } if (window.Player) window.Player.onPlayheadChanged(render); + window.OverlayPreview = { refresh: render }; render(); })(); diff --git a/movie_editor/frontend/overlay_ui.js b/movie_editor/frontend/overlay_ui.js index 3bd97639..92850776 100644 --- a/movie_editor/frontend/overlay_ui.js +++ b/movie_editor/frontend/overlay_ui.js @@ -97,5 +97,95 @@ return wrap; } - window.OverlayUI = { FONTS, cssFamily, fontSelect, openModal, field, rangeField }; + function overlayWidthPx(ov, canvasW) { + if (ov.width_px != null) return Math.max(8, parseInt(ov.width_px, 10) || 8); + if (ov.scale != null) return Math.max(8, Math.round(+ov.scale * canvasW)); + return Math.max(8, Math.round(canvasW * 0.35)); + } + + function overlayHeightPx(ov, canvasW, canvasH) { + if (ov.height_px != null) return Math.max(8, parseInt(ov.height_px, 10) || 8); + return overlayWidthPx(ov, canvasW); + } + + function pixelSizeFields(ov, canvasW, canvasH, onPatch) { + const wrap = document.createElement("div"); + wrap.className = "ov-size-fields"; + const row = document.createElement("div"); + row.className = "ov-form-row"; + + const wIn = document.createElement("input"); + wIn.type = "number"; + wIn.min = "8"; + wIn.max = String(Math.max(canvasW * 4, 4096)); + wIn.step = "1"; + wIn.className = "ov-input"; + wIn.value = overlayWidthPx(ov, canvasW); + + const hIn = document.createElement("input"); + hIn.type = "number"; + hIn.min = "8"; + hIn.max = String(Math.max(canvasH * 4, 4096)); + hIn.step = "1"; + hIn.className = "ov-input"; + hIn.value = overlayHeightPx(ov, canvasW, canvasH); + + const keep = document.createElement("input"); + keep.type = "checkbox"; + keep.checked = ov.keep_aspect !== false; + keep.id = "ov-keep-" + (ov.id || "new"); + + const syncHeight = () => { + hIn.disabled = keep.checked; + hIn.style.opacity = keep.checked ? "0.45" : "1"; + }; + syncHeight(); + + wIn.oninput = () => onPatch({ width_px: Math.max(8, parseInt(wIn.value || "8", 10)) }); + hIn.oninput = () => onPatch({ height_px: Math.max(8, parseInt(hIn.value || "8", 10)) }); + keep.onchange = () => { + syncHeight(); + onPatch({ keep_aspect: keep.checked }); + }; + + row.append(field("Width (px)", wIn), field("Height (px)", hIn)); + wrap.append(row); + + const keepRow = document.createElement("label"); + keepRow.className = "ov-keep-row"; + const keepLab = document.createElement("span"); + keepLab.textContent = "Keep proportion"; + keepRow.append(keep, keepLab); + wrap.append(keepRow); + + const flipRow = document.createElement("div"); + flipRow.className = "ov-flip-row"; + let flipH = !!ov.flip_h; + let flipV = !!ov.flip_v; + const mkFlip = (label, get, set) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "btn ghost tiny" + (get() ? " active" : ""); + btn.textContent = label; + btn.onclick = (e) => { + e.preventDefault(); + set(!get()); + btn.classList.toggle("active", get()); + onPatch({ flip_h: flipH, flip_v: flipV }); + }; + return btn; + }; + flipRow.append( + mkFlip("Flip horizontal", () => flipH, (v) => { flipH = v; }), + mkFlip("Flip vertical", () => flipV, (v) => { flipV = v; }), + ); + wrap.append(flipRow); + + return wrap; + } + + window.OverlayUI = { + FONTS, cssFamily, fontSelect, openModal, field, rangeField, + overlayWidthPx, overlayHeightPx, pixelSizeFields, + }; })(); diff --git a/movie_editor/frontend/player.js b/movie_editor/frontend/player.js index d2523e13..72d341db 100644 --- a/movie_editor/frontend/player.js +++ b/movie_editor/frontend/player.js @@ -758,9 +758,9 @@ _clipBoundaryToken = 0; _stopTick(); _pauseInsAudio(); - _notifyPh(); _renderTransport(); if (wasPlaying) render(S.get()); + _notifyPh(); } function _stop() { @@ -1145,6 +1145,8 @@ // header status clear(statusEl); if (gen.promptId) statusEl.append(el("span", null, gen.state.toUpperCase())); + + try { window.OverlayPreview?.refresh?.(); } catch (_) {} } if (window.ViewBus) window.ViewBus.subscribePlayer(render); diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index 30376418..5601b8a0 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -1598,7 +1598,27 @@ } function overlayTrack(id) { - return (state.project?.overlay_tracks || []).find((t) => t.id === id) || null; + const t = (state.project?.overlay_tracks || []).find((x) => x.id === id) || null; + return t ? normalizeOverlayTrack(t) : null; + } + + function projectCanvasSize() { + return { w: state.project?.width || 768, h: state.project?.height || 512 }; + } + + function normalizeOverlayTrack(ov) { + if (!ov) return ov; + const cw = state.project?.width || 768; + const ch = state.project?.height || 512; + if (ov.kind === "image") { + if (ov.width_px == null && ov.scale != null) ov.width_px = Math.max(8, Math.round(+ov.scale * cw)); + if (ov.width_px == null) ov.width_px = Math.max(8, Math.round(cw * 0.35)); + if (ov.keep_aspect === undefined) ov.keep_aspect = true; + if (!ov.keep_aspect && ov.height_px == null) ov.height_px = ov.width_px; + } + ov.flip_h = !!ov.flip_h; + ov.flip_v = !!ov.flip_v; + return ov; } function normalizeOverlayLanes(project) { @@ -1613,6 +1633,7 @@ const fallback = lanes[0].id; project.overlay_tracks.forEach((t) => { if (!t.lane_id || !ids.has(t.lane_id)) t.lane_id = fallback; + normalizeOverlayTrack(t); }); } @@ -1743,6 +1764,7 @@ const lane = laneId && overlayLaneById(laneId) ? laneId : defaultOverlayLaneId(); state.project.overlay_tracks = state.project.overlay_tracks || []; const id = _uid(); + const { w: cw, h: ch } = projectCanvasSize(); state.project.overlay_tracks.push({ id, lane_id: lane, @@ -1752,7 +1774,10 @@ duration_sec: 3, x: 0.5, y: 0.5, - scale: 0.35, + width_px: Math.max(8, Math.round(cw * 0.35)), + keep_aspect: true, + flip_h: false, + flip_v: false, opacity: 1, label: (asset && asset.name) || "Image", }); @@ -1779,6 +1804,8 @@ duration_sec: 3, x: 0.5, y: 0.5, + flip_h: false, + flip_v: false, opacity: 1, label: "Text", }); @@ -3139,6 +3166,7 @@ addAudioTrack, updateAudioTrack, removeAudioTrack, separateSceneAudio, separatedTrackForScene, separatedTrackMedia, separatedTrackInSec, separatedTrackDurSec, overlayTrack, selectOverlay, ensureOverlayLanes, sortedOverlayTracks, overlayLaneById, overlayLaneIndex, + projectCanvasSize, normalizeOverlayTrack, addOverlayLane, removeOverlayLane, assignMediaToOverlayLane, bringOverlayToFront, sendOverlayToBack, bringOverlayForward, sendOverlayBackward, addImageOverlay, addTextOverlay, updateOverlayTrack, removeOverlayTrack, removeSelectedOverlay, diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index 7178e6e6..ef2e7e0e 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -650,7 +650,8 @@ input[type=number] { font-family: var(--font-mono); } .tl-stage { display: flex; flex-direction: row; height: 100%; min-height: 100%; } .tl-gutter { flex: 0 0 auto; position: sticky; left: 0; z-index: 7; display: flex; flex-direction: column; background: var(--ink-1); border-right: 1px solid var(--line-2); } -.tl-gutter-ruler { flex: 0 0 24px; border-bottom: 1px solid var(--line); } +.tl-gutter-ruler { flex: 0 0 24px; border-bottom: 1px solid var(--line); + position: sticky; top: 0; z-index: 9; background: var(--ink-1); } .tl-gutter-tracks { flex: 0 0 auto; display: flex; flex-direction: column; } .tl-gutter-lane { display: flex; align-items: center; justify-content: center; text-align: center; padding: 0 4px; font-family: var(--font-mono); font-size: 9px; letter-spacing: .5px; text-transform: uppercase; @@ -662,7 +663,7 @@ input[type=number] { font-family: var(--font-mono); } .tl-content { position: relative; flex: 1 1 auto; min-height: 100%; } /* ruler */ -.tl-ruler2 { position: relative; height: 24px; flex: 0 0 24px; border-bottom: 1px solid var(--line); cursor: pointer; +.tl-ruler2 { position: sticky; top: 0; z-index: 8; height: 24px; flex: 0 0 24px; border-bottom: 1px solid var(--line); cursor: pointer; background: var(--ink-0); user-select: none; } .tl-tick { position: absolute; top: 0; bottom: 0; border-left: 1px solid var(--line-2); } .tl-tick-label { position: absolute; left: 4px; top: 4px; font-family: var(--font-mono); font-size: 10px; color: var(--faint); white-space: nowrap; } @@ -699,15 +700,6 @@ input[type=number] { font-family: var(--font-mono); } background: transparent; color: #d8b4fe; font-size: 10px; line-height: 1; cursor: pointer; opacity: .75; } .tl-gutter-ov-rm:hover { opacity: 1; background: color-mix(in srgb, var(--danger) 25%, transparent); color: #fecaca; } -.tl-gutter-ov-add, .tl-overlay-add-row { - display: flex; align-items: center; justify-content: center; box-sizing: border-box; - border-top: 1px solid #ffffff08; background: color-mix(in srgb, #c084fc 3%, var(--ink-0)); -} -.tl-overlay-add-btn { - width: 22px; height: 18px; padding: 0; border: 1px dashed color-mix(in srgb, #c084fc 35%, var(--line)); - border-radius: 4px; background: transparent; color: #d8b4fe; font-size: 14px; line-height: 1; cursor: pointer; -} -.tl-overlay-add-btn:hover { background: color-mix(in srgb, #c084fc 12%, transparent); border-color: #c084fc; } .insp-layer-actions { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 4px; } .tl-ov-clip { position: absolute; top: 4px; bottom: 4px; min-width: 40px; @@ -743,11 +735,34 @@ input[type=number] { font-family: var(--font-mono); } position: absolute; pointer-events: auto; cursor: grab; max-width: 92%; text-align: center; text-shadow: 0 1px 4px rgba(0,0,0,.85); white-space: pre-wrap; line-height: 1.15; border: 1px dashed transparent; border-radius: 4px; padding: 2px 4px; - transform: translate(-50%, -50%); + transform: translate(-50%, -50%); transform-origin: center center; } .pm-ov-box.selected { border-color: color-mix(in srgb, #c084fc 70%, transparent); } .pm-ov-box.dragging { cursor: grabbing; opacity: .92; } .pm-ov-box img { display: block; max-height: 70vh; pointer-events: none; } +.pm-ov-handles { position: absolute; inset: -5px; pointer-events: none; } +.pm-ov-handle { + position: absolute; width: 9px; height: 9px; border-radius: 2px; pointer-events: auto; + background: #c084fc; border: 1px solid #fff; box-shadow: 0 0 0 1px rgba(0,0,0,.35); +} +.pm-ov-handle.nw { top: 0; left: 0; cursor: nwse-resize; } +.pm-ov-handle.ne { top: 0; right: 0; cursor: nesw-resize; } +.pm-ov-handle.sw { bottom: 0; left: 0; cursor: nesw-resize; } +.pm-ov-handle.se { bottom: 0; right: 0; cursor: nwse-resize; } +.pm-ov-flip-bar { + position: absolute; left: 50%; top: -30px; transform: translateX(-50%); + display: flex; gap: 4px; pointer-events: auto; +} +.pm-ov-flip-btn { + width: 24px; height: 22px; padding: 0; border: 1px solid color-mix(in srgb, #c084fc 40%, var(--line)); + border-radius: 5px; background: rgba(0,0,0,.55); color: #e9d5ff; font-size: 13px; line-height: 1; cursor: pointer; +} +.pm-ov-flip-btn:hover, .pm-ov-flip-btn.active { background: color-mix(in srgb, #c084fc 25%, rgba(0,0,0,.55)); border-color: #c084fc; } +.ov-keep-row { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--muted); } +.ov-keep-row input { accent-color: #c084fc; } +.ov-flip-row { display: flex; gap: 8px; flex-wrap: wrap; } +.ov-flip-row .btn.active { border-color: #c084fc; color: #e9d5ff; } +.insp-layer-actions .btn.active { border-color: #c084fc; color: #e9d5ff; } /* overlay settings modals */ .ov-modal-overlay { align-items: center; padding: 24px 16px; } diff --git a/movie_editor/frontend/timeline.js b/movie_editor/frontend/timeline.js index 868cc3fb..4a97e7be 100644 --- a/movie_editor/frontend/timeline.js +++ b/movie_editor/frontend/timeline.js @@ -63,7 +63,6 @@ const VIDEO_LANE_H = 96; // matches .tl-track2 padding + clip band const AUDIO_LANE_H = 44; const OVERLAY_LANE_H = 40; - const OVERLAY_ADD_ROW_H = 24; function isAudioAsset(m) { return !!m && (m.kind === "audio" || /\.(mp3|wav|m4a|aac|ogg|flac|opus|weba)$/i.test(m.name || "")); @@ -994,15 +993,9 @@ } if (editing) { - const scale = el("input"); - scale.type = "range"; scale.min = "0.05"; scale.max = "1"; scale.step = "0.01"; - scale.value = existing.scale != null ? existing.scale : 0.35; - const scaleVal = el("span", "ov-range-val", Math.round(parseFloat(scale.value) * 100) + "%"); - scale.oninput = () => { - scaleVal.textContent = Math.round(parseFloat(scale.value) * 100) + "%"; - S.updateOverlayTrack(existing.id, { scale: parseFloat(scale.value) }, true); - }; - form.append(OUI.rangeField("Size", scale, scaleVal)); + form.append(OUI.pixelSizeFields(existing, st.project?.width || 768, st.project?.height || 512, (patch) => { + S.updateOverlayTrack(existing.id, patch, true); + })); const op = el("input"); op.type = "range"; op.min = "0"; op.max = "1"; op.step = "0.05"; op.value = existing.opacity != null ? existing.opacity : 1; @@ -1066,7 +1059,7 @@ size.type = "number"; size.min = "8"; size.max = "200"; size.step = "1"; size.value = state.font_size; size.oninput = () => { state.font_size = parseInt(size.value || "42", 10); syncPreview(); }; - row.append(OUI.field("Size", size)); + row.append(OUI.field("Font size (px)", size)); const fontSel = OUI.fontSelect(state.font_family, (v) => { state.font_family = v; syncPreview(); }); row.append(OUI.field("Font", fontSel)); @@ -1473,19 +1466,6 @@ return row; } - function overlayAddRow(kind) { - const row = el("div", kind); - row.style.height = OVERLAY_ADD_ROW_H + "px"; - row.style.minHeight = OVERLAY_ADD_ROW_H + "px"; - row.style.flex = `0 0 ${OVERLAY_ADD_ROW_H}px`; - const add = el("button", "tl-overlay-add-btn", "+"); - add.type = "button"; - add.title = "Add overlay track (draws above existing tracks)"; - add.onclick = (e) => { e.stopPropagation(); S.addOverlayLane(); }; - row.append(add); - return row; - } - function timelineGutter(st, p) { const gutter = el("div", "tl-gutter"); gutter.style.width = GUTTER_W + "px"; @@ -1517,7 +1497,6 @@ const ovLanes = p.overlay_lanes || []; const gOv = el("div", "tl-gutter-ov"); ovLanes.forEach((lane, i) => gOv.append(overlayGutterLane(st, lane, i, ovLanes.length))); - gOv.append(overlayAddRow("tl-gutter-ov-add")); gTracks.append(gOv); gutter.append(gTracks); return gutter; @@ -1574,7 +1553,6 @@ const lanes = p.overlay_lanes || []; const wrap = el("div", "tl-overlay-lanes"); lanes.forEach((lane, i) => wrap.append(overlayLaneEl(st, p, lane, i, lanes.length))); - wrap.append(overlayAddRow("tl-overlay-add-row")); return wrap; } diff --git a/movie_editor/tests/test_nle_overlays.py b/movie_editor/tests/test_nle_overlays.py index ed93a18b..4e03f060 100644 --- a/movie_editor/tests/test_nle_overlays.py +++ b/movie_editor/tests/test_nle_overlays.py @@ -51,12 +51,49 @@ def test_sort_overlays_by_lane(): def test_build_overlay_image_filter(): parts, final = build_overlay_video_filter( "[vbase]", - [{"kind": "image", "start_sec": 0, "duration_sec": 5, "x": 0.5, "y": 0.5, "scale": 0.4, "opacity": 0.8}], + [{"kind": "image", "start_sec": 0, "duration_sec": 5, "x": 0.5, "y": 0.5, "width_px": 400, "opacity": 0.8}], canvas_w=1000, canvas_h=600, image_input_labels=["[3:v:0]"], ) assert len(parts) == 2 - assert "scale=400" in parts[0] + assert "scale=400:-1" in parts[0] assert "overlay=" in parts[1] assert final.startswith("[vov") + + +def test_build_overlay_image_legacy_scale(): + parts, _ = build_overlay_video_filter( + "[vbase]", + [{"kind": "image", "start_sec": 0, "duration_sec": 5, "scale": 0.4}], + canvas_w=1000, + canvas_h=600, + image_input_labels=["[3:v:0]"], + ) + assert "scale=400:-1" in parts[0] + + +def test_build_overlay_image_flip(): + parts, _ = build_overlay_video_filter( + "[vbase]", + [{"kind": "image", "start_sec": 0, "duration_sec": 2, "width_px": 200, "flip_h": True, "flip_v": True}], + canvas_w=800, + canvas_h=600, + image_input_labels=["[2:v:0]"], + ) + joined = "\n".join(parts) + assert "hflip" in joined + assert "vflip" in joined + + +def test_build_overlay_text_flip(): + parts, _ = build_overlay_video_filter( + "[vbase]", + [{"kind": "text", "text": "Hi", "start_sec": 0, "duration_sec": 1, "flip_h": True}], + canvas_w=768, + canvas_h=512, + image_input_labels=[], + ) + joined = "\n".join(parts) + assert "drawtext" in joined + assert "hflip" in joined