Add multi-lane overlay tracks with drag-drop and layer ordering.
Includes font picker, redesigned overlay modals, and compositing by track height on export.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {}),
|
||||
|
||||
@@ -115,6 +115,7 @@
|
||||
<script src="mediabrowser.js"></script>
|
||||
<script src="log.js"></script>
|
||||
<script src="player.js"></script>
|
||||
<script src="overlay_ui.js"></script>
|
||||
<script src="overlay_preview.js"></script>
|
||||
<script src="sampler_panel.js"></script>
|
||||
<script src="engine_settings.js"></script>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
+206
-107
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]",
|
||||
|
||||
Reference in New Issue
Block a user