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