diff --git a/movie_editor/backend/nle_overlays.py b/movie_editor/backend/nle_overlays.py index 1ceb166c..bc569630 100644 --- a/movie_editor/backend/nle_overlays.py +++ b/movie_editor/backend/nle_overlays.py @@ -1,10 +1,53 @@ """Timeline graphics overlays (images + text) for preview/export ffmpeg compositing.""" from __future__ import annotations +import os import re from typing import Any +# Common system font paths (first match wins at export time). +_FONT_PATHS: dict[str, list[str]] = { + "arial": [ + "/System/Library/Fonts/Supplemental/Arial.ttf", + "/Library/Fonts/Arial.ttf", + "/usr/share/fonts/truetype/msttcorefonts/Arial.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + ], + "helvetica": [ + "/System/Library/Fonts/Helvetica.ttc", + "/System/Library/Fonts/Supplemental/Arial.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + ], + "georgia": [ + "/System/Library/Fonts/Supplemental/Georgia.ttf", + "/Library/Fonts/Georgia.ttf", + "/usr/share/fonts/truetype/msttcorefonts/Georgia.ttf", + ], + "times": [ + "/System/Library/Fonts/Supplemental/Times New Roman.ttf", + "/Library/Fonts/Times New Roman.ttf", + "/usr/share/fonts/truetype/msttcorefonts/Times_New_Roman.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf", + ], + "courier": [ + "/System/Library/Fonts/Supplemental/Courier New.ttf", + "/Library/Fonts/Courier New.ttf", + "/usr/share/fonts/truetype/msttcorefonts/Courier_New.ttf", + "/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf", + ], + "verdana": [ + "/System/Library/Fonts/Supplemental/Verdana.ttf", + "/Library/Fonts/Verdana.ttf", + "/usr/share/fonts/truetype/msttcorefonts/Verdana.ttf", + ], + "impact": [ + "/System/Library/Fonts/Supplemental/Impact.ttf", + "/Library/Fonts/Impact.ttf", + ], +} + + def _escape_drawtext(text: str) -> str: s = str(text or "") s = s.replace("\\", "\\\\").replace(":", "\\:").replace("'", "\\'").replace("%", "\\%") @@ -12,6 +55,34 @@ def _escape_drawtext(text: str) -> str: return s +def _resolve_fontfile(font_family: str | None) -> str | None: + key = (font_family or "system-ui").strip().lower() + if key in ("", "system-ui", "default"): + return None + for path in _FONT_PATHS.get(key, []): + if os.path.isfile(path): + return path + return None + + +def sort_overlays_for_composite( + overlays: list[dict], + lanes: list[dict] | None = None, +) -> list[dict]: + """Bottom lanes first, then start time within a lane.""" + lane_list = list(lanes or []) + if not lane_list: + return list(overlays or []) + order = {str(l.get("id") or ""): i for i, l in enumerate(lane_list)} + fallback = str(lane_list[0].get("id") or "") + + def key(ov: dict) -> tuple[int, float]: + lid = str(ov.get("lane_id") or fallback) + return (order.get(lid, 0), float(ov.get("start_sec") or 0)) + + return sorted(list(overlays or []), key=key) + + def build_overlay_video_filter( base_label: str, overlays: list[dict], @@ -53,8 +124,10 @@ def build_overlay_video_filter( alpha = f":alpha={opacity:.3f}" if opacity < 0.999 else "" x_expr = f"{nx:.6f}*W-text_w/2" 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}:" + f"{cur}drawtext=text='{text}':fontsize={size}:fontcolor={fontcolor}{alpha}{font_opt}:" f"x={x_expr}:y={y_expr}:{enable}{out}" ) cur = out diff --git a/movie_editor/backend/timeline.py b/movie_editor/backend/timeline.py index 8cfacdd9..7beb54a9 100644 --- a/movie_editor/backend/timeline.py +++ b/movie_editor/backend/timeline.py @@ -619,10 +619,10 @@ class Project: # Overlay tracks use absolute timeline start_sec and persist across scene edits. keep_original_audio: bool = True audio_tracks: list = field(default_factory=list) - # Graphics overlays composited on top of the final montage (one timeline lane): - # image — {id, kind:"image", media_ref, start_sec, duration_sec, x, y, scale, opacity, label} - # text — {id, kind:"text", text, font_size, color, start_sec, duration_sec, x, y, opacity} - # x/y are normalized center position (0..1). Later items draw on top when times overlap. + # Graphics overlay lanes (bottom → top). Each lane is one timeline row; higher lanes draw on top. + # lane — {id, label} + # clip — {id, lane_id, kind:"image"|"text", start_sec, duration_sec, x, y, …} + overlay_lanes: list = field(default_factory=list) overlay_tracks: list = field(default_factory=list) # Per-project pipeline config: the configured loader/node slots + linked inputs # (same shape as the global models.json). Empty {"slots": []} falls back to the @@ -663,6 +663,7 @@ class Project: sampler_inputs=dict(d.get("sampler_inputs") or {}), keep_original_audio=bool(d.get("keep_original_audio", True)), audio_tracks=list(d.get("audio_tracks") or []), + overlay_lanes=list(d.get("overlay_lanes") or []), overlay_tracks=list(d.get("overlay_tracks") or []), models=dict(d.get("models") or {"slots": []}), guide_settings=dict(d.get("guide_settings") or {}), diff --git a/movie_editor/frontend/index.html b/movie_editor/frontend/index.html index 09603258..dffad0a3 100644 --- a/movie_editor/frontend/index.html +++ b/movie_editor/frontend/index.html @@ -115,6 +115,7 @@ + diff --git a/movie_editor/frontend/inspector.js b/movie_editor/frontend/inspector.js index 4683d425..61ca2af2 100644 --- a/movie_editor/frontend/inspector.js +++ b/movie_editor/frontend/inspector.js @@ -777,6 +777,11 @@ size.oninput = () => S.updateOverlayTrack(ov.id, { font_size: parseInt(size.value || "42", 10) }, true); sec.append(field("Font size", size)); + const fontSel = window.OverlayUI + ? window.OverlayUI.fontSelect(ov.font_family || "system-ui", (v) => S.updateOverlayTrack(ov.id, { font_family: v }, true)) + : el("select"); + sec.append(field("Font", fontSel)); + const color = el("input"); color.type = "color"; color.value = (ov.color || "#ffffff").match(/^#/) ? ov.color : "#ffffff"; @@ -815,6 +820,28 @@ op.oninput = () => S.updateOverlayTrack(ov.id, { opacity: parseFloat(op.value) }, true); sec.append(field("Opacity", op)); + 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; + sec.append(el("div", "insp-hint", lane + ? `Track ${laneIdx + 1} of ${laneCount} · higher tracks draw on top.` + : "Higher overlay tracks draw on top when times overlap.")); + + const layerRow = el("div", "insp-layer-actions"); + const mkLayerBtn = (label, fn, title) => { + const b = el("button", "btn ghost tiny", label); + b.title = title; + b.onclick = fn; + return b; + }; + layerRow.append( + mkLayerBtn("To back", () => S.sendOverlayToBack(ov.id), "Move to bottom overlay track"), + mkLayerBtn("↓", () => S.sendOverlayBackward(ov.id), "One track down"), + mkLayerBtn("↑", () => S.bringOverlayForward(ov.id), "One track up"), + mkLayerBtn("To front", () => S.bringOverlayToFront(ov.id), "Move to top overlay track"), + ); + sec.append(layerRow); + sec.append(el("div", "insp-hint", "Drag on the preview to reposition. Double-click the timeline clip to edit.")); const rm = el("button", "btn danger tiny", "Remove overlay"); rm.onclick = () => S.removeOverlayTrack(ov.id); diff --git a/movie_editor/frontend/overlay_preview.js b/movie_editor/frontend/overlay_preview.js index 40302405..c44e1118 100644 --- a/movie_editor/frontend/overlay_preview.js +++ b/movie_editor/frontend/overlay_preview.js @@ -2,6 +2,7 @@ (function () { const S = window.Store; const API = window.MovieEditorAPI; + const OUI = window.OverlayUI; let layer = null; let dragging = null; @@ -36,6 +37,13 @@ box.style.opacity = ov.opacity != null ? ov.opacity : 1; } + function applyTextStyle(box, ov) { + box.textContent = ov.text || "Text"; + box.style.fontSize = (ov.font_size || 42) + "px"; + box.style.color = ov.color || "#ffffff"; + if (OUI) box.style.fontFamily = OUI.cssFamily(ov.font_family); + } + function startDrag(e, ov, box, rect) { e.preventDefault(); e.stopPropagation(); @@ -44,33 +52,34 @@ const boxRect = box.getBoundingClientRect(); const grabOffX = e.clientX - (boxRect.left + boxRect.width / 2); const grabOffY = e.clientY - (boxRect.top + boxRect.height / 2); - const ox = ov.x != null ? ov.x : 0.5; - const oy = ov.y != null ? ov.y : 0.5; - dragging = { id: ov.id, ox, oy, grabOffX, grabOffY, rect }; - box.classList.add("dragging"); const posAt = (clientX, clientY) => { - const cx = clientX - dragging.rect.left - dragging.grabOffX; - const cy = clientY - dragging.rect.top - dragging.grabOffY; - const nx = Math.max(0, Math.min(1, cx / dragging.rect.width)); - const ny = Math.max(0, Math.min(1, cy / dragging.rect.height)); - return { nx, ny }; + const cx = clientX - rect.left - grabOffX; + const cy = clientY - rect.top - grabOffY; + return { + nx: Math.max(0, Math.min(1, cx / rect.width)), + ny: Math.max(0, Math.min(1, cy / rect.height)), + }; }; + dragging = { id: ov.id, grabOffX, grabOffY, rect, box }; + box.classList.add("dragging"); + const onMove = (ev) => { if (!dragging) return; const { nx, ny } = posAt(ev.clientX, ev.clientY); - box.style.left = (nx * dragging.rect.width) + "px"; - box.style.top = (ny * dragging.rect.height) + "px"; + dragging.box.style.left = (nx * dragging.rect.width) + "px"; + dragging.box.style.top = (ny * dragging.rect.height) + "px"; }; const onUp = (ev) => { if (!dragging) return; const { nx, ny } = posAt(ev.clientX, ev.clientY); - S.updateOverlayTrack(dragging.id, { x: nx, y: ny }, true); + const id = dragging.id; + dragging.box.classList.remove("dragging"); dragging = null; - box.classList.remove("dragging"); document.removeEventListener("pointermove", onMove); document.removeEventListener("pointerup", onUp); + S.updateOverlayTrack(id, { x: nx, y: ny }, true); }; document.addEventListener("pointermove", onMove); document.addEventListener("pointerup", onUp); @@ -85,12 +94,12 @@ host.style.display = "none"; return; } + if (dragging) return; host.style.display = ""; - const tracks = st.project?.overlay_tracks || []; + const tracks = S.sortedOverlayTracks ? S.sortedOverlayTracks() : (st.project?.overlay_tracks || []); const t = window.Player?.getPlayhead?.() ?? 0; const sel = st.selectedOverlayId; const rect = host.getBoundingClientRect(); - if (dragging) return; host.replaceChildren(); tracks.forEach((ov) => { @@ -100,9 +109,7 @@ box.title = ov.label || ov.text || "Overlay"; if (ov.kind === "text") { - box.textContent = ov.text || "Text"; - box.style.fontSize = (ov.font_size || 42) + "px"; - box.style.color = ov.color || "#ffffff"; + applyTextStyle(box, ov); } else { const asset = (st.mediaBin || []).find((m) => m.id === ov.media_ref); if (asset?.kind === "image") { @@ -126,8 +133,8 @@ }); } - if (window.ViewBus) { - window.ViewBus.subscribePlayer(render); + if (window.ViewBus?.subscribeOverlays) { + window.ViewBus.subscribeOverlays(render); } else { S.subscribe(render); } diff --git a/movie_editor/frontend/overlay_ui.js b/movie_editor/frontend/overlay_ui.js new file mode 100644 index 00000000..3bd97639 --- /dev/null +++ b/movie_editor/frontend/overlay_ui.js @@ -0,0 +1,101 @@ +// Shared overlay fonts + modal shell for text/image overlay dialogs. +(function () { + const FONTS = [ + { id: "system-ui", label: "System default", css: "system-ui, -apple-system, sans-serif" }, + { id: "arial", label: "Arial", css: "Arial, Helvetica, sans-serif" }, + { id: "helvetica", label: "Helvetica Neue", css: "'Helvetica Neue', Helvetica, Arial, sans-serif" }, + { id: "georgia", label: "Georgia", css: "Georgia, 'Times New Roman', serif" }, + { id: "times", label: "Times New Roman", css: "'Times New Roman', Times, serif" }, + { id: "courier", label: "Courier New", css: "'Courier New', Courier, monospace" }, + { id: "verdana", label: "Verdana", css: "Verdana, Geneva, sans-serif" }, + { id: "impact", label: "Impact", css: "Impact, Haettenschweiler, sans-serif" }, + ]; + + function cssFamily(id) { + const f = FONTS.find((x) => x.id === id) || FONTS[0]; + return f.css; + } + + function fontSelect(value, onChange) { + const sel = document.createElement("select"); + sel.className = "ov-input"; + FONTS.forEach((f) => { + const o = document.createElement("option"); + o.value = f.id; + o.textContent = f.label; + o.style.fontFamily = f.css; + if (f.id === (value || "system-ui")) o.selected = true; + sel.appendChild(o); + }); + sel.onchange = () => onChange(sel.value); + return sel; + } + + function openModal({ title, subtitle, widthClass, onClose }) { + const overlay = document.createElement("div"); + overlay.className = "modal-overlay ov-modal-overlay"; + const box = document.createElement("div"); + box.className = "modal ov-modal" + (widthClass ? " " + widthClass : ""); + const head = document.createElement("div"); + head.className = "ov-modal-head"; + const headText = document.createElement("div"); + headText.className = "ov-modal-head-text"; + const titleEl = document.createElement("div"); + titleEl.className = "ov-modal-title"; + titleEl.textContent = title; + headText.appendChild(titleEl); + if (subtitle) { + const sub = document.createElement("div"); + sub.className = "ov-modal-sub"; + sub.textContent = subtitle; + headText.appendChild(sub); + } + const closeBtn = document.createElement("button"); + closeBtn.type = "button"; + closeBtn.className = "ov-modal-close"; + closeBtn.title = "Close"; + closeBtn.textContent = "×"; + head.append(headText, closeBtn); + const body = document.createElement("div"); + body.className = "ov-modal-body"; + const foot = document.createElement("div"); + foot.className = "ov-modal-foot"; + box.append(head, body, foot); + overlay.append(box); + const close = () => { + overlay.remove(); + if (onClose) onClose(); + }; + closeBtn.onclick = close; + overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); }); + document.body.append(overlay); + return { overlay, body, foot, close }; + } + + function field(label, control, opts) { + opts = opts || {}; + const row = document.createElement("label"); + row.className = "ov-field" + (opts.full ? " full" : ""); + const lab = document.createElement("span"); + lab.className = "ov-label"; + lab.textContent = label; + row.append(lab, control); + return row; + } + + function rangeField(label, input, valueEl) { + const wrap = document.createElement("div"); + wrap.className = "ov-field full ov-range-field"; + const top = document.createElement("div"); + top.className = "ov-range-top"; + const lab = document.createElement("span"); + lab.className = "ov-label"; + lab.textContent = label; + top.append(lab, valueEl || null); + input.className = "ov-range"; + wrap.append(top, input); + return wrap; + } + + window.OverlayUI = { FONTS, cssFamily, fontSelect, openModal, field, rangeField }; +})(); diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index 9d2d55f8..11a8dc45 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -609,6 +609,7 @@ state.gen = { state: "idle", promptId: null, media: [], msg: "" }; state.sceneRenders = JSON.parse(JSON.stringify(state.project.scene_renders || {})); state.sceneGhosts = JSON.parse(JSON.stringify(state.project.scene_ghosts || [])); + normalizeOverlayLanes(state.project); state.models = JSON.parse(JSON.stringify(state.project.models || { slots: [] })); if (_clearOrphanRatings()) { _syncEditorStateToProject(); @@ -1600,6 +1601,130 @@ return (state.project?.overlay_tracks || []).find((t) => t.id === id) || null; } + function normalizeOverlayLanes(project) { + if (!project) return; + project.overlay_tracks = project.overlay_tracks || []; + let lanes = project.overlay_lanes = project.overlay_lanes || []; + if (!lanes.length) { + const id = _uid(); + lanes = project.overlay_lanes = [{ id, label: "Overlay 1" }]; + } + const ids = new Set(lanes.map((l) => l.id)); + const fallback = lanes[0].id; + project.overlay_tracks.forEach((t) => { + if (!t.lane_id || !ids.has(t.lane_id)) t.lane_id = fallback; + }); + } + + function ensureOverlayLanes() { + if (!state.project) return []; + normalizeOverlayLanes(state.project); + return state.project.overlay_lanes; + } + + function overlayLaneById(laneId) { + return ensureOverlayLanes().find((l) => l.id === laneId) || null; + } + + function overlayLaneIndex(laneId) { + return ensureOverlayLanes().findIndex((l) => l.id === laneId); + } + + function sortedOverlayTracks() { + ensureOverlayLanes(); + const tracks = [...(state.project.overlay_tracks || [])]; + tracks.sort((a, b) => { + const ai = overlayLaneIndex(a.lane_id); + const bi = overlayLaneIndex(b.lane_id); + if (ai !== bi) return ai - bi; + return (a.start_sec || 0) - (b.start_sec || 0); + }); + return tracks; + } + + function defaultOverlayLaneId() { + const lanes = ensureOverlayLanes(); + return lanes[lanes.length - 1]?.id || lanes[0]?.id; + } + + function addOverlayLane(opts) { + if (!state.project) return null; + _historyRecord(); + ensureOverlayLanes(); + const id = _uid(); + const n = state.project.overlay_lanes.length + 1; + const lane = { id, label: `Overlay ${n}` }; + if (opts?.belowId) { + const idx = state.project.overlay_lanes.findIndex((l) => l.id === opts.belowId); + state.project.overlay_lanes.splice(Math.max(0, idx), 0, lane); + } else { + state.project.overlay_lanes.push(lane); + } + notify(); scheduleSave(); + return id; + } + + function removeOverlayLane(laneId) { + if (!state.project) return; + ensureOverlayLanes(); + if (state.project.overlay_lanes.length <= 1) return; + const hasClips = state.project.overlay_tracks.some((t) => t.lane_id === laneId); + if (hasClips && !confirm("Remove this overlay track and all clips on it?")) return; + _historyRecord(); + state.project.overlay_tracks = state.project.overlay_tracks.filter((t) => t.lane_id !== laneId); + state.project.overlay_lanes = state.project.overlay_lanes.filter((l) => l.id !== laneId); + notify(); scheduleSave(); + } + + function _setOverlayLane(overlayId, laneId) { + const t = overlayTrack(overlayId); + if (!t || !overlayLaneById(laneId)) return; + t.lane_id = laneId; + notify(); scheduleSave(); + } + + function bringOverlayToFront(overlayId) { + const lanes = ensureOverlayLanes(); + if (!lanes.length) return; + _historyRecord(); + _setOverlayLane(overlayId, lanes[lanes.length - 1].id); + } + + function sendOverlayToBack(overlayId) { + const lanes = ensureOverlayLanes(); + if (!lanes.length) return; + _historyRecord(); + _setOverlayLane(overlayId, lanes[0].id); + } + + function bringOverlayForward(overlayId) { + const t = overlayTrack(overlayId); + if (!t) return; + const lanes = ensureOverlayLanes(); + const idx = overlayLaneIndex(t.lane_id); + if (idx < 0 || idx >= lanes.length - 1) return; + _historyRecord(); + _setOverlayLane(overlayId, lanes[idx + 1].id); + } + + function sendOverlayBackward(overlayId) { + const t = overlayTrack(overlayId); + if (!t) return; + const lanes = ensureOverlayLanes(); + const idx = overlayLaneIndex(t.lane_id); + if (idx <= 0) return; + _historyRecord(); + _setOverlayLane(overlayId, lanes[idx - 1].id); + } + + function assignMediaToOverlayLane(laneId, mediaId) { + if (!state.project || !laneId || !mediaId) return; + ensureOverlayLanes(); + if (!overlayLaneById(laneId)) return; + const asset = (state.mediaBin || []).find((m) => m.id === mediaId); + if (asset?.kind === "image") addImageOverlay(mediaId, undefined, laneId); + } + function selectOverlay(id) { state.selectedOverlayId = id || null; if (id) { @@ -1610,14 +1735,17 @@ notify(); } - function addImageOverlay(mediaId, startSec) { + function addImageOverlay(mediaId, startSec, laneId) { if (!state.project || !mediaId) return; _historyRecord(); + ensureOverlayLanes(); const asset = (state.mediaBin || []).find((m) => m.id === mediaId); + const lane = laneId && overlayLaneById(laneId) ? laneId : defaultOverlayLaneId(); state.project.overlay_tracks = state.project.overlay_tracks || []; const id = _uid(); state.project.overlay_tracks.push({ id, + lane_id: lane, kind: "image", media_ref: mediaId, start_sec: Math.max(0, +(startSec ?? _playheadSec())), @@ -1632,16 +1760,20 @@ scheduleSave(); } - function addTextOverlay(text, startSec) { + function addTextOverlay(text, startSec, laneId) { if (!state.project) return; _historyRecord(); + ensureOverlayLanes(); + const lane = laneId && overlayLaneById(laneId) ? laneId : defaultOverlayLaneId(); state.project.overlay_tracks = state.project.overlay_tracks || []; const id = _uid(); state.project.overlay_tracks.push({ id, + lane_id: lane, kind: "text", text: String(text || "Title").trim() || "Title", font_size: 42, + font_family: "system-ui", color: "#ffffff", start_sec: Math.max(0, +(startSec ?? _playheadSec())), duration_sec: 3, @@ -3000,7 +3132,10 @@ buildPreviewSegments, previewTotalSec, segmentDurationSec, sceneDurationSec, clipSourceInSec, addAudioTrack, updateAudioTrack, removeAudioTrack, separateSceneAudio, separatedTrackForScene, separatedTrackMedia, separatedTrackInSec, separatedTrackDurSec, - overlayTrack, selectOverlay, addImageOverlay, addTextOverlay, updateOverlayTrack, removeOverlayTrack, + overlayTrack, selectOverlay, ensureOverlayLanes, sortedOverlayTracks, overlayLaneById, overlayLaneIndex, + addOverlayLane, removeOverlayLane, assignMediaToOverlayLane, + bringOverlayToFront, sendOverlayToBack, bringOverlayForward, sendOverlayBackward, + addImageOverlay, addTextOverlay, updateOverlayTrack, removeOverlayTrack, isOverlayAudioTrack, isSeparatedAudioTrack, resizeScene, splitScene, snapFrames, setSourceTrim, trimSceneLeft, slipScene, applyEnginePreset, ENGINE_PRESETS, undo, redo, diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index ff1e67df..684e65f8 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -687,8 +687,15 @@ input[type=number] { font-family: var(--font-mono); } /* overlay graphics lane (text + images) */ .tl-overlay-lanes { border-top: 1px solid var(--line); } .tl-overlay-lane { position: relative; border-top: 1px solid #ffffff08; background: color-mix(in srgb, #c084fc 4%, var(--ink-0)); } -.tl-overlay-lane-body { position: relative; height: 100%; } -.tl-gutter-lane.overlay { color: #d8b4fe; } +.tl-overlay-lane.top { background: color-mix(in srgb, #c084fc 7%, var(--ink-0)); } +.tl-overlay-lane.drop-target { background: color-mix(in srgb, #c084fc 14%, var(--ink-0)); box-shadow: inset 0 0 0 1px #c084fc55; } +.tl-overlay-lane-body { position: relative; height: 100%; min-height: 36px; } +.tl-gutter-ov { flex: 0 0 auto; border-top: 1px solid var(--line); } +.tl-gutter-ov .tl-gutter-lane.overlay { color: #d8b4fe; flex: 0 0 40px; height: 40px; font-size: 10px; } +.tl-gutter-ov .tl-gutter-lane.overlay.top { color: #e9d5ff; font-weight: 600; } +.tl-gutter-add-ov { flex: 0 0 28px; height: 28px; margin: 4px 6px 6px; border: 1px dashed color-mix(in srgb, #c084fc 35%, var(--line)); border-radius: 6px; background: transparent; color: #d8b4fe; cursor: pointer; font-size: 16px; line-height: 1; } +.tl-gutter-add-ov:hover { background: color-mix(in srgb, #c084fc 10%, 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; border-radius: 6px; border: 1px solid color-mix(in srgb, #c084fc 40%, var(--line)); @@ -722,6 +729,41 @@ input[type=number] { font-family: var(--font-mono); } .pm-ov-box.dragging { cursor: grabbing; opacity: .92; } .pm-ov-box img { display: block; max-height: 70vh; pointer-events: none; } +/* overlay settings modals */ +.ov-modal-overlay { align-items: center; padding: 24px 16px; } +.ov-modal { width: min(420px, 94vw); max-height: min(88vh, 720px); border-radius: 16px; border-color: color-mix(in srgb, #c084fc 18%, var(--line)); box-shadow: 0 24px 64px rgba(0,0,0,.55); } +.ov-modal.ov-modal-md { width: min(560px, 94vw); } +.ov-modal-head { display: flex; align-items: flex-start; gap: 12px; padding: 18px 20px 14px; border-bottom: 1px solid var(--line); background: linear-gradient(180deg, color-mix(in srgb, #c084fc 6%, #1a1712), #14110d); } +.ov-modal-head-text { flex: 1; min-width: 0; } +.ov-modal-title { font-family: var(--font-display); font-weight: 700; font-size: 18px; letter-spacing: -.01em; } +.ov-modal-sub { margin-top: 4px; font-size: 12px; line-height: 1.45; color: var(--muted); } +.ov-modal-close { flex: 0 0 32px; width: 32px; height: 32px; border: none; border-radius: 8px; background: transparent; color: var(--muted); font-size: 22px; line-height: 1; cursor: pointer; } +.ov-modal-close:hover { background: #ffffff10; color: var(--text); } +.ov-modal-body { padding: 18px 20px; overflow: auto; display: flex; flex-direction: column; gap: 16px; } +.ov-modal-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 20px 18px; border-top: 1px solid var(--line); background: #12100c; } +.ov-form { display: flex; flex-direction: column; gap: 12px; } +.ov-form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.ov-field { display: flex; flex-direction: column; gap: 6px; min-width: 0; } +.ov-field.full { grid-column: 1 / -1; } +.ov-label { font-size: 11px; font-family: var(--font-mono); letter-spacing: .06em; text-transform: uppercase; color: var(--muted); } +.ov-input, .ov-textarea { width: 100%; box-sizing: border-box; border: 1px solid var(--line); border-radius: 10px; background: var(--ink-2); color: var(--text); padding: 10px 12px; font: inherit; } +.ov-textarea { min-height: 72px; resize: vertical; line-height: 1.35; } +.ov-input:focus, .ov-textarea:focus, .ov-color:focus { outline: none; border-color: color-mix(in srgb, #c084fc 45%, var(--line)); box-shadow: 0 0 0 3px color-mix(in srgb, #c084fc 12%, transparent); } +.ov-color { width: 100%; height: 40px; padding: 4px; cursor: pointer; border-radius: 10px; border: 1px solid var(--line); background: var(--ink-2); } +.ov-range-field { gap: 8px; } +.ov-range-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.ov-range-val { font-family: var(--font-mono); font-size: 11px; color: #d8b4fe; } +.ov-range { width: 100%; accent-color: #c084fc; } +.ov-text-preview { border-radius: 12px; border: 1px solid var(--line); background: radial-gradient(ellipse at center, #2a2430 0%, #121018 72%); min-height: 96px; display: grid; place-items: center; padding: 20px; } +.ov-text-preview-inner { max-width: 100%; text-align: center; text-shadow: 0 2px 8px rgba(0,0,0,.85); white-space: pre-wrap; line-height: 1.15; word-break: break-word; } +.ov-image-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(108px, 1fr)); gap: 10px; } +.ov-image-card { display: flex; flex-direction: column; gap: 6px; padding: 8px; border-radius: 12px; border: 1px solid var(--line); background: var(--ink-2); cursor: pointer; text-align: left; } +.ov-image-card:hover { border-color: color-mix(in srgb, #c084fc 35%, var(--line)); background: color-mix(in srgb, #c084fc 5%, var(--ink-2)); } +.ov-image-card.selected { border-color: #c084fc; box-shadow: 0 0 0 1px color-mix(in srgb, #c084fc 40%, transparent); } +.ov-image-card img { width: 100%; aspect-ratio: 16 / 10; object-fit: cover; border-radius: 8px; background: #000; display: block; } +.ov-image-name { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ov-empty { font-size: 13px; color: var(--muted); line-height: 1.45; } + .tl-audio-lane-body { position: absolute; inset: 0; } .tl-aud-clip { position: absolute; top: 4px; bottom: 4px; box-sizing: border-box; background: linear-gradient(180deg, #1e3a2f, #152820); border: 1px solid #2d5a45; border-radius: 5px; diff --git a/movie_editor/frontend/timeline.js b/movie_editor/frontend/timeline.js index f0f886f2..8adf9201 100644 --- a/movie_editor/frontend/timeline.js +++ b/movie_editor/frontend/timeline.js @@ -505,8 +505,8 @@ function onDrag(startEvt, onMove, onUp) { startEvt.preventDefault(); startEvt.stopPropagation(); const x0 = startEvt.clientX; - const move = (e) => onMove(e.clientX - x0); - const up = (e) => { document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", up); if (onUp) onUp(e.clientX - x0); }; + const move = (e) => onMove(e.clientX - x0, e); + const up = (e) => { document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", up); if (onUp) onUp(e.clientX - x0, e); }; document.addEventListener("mousemove", move); document.addEventListener("mouseup", up); } @@ -860,8 +860,9 @@ addRow("Video", "Add an imported video clip from the Media Browser", () => openVideoClipModal(st)); addRow("Effects", "Post-render clip effect (zoom, blur, fade…)", () => openNleSettingsModal("effect", st)); addRow("Transitions", "Video blend on the outgoing edge of the clip", () => openNleSettingsModal("transition", st)); - addRow("Text", "Add a title or label on the timeline", () => openTextOverlayModal(st)); + addRow("Text", "Add a title or label on the top overlay track", () => openTextOverlayModal(st)); addRow("Image", "Add a picture overlay from the Media Browser", () => openImageOverlayModal(st)); + addRow("Overlay track", "Add another compositing layer (higher tracks draw on top)", () => S.addOverlayLane()); addRow("Audio", "Add an audio track from the Media Browser", () => openAudioTrackModal(st)); btn.onclick = (e) => { @@ -937,38 +938,25 @@ document.body.append(_addModal); } - // ── audio lanes (NLE-style, below video) ─────────────────────────────────────── - function modalField(label, control) { - const wrap = el("div"); - wrap.style.marginBottom = "10px"; - wrap.append(el("div", "pj-meta", label)); - wrap.append(control); - return wrap; - } - + // ── overlay modals ───────────────────────────────────────────────────────────── function openImageOverlayModal(st, existing) { closeAddModal(); + const OUI = window.OverlayUI; + const API = window.MovieEditorAPI; const images = (st.mediaBin || []).filter((m) => m.kind === "image"); const editing = existing && existing.kind === "image"; - _addModal = el("div", "modal-overlay"); - const box = el("div", "modal"); - const head = el("div", "modal-head"); - head.append(el("div", "modal-title", editing ? "Image overlay settings" : "Add image overlay")); - const headRight = el("div", "modal-head-right"); - const closeBtn = el("button", "btn ghost tiny", "✕"); - closeBtn.onclick = closeAddModal; - headRight.append(closeBtn); - head.append(headRight); - box.append(head); - const content = el("div", "modal-content"); - const pick = (mediaId) => { - if (editing) S.updateOverlayTrack(existing.id, { media_ref: mediaId, label: (images.find((m) => m.id === mediaId)?.name) || "Image" }); - else S.addImageOverlay(mediaId); - closeAddModal(); - }; + const { body, foot } = OUI.openModal({ + title: editing ? "Image overlay" : "Add image", + subtitle: editing ? "Pick a source and adjust size on the preview." : "Placed at the playhead · drag on preview to move.", + widthClass: "ov-modal-md", + onClose: () => { _addModal = null; }, + }); + _addModal = body.closest(".modal-overlay"); + + const form = el("div", "ov-form"); if (!images.length) { - content.append(el("div", "pj-meta", "Upload images in the Media Browser, then pick one here.")); - const uploadBtn = el("button", "btn primary tiny", "Upload image"); + form.append(el("div", "ov-empty", "Upload images in the Media Browser first.")); + const uploadBtn = el("button", "btn primary", "Upload image"); const fileIn = el("input"); fileIn.type = "file"; fileIn.accept = "image/*,.png,.jpg,.jpeg,.webp,.gif"; @@ -979,95 +967,151 @@ fileIn.value = ""; if (!files.length) return; await S.uploadMedia(files); - const fresh = S.get(); - const added = (fresh.mediaBin || []).filter((m) => m.kind === "image"); - if (added[0]) pick(added[0].id); - else closeAddModal(); + closeAddModal(); + openImageOverlayModal(S.get(), existing); }; - content.append(uploadBtn, fileIn); + form.append(uploadBtn, fileIn); } else { - content.append(el("div", "pj-meta", editing ? "Change image or adjust settings below." : "Placed at the playhead. Drag on the preview to reposition.")); + const grid = el("div", "ov-image-grid"); images.forEach((m) => { - const row = el("button", "tl-add-row aud-pick-row" + (editing && existing.media_ref === m.id ? " selected" : ""), m.name || m.id); - row.type = "button"; - row.onclick = () => pick(m.id); - content.append(row); + const card = el("button", "ov-image-card" + (editing && existing.media_ref === m.id ? " selected" : "")); + card.type = "button"; + const img = el("img"); + img.src = API.mediaUrl(m.id); + img.alt = m.name || ""; + img.loading = "lazy"; + card.append(img, el("span", "ov-image-name", m.name || m.id)); + card.onclick = () => { + if (editing) S.updateOverlayTrack(existing.id, { media_ref: m.id, label: m.name || "Image" }); + else S.addImageOverlay(m.id); + closeAddModal(); + }; + grid.append(card); }); + form.append(grid); } + 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; - scale.oninput = () => S.updateOverlayTrack(existing.id, { scale: parseFloat(scale.value) }, true); - content.append(modalField("Size", scale)); + 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)); 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; - op.oninput = () => S.updateOverlayTrack(existing.id, { opacity: parseFloat(op.value) }, true); - content.append(modalField("Opacity", op)); + const opVal = el("span", "ov-range-val", Math.round(parseFloat(op.value) * 100) + "%"); + op.oninput = () => { + opVal.textContent = Math.round(parseFloat(op.value) * 100) + "%"; + S.updateOverlayTrack(existing.id, { opacity: parseFloat(op.value) }, true); + }; + form.append(OUI.rangeField("Opacity", op, opVal)); } - const cancel = el("button", "btn ghost tiny", editing ? "Close" : "Cancel"); + + body.append(form); + const cancel = el("button", "btn ghost", editing ? "Close" : "Cancel"); cancel.onclick = closeAddModal; - content.append(cancel); - box.append(content); - _addModal.append(box); - _addModal.addEventListener("click", (e) => { if (e.target === _addModal) closeAddModal(); }); - document.body.append(_addModal); + foot.append(cancel); } function openTextOverlayModal(st, existing) { closeAddModal(); + const OUI = window.OverlayUI; const editing = existing && existing.kind === "text"; - _addModal = el("div", "modal-overlay"); - const box = el("div", "modal"); - const head = el("div", "modal-head"); - head.append(el("div", "modal-title", editing ? "Text overlay settings" : "Add text overlay")); - const headRight = el("div", "modal-head-right"); - const closeBtn = el("button", "btn ghost tiny", "✕"); - closeBtn.onclick = closeAddModal; - headRight.append(closeBtn); - head.append(headRight); - box.append(head); - const content = el("div", "modal-content"); - if (!editing) content.append(el("div", "pj-meta", "Placed at the playhead. Drag on the preview to reposition.")); - const ta = el("textarea", "insp-global-ta"); + const state = { + text: editing ? (existing.text || "") : "Title", + font_size: editing ? (existing.font_size != null ? existing.font_size : 42) : 42, + font_family: editing ? (existing.font_family || "system-ui") : "system-ui", + color: editing ? ((existing.color || "#ffffff").match(/^#/) ? existing.color : "#ffffff") : "#ffffff", + opacity: editing ? (existing.opacity != null ? existing.opacity : 1) : 1, + }; + + const { body, foot } = OUI.openModal({ + title: editing ? "Text overlay" : "Add text", + subtitle: "Drag on the program monitor to reposition.", + widthClass: "ov-modal-sm", + onClose: () => { _addModal = null; }, + }); + _addModal = body.closest(".modal-overlay"); + + const preview = el("div", "ov-text-preview"); + const previewInner = el("div", "ov-text-preview-inner"); + preview.append(previewInner); + + const syncPreview = () => { + previewInner.textContent = state.text || "Title"; + previewInner.style.fontSize = state.font_size + "px"; + previewInner.style.color = state.color; + previewInner.style.fontFamily = OUI.cssFamily(state.font_family); + previewInner.style.opacity = state.opacity; + }; + syncPreview(); + + const form = el("div", "ov-form"); + const ta = el("textarea", "ov-input ov-textarea"); ta.rows = 2; - ta.value = editing ? (existing.text || "") : "Title"; + ta.value = state.text; ta.placeholder = "Enter text…"; - content.append(modalField("Text", ta)); - const size = el("input"); + ta.oninput = () => { state.text = ta.value; syncPreview(); }; + form.append(OUI.field("Text", ta, { full: true })); + + const row = el("div", "ov-form-row"); + const size = el("input", "ov-input"); size.type = "number"; size.min = "8"; size.max = "200"; size.step = "1"; - size.value = editing ? (existing.font_size != null ? existing.font_size : 42) : 42; - content.append(modalField("Font size", size)); - const color = el("input"); + size.value = state.font_size; + size.oninput = () => { state.font_size = parseInt(size.value || "42", 10); syncPreview(); }; + row.append(OUI.field("Size", size)); + + const fontSel = OUI.fontSelect(state.font_family, (v) => { state.font_family = v; syncPreview(); }); + row.append(OUI.field("Font", fontSel)); + form.append(row); + + const row2 = el("div", "ov-form-row"); + const color = el("input", "ov-color"); color.type = "color"; - color.value = editing ? ((existing.color || "#ffffff").match(/^#/) ? existing.color : "#ffffff") : "#ffffff"; - content.append(modalField("Color", color)); + color.value = state.color; + color.oninput = () => { state.color = color.value; syncPreview(); }; + row2.append(OUI.field("Color", color)); + form.append(row2); + const op = el("input"); op.type = "range"; op.min = "0"; op.max = "1"; op.step = "0.05"; - op.value = editing ? (existing.opacity != null ? existing.opacity : 1) : 1; - content.append(modalField("Opacity", op)); - const actions = el("div", "lib-form-actions"); - const ok = el("button", "btn primary tiny", editing ? "Save" : "Add"); + op.value = state.opacity; + const opVal = el("span", "ov-range-val", Math.round(state.opacity * 100) + "%"); + op.oninput = () => { + state.opacity = parseFloat(op.value); + opVal.textContent = Math.round(state.opacity * 100) + "%"; + syncPreview(); + }; + form.append(OUI.rangeField("Opacity", op, opVal)); + + body.append(preview, form); + + const cancel = el("button", "btn ghost", "Cancel"); + cancel.onclick = closeAddModal; + const ok = el("button", "btn primary", editing ? "Save" : "Add"); ok.onclick = () => { - const text = ta.value.trim() || "Title"; - if (editing) { - S.updateOverlayTrack(existing.id, { text, font_size: parseInt(size.value || "42", 10), color: color.value, opacity: parseFloat(op.value), label: "Text" }); - } else { - S.addTextOverlay(text); + const payload = { + text: (state.text || "").trim() || "Title", + font_size: state.font_size, + font_family: state.font_family, + color: state.color, + opacity: state.opacity, + label: "Text", + }; + if (editing) S.updateOverlayTrack(existing.id, payload); + else { + S.addTextOverlay(payload.text); const ov = (S.get().project?.overlay_tracks || []).slice(-1)[0]; - if (ov) S.updateOverlayTrack(ov.id, { font_size: parseInt(size.value || "42", 10), color: color.value, opacity: parseFloat(op.value) }, true); + if (ov) S.updateOverlayTrack(ov.id, payload, true); } closeAddModal(); }; - const cancel = el("button", "btn ghost tiny", "Cancel"); - cancel.onclick = closeAddModal; - actions.append(ok, cancel); - content.append(actions); - box.append(content); - _addModal.append(box); - _addModal.addEventListener("click", (e) => { if (e.target === _addModal) closeAddModal(); }); - document.body.append(_addModal); + foot.append(cancel, ok); ta.focus(); if (!editing) ta.select(); } @@ -1079,7 +1123,7 @@ else openImageOverlayModal(st, track); } - function openAudioTrackModal(st) { + // ── audio lanes (NLE-style, below video) ─────────────────────────────────────── closeAddModal(); const audioAssets = (st.mediaBin || []).filter(isAudioAsset); @@ -1285,7 +1329,7 @@ return lane; } - function overlayBlock(st, track) { + function overlayBlock(st, track, laneId) { const startSec = track.start_sec || 0; const durSec = Math.max(0.25, track.duration_sec || 3); const w = Math.max(durSec * pxPerSec, 40); @@ -1359,13 +1403,26 @@ S.selectOverlay(track.id); const baseLeft = startSec * pxPerSec; const anchors = timelineSnapAnchorsPx(st, st.project); - coalescedDrag(e, (dx) => { + document.querySelectorAll(".tl-overlay-lane").forEach((l) => l.classList.remove("drop-target")); + coalescedDrag(e, (dx, moveEv) => { const snapped = snapPx(baseLeft + dx, anchors, 10); const sec = Math.max(0, snapped / pxPerSec); block.style.left = (sec * pxPerSec) + "px"; - }, (dx) => { + if (moveEv) { + document.querySelectorAll(".tl-overlay-lane").forEach((l) => l.classList.remove("drop-target")); + const laneEl = document.elementFromPoint(moveEv.clientX, moveEv.clientY)?.closest?.(".tl-overlay-lane"); + if (laneEl) laneEl.classList.add("drop-target"); + } + }, (dx, upEv) => { const snapped = snapPx(baseLeft + dx, anchors, 10); S.updateOverlayTrack(track.id, { start_sec: Math.max(0, snapped / pxPerSec) }); + const hit = document.elementFromPoint(upEv.clientX, upEv.clientY); + const laneEl = hit?.closest?.(".tl-overlay-lane"); + const newLaneId = laneEl?.dataset?.laneId; + if (newLaneId && newLaneId !== laneId) { + S.updateOverlayTrack(track.id, { lane_id: newLaneId }, true); + } + document.querySelectorAll(".tl-overlay-lane").forEach((l) => l.classList.remove("drop-target")); }); }); @@ -1405,10 +1462,25 @@ gAud.append(gutterLane(short, title, "audio" + (isOverlay ? " overlay" : isSep ? " sep" : ""))); }); gTracks.append(gAud); - const ovTracks = p.overlay_tracks || []; - if (ovTracks.length) { - gTracks.append(gutterLane("Overlays", "Text and image overlays composited on export", "overlay")); - } + S.ensureOverlayLanes(); + const ovLanes = p.overlay_lanes || []; + const gOv = el("div", "tl-gutter-ov"); + ovLanes.forEach((lane, i) => { + const top = i === ovLanes.length - 1; + const bottom = i === 0; + const label = ovLanes.length > 1 ? `O${i + 1}` : "Overlay"; + const hint = top ? "top layer" : bottom ? "bottom layer" : "middle layer"; + const gl = gutterLane(label, `${lane.label || "Overlay"} · ${hint}`, "overlay" + (top ? " top" : "")); + gl.style.height = OVERLAY_LANE_H + "px"; + gl.style.flex = `0 0 ${OVERLAY_LANE_H}px`; + gOv.append(gl); + }); + const addOv = el("button", "tl-gutter-add-ov", "+"); + addOv.type = "button"; + addOv.title = "Add overlay track (draws above existing tracks)"; + addOv.onclick = (e) => { e.stopPropagation(); S.addOverlayLane(); }; + gOv.append(addOv); + gTracks.append(gOv); gutter.append(gTracks); return gutter; } @@ -1424,16 +1496,44 @@ return wrap; } - function overlayLanes(st, p) { - const tracks = p.overlay_tracks || []; - if (!tracks.length) return null; - const lane = el("div", "tl-overlay-lane"); - lane.style.height = OVERLAY_LANE_H + "px"; + function attachOverlayLaneDrop(laneEl, body, laneId) { + body.addEventListener("dragover", (e) => { + if (e.dataTransfer.types.includes("application/funpack-media")) { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + laneEl.classList.add("drop-target"); + } + }); + body.addEventListener("dragleave", (e) => { + if (!body.contains(e.relatedTarget)) laneEl.classList.remove("drop-target"); + }); + body.addEventListener("drop", (e) => { + laneEl.classList.remove("drop-target"); + const id = e.dataTransfer.getData("application/funpack-media"); + if (!id) return; + e.preventDefault(); + e.stopPropagation(); + S.assignMediaToOverlayLane(laneId, id); + }); + } + + function overlayLaneEl(st, p, lane, laneIndex, laneCount) { + const laneEl = el("div", "tl-overlay-lane" + (laneIndex === laneCount - 1 ? " top" : laneIndex === 0 ? " bottom" : "")); + laneEl.style.height = OVERLAY_LANE_H + "px"; + laneEl.dataset.laneId = lane.id; const body = el("div", "tl-overlay-lane-body"); - tracks.forEach((t) => body.append(overlayBlock(st, t))); - lane.append(body); + const tracks = (p.overlay_tracks || []).filter((t) => t.lane_id === lane.id); + tracks.forEach((t) => body.append(overlayBlock(st, t, lane.id))); + attachOverlayLaneDrop(laneEl, body, lane.id); + laneEl.append(body); + return laneEl; + } + + function overlayLanes(st, p) { + S.ensureOverlayLanes(); + const lanes = p.overlay_lanes || []; const wrap = el("div", "tl-overlay-lanes"); - wrap.append(lane); + lanes.forEach((lane, i) => wrap.append(overlayLaneEl(st, p, lane, i, lanes.length))); return wrap; } @@ -1658,8 +1758,7 @@ if (!lay.length) track.append(el("div", "tl-emptyhint", "No clips yet — add one from the toolbar.")); tracks.append(track); tracks.append(audioLanes(st, p, sceneLay)); - const ovLanes = overlayLanes(st, p); - if (ovLanes) tracks.append(ovLanes); + tracks.append(overlayLanes(st, p)); const phSec = Math.min(window.Player?.getPlayhead() ?? 0, totalSec); tlPhEl = el("div", "tl-playhead"); tlPhEl.style.left = (phSec * pxPerSec) + "px"; tracks.append(tlPhEl); content.append(tracks); diff --git a/movie_editor/frontend/view_bus.js b/movie_editor/frontend/view_bus.js index d567e49c..72e52cfc 100644 --- a/movie_editor/frontend/view_bus.js +++ b/movie_editor/frontend/view_bus.js @@ -19,6 +19,7 @@ ghosts: st.sceneGhosts, audio: p.audio_tracks, overlays: p.overlay_tracks, + ovLanes: p.overlay_lanes, keepAud: p.keep_original_audio, gen: st.gen?.state, labels: (st.ratingLabels || []).length, @@ -44,6 +45,7 @@ sels: st.selectedSceneIds, overlay: st.selectedOverlayId, overlays: p.overlay_tracks, + ovLanes: p.overlay_lanes, scene: sc, proj: sc ? null : { name: p.name, anchor: p.anchor, global_prompt: p.global_prompt, @@ -89,7 +91,6 @@ renders: st.sceneRenders, ghosts: st.sceneGhosts, audio: p.audio_tracks, - overlays: p.overlay_tracks, keepAud: p.keep_original_audio, gen: { state: st.gen?.state, media: st.gen?.media }, mediaPreview: st.mediaPreviewId, @@ -97,6 +98,17 @@ }); } + function fpOverlays(st) { + if (!st.project) return "none"; + return hash({ + pid: st.project.id, + overlays: st.project.overlay_tracks, + ovLanes: st.project.overlay_lanes, + sel: st.selectedOverlayId, + mediaPreview: st.mediaPreviewId, + }); + } + function fpActionbar(st) { return hash({ pid: st.project?.id, @@ -135,8 +147,9 @@ subscribeTimeline: (fn) => subscribeZone("timeline", fn, fpTimeline), subscribeInspector: (fn) => subscribeZone("inspector", fn, fpInspector), subscribePlayer: (fn) => subscribeZone("player", fn, fpPlayer), + subscribeOverlays: (fn) => subscribeZone("overlays", fn, fpOverlays), subscribeActionbar: (fn) => subscribeZone("actionbar", fn, fpActionbar), subscribeMediabrowser: (fn) => subscribeZone("mediabrowser", fn, fpMediabrowser), - fingerprints: { fpTimeline, fpTimelineData, fpTimelineSel, fpInspector, fpPlayer, fpActionbar, fpMediabrowser }, + fingerprints: { fpTimeline, fpTimelineData, fpTimelineSel, fpInspector, fpPlayer, fpOverlays, fpActionbar, fpMediabrowser }, }; })(); diff --git a/movie_editor/server.py b/movie_editor/server.py index c1059ccf..64f26f90 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -21,7 +21,7 @@ except Exception: # pragma: no cover - only available inside ComfyUI from .backend import bridge, builder, config, git_update, media, nodes, pipeline_caps, pipeline_wiring, projects, workflow_import from .backend.nle_effects import zoompan_z_expr -from .backend.nle_overlays import build_overlay_video_filter +from .backend.nle_overlays import build_overlay_video_filter, sort_overlays_for_composite from .backend.timeline import ( Project, build_combined_prompt, @@ -674,7 +674,10 @@ def _append_overlay_filters( image_input_base: int, ) -> None: """Append overlay compositing after [vbase]; sets final [vout].""" - overlays = list(getattr(proj, "overlay_tracks", None) or []) + overlays = sort_overlays_for_composite( + list(getattr(proj, "overlay_tracks", None) or []), + list(getattr(proj, "overlay_lanes", None) or []), + ) if not overlays: parts.append("[vbase]null[vout]") return diff --git a/movie_editor/tests/test_nle_overlays.py b/movie_editor/tests/test_nle_overlays.py index fa4cb379..ed93a18b 100644 --- a/movie_editor/tests/test_nle_overlays.py +++ b/movie_editor/tests/test_nle_overlays.py @@ -18,6 +18,36 @@ def test_build_overlay_text_filter(): assert final.startswith("[vov") +def test_build_overlay_text_with_font(): + parts, _ = build_overlay_video_filter( + "[vbase]", + [{"kind": "text", "text": "Hi", "font_family": "system-ui", "start_sec": 0, "duration_sec": 1}], + canvas_w=768, + canvas_h=512, + image_input_labels=[], + ) + assert "drawtext" in parts[0] + assert "fontfile=" not in parts[0] + + +def test_resolve_fontfile_optional(): + from movie_editor.backend.nle_overlays import _resolve_fontfile + assert _resolve_fontfile("system-ui") is None + assert _resolve_fontfile(None) is None + + +def test_sort_overlays_by_lane(): + from movie_editor.backend.nle_overlays import sort_overlays_for_composite + lanes = [{"id": "a"}, {"id": "b"}, {"id": "c"}] + tracks = [ + {"id": "1", "lane_id": "c", "start_sec": 0}, + {"id": "2", "lane_id": "a", "start_sec": 1}, + {"id": "3", "lane_id": "b", "start_sec": 0}, + ] + out = sort_overlays_for_composite(tracks, lanes) + assert [t["id"] for t in out] == ["2", "3", "1"] + + def test_build_overlay_image_filter(): parts, final = build_overlay_video_filter( "[vbase]",