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]",