A timeline you can drag, and a preview that needs no render

The cut could be measured and executed but never seen or adjusted. This adds a
track view of where each cutaway sits and a player that switches sources at
each boundary, so the edit can be judged before spending anything on a render.

The audio is deliberately never touched: the talking head plays underneath
throughout and only the picture changes, which is what makes a cutaway hide a
join instead of interrupting the piece.

Dragging a block moves that cutaway; the handle on its right edge changes how
long it runs. Nothing is saved until Save, and Reset returns to the placement
the system chose.
This commit is contained in:
gator 2026-09-07 22:28:34 +00:00
parent d175b3b926
commit f7c8644b7f

View file

@ -33,6 +33,19 @@
.place{border-top:1px solid var(--line);padding:8px 0;font:12.5px/1.6 var(--mono);color:var(--muted)}
.full{background:#0f1117;border:1px solid var(--line);border-radius:12px;padding:18px 20px;margin-bottom:24px;
color:var(--muted);font-size:15.5px;line-height:1.7}
.tl{position:relative;height:56px;background:#0f1117;border:1px solid var(--line);border-radius:8px;
overflow:hidden;margin-top:12px;user-select:none;touch-action:none}
.tlcut{position:absolute;top:6px;bottom:6px;background:#1f6feb;border-radius:5px;cursor:grab;
font:11px/1.5 var(--mono);color:#fff;padding:5px 7px;overflow:hidden;box-sizing:border-box}
.tlcut.moved{background:#8957e5}
.tlcut .grip{position:absolute;top:0;right:0;bottom:0;width:9px;cursor:ew-resize;background:rgba(255,255,255,.28)}
.tlhead{position:absolute;top:0;bottom:0;width:2px;background:var(--accent2);pointer-events:none}
.tlruler{position:absolute;left:0;right:0;bottom:0;height:14px;font:9px/14px var(--mono);color:var(--muted)}
.tlruler span{position:absolute;transform:translateX(-50%)}
.stage{position:relative;width:100%;max-width:300px}
.stage>video{width:100%;aspect-ratio:9/16;border-radius:10px;background:#000;display:block}
.ovl{position:absolute;inset:0;border-radius:10px;overflow:hidden;background:#000;display:none}
.ovl>*{width:100%;height:100%;object-fit:cover;display:block}
</style>
</head>
<body>
@ -255,6 +268,19 @@ function render(r) {
: '')
+ '<p class="foot" style="margin:0 0 12px">Cutaways are laid across the joins between shots, '
+ 'so the picture changes while the voice keeps going and the join is never on screen.</p>'
+ '<div id="edlwrap" style="margin:6px 0 18px">'
+ '<div class="stage" style="margin-bottom:12px">'
+ '<video id="pvid" playsinline preload="metadata"></video>'
+ '<div class="ovl" id="povl"><img id="povlimg" alt="cutaway"><video id="povlvid" muted playsinline></video></div>'
+ '</div>'
+ '<button type="button" class="btn ghost" id="pplay">&#9654; Preview the cut</button>'
+ '<span class="foot" style="margin-left:12px">Nothing is rendered &mdash; this switches the picture as it plays.</span>'
+ '<div class="tl" id="tl"></div>'
+ '<p class="foot" id="tlinfo" style="margin:8px 0 0"></p>'
+ '<div style="margin-top:12px">'
+ '<button type="button" class="btn ghost" id="edlsave" disabled>Save these positions</button>'
+ '<button type="button" class="btn ghost" id="edlreset" style="margin-left:8px">Back to the suggested cut</button>'
+ '</div></div>'
+ '<button type="button" class="btn ghost" id="seams">Measure the joins</button>'
+ '<button type="button" class="btn" id="cut" style="margin-left:10px">'
+ (p.cut_url ? 'Cut it again' : 'Cut the B-roll in') + '</button>'
@ -549,7 +575,152 @@ async function bindFilm(p) {
};
}
// ---------------------------------------------------------------- the cut
// EDL is {duration, cuts:[{shot,start,seconds,kind,src,in,moved}], segments}.
// Only the cut positions are editable; the server composes the rest, so the
// page never has to be trusted about source urls or trim points.
var EDL = null, DIRTY = false;
function tlDraw() {
var tl = document.getElementById("tl");
if (!tl || !EDL) return;
var dur = EDL.duration || 1, w = tl.clientWidth || 1;
var html = "";
(EDL.cuts || []).forEach(function (c, i) {
var left = (c.start / dur) * 100, wide = (c.seconds / dur) * 100;
html += '<div class="tlcut' + (c.moved ? ' moved' : '') + '" data-i="' + i + '" '
+ 'style="left:' + left.toFixed(3) + '%;width:' + wide.toFixed(3) + '%" '
+ 'title="cutaway for shot ' + c.shot + '">'
+ 'shot ' + c.shot + '<div class="grip" data-grip="' + i + '"></div></div>';
});
html += '<div class="tlhead" id="tlhead" style="left:0"></div>';
html += '<div class="tlruler">';
for (var t = 0; t <= dur; t += 8) {
html += '<span style="left:' + ((t / dur) * 100).toFixed(2) + '%">' + t + 's</span>';
}
html += '</div>';
tl.innerHTML = html;
var n = (EDL.cuts || []).length;
document.getElementById("tlinfo").textContent = n
? n + " cutaway" + (n === 1 ? "" : "s") + " over " + dur + " seconds. Drag to move one, or pull its right edge to change how long it runs. The voice underneath never stops."
: "No cutaways on this one, so the talking head runs the whole way.";
document.getElementById("edlsave").disabled = !DIRTY;
}
function tlBind() {
var tl = document.getElementById("tl");
if (!tl) return;
var drag = null;
tl.addEventListener("pointerdown", function (e) {
var grip = e.target.closest("[data-grip]");
var block = e.target.closest("[data-i]");
if (!block) return;
var i = parseInt((grip || block).getAttribute(grip ? "data-grip" : "data-i"), 10);
drag = { i: i, mode: grip ? "len" : "move", x0: e.clientX,
start0: EDL.cuts[i].start, secs0: EDL.cuts[i].seconds };
tl.setPointerCapture(e.pointerId);
e.preventDefault();
});
tl.addEventListener("pointermove", function (e) {
if (!drag) return;
var dur = EDL.duration || 1, w = tl.clientWidth || 1;
var d = ((e.clientX - drag.x0) / w) * dur;
var c = EDL.cuts[drag.i];
if (drag.mode === "move") {
c.start = Math.max(0, Math.min(drag.start0 + d, dur - c.seconds));
} else {
c.seconds = Math.max(0.8, Math.min(drag.secs0 + d, Math.min(20, dur - c.start)));
}
c.start = Math.round(c.start * 20) / 20; // quarter-frame is plenty
c.seconds = Math.round(c.seconds * 20) / 20;
c.moved = true; DIRTY = true;
tlDraw();
});
function end(e) {
if (!drag) return;
drag = null;
try { tl.releasePointerCapture(e.pointerId); } catch (x) {}
}
tl.addEventListener("pointerup", end);
tl.addEventListener("pointercancel", end);
}
// The preview never renders anything. The talking head plays with its audio
// throughout and the picture is covered while a cutaway is due, which is
// exactly what the finished cut does.
function previewBind(p) {
var v = document.getElementById("pvid");
if (!v) return;
if (p.video_url) v.src = p.video_url;
var ovl = document.getElementById("povl"),
oi = document.getElementById("povlimg"),
ov = document.getElementById("povlvid");
function activeCut(t) {
var cs = (EDL && EDL.cuts) || [];
for (var i = 0; i < cs.length; i++) {
if (t >= cs[i].start && t < cs[i].start + cs[i].seconds) return cs[i];
}
return null;
}
var showing = null;
v.addEventListener("timeupdate", function () {
var head = document.getElementById("tlhead");
if (head && EDL) head.style.left = ((v.currentTime / (EDL.duration || 1)) * 100) + "%";
var c = activeCut(v.currentTime);
if (c === showing) return;
showing = c;
if (!c) { ovl.style.display = "none"; ov.pause(); return; }
ovl.style.display = "block";
if (c.kind === "video") {
oi.style.display = "none"; ov.style.display = "block";
if (ov.src !== c.src) ov.src = c.src;
try { ov.currentTime = c["in"] || 0; } catch (x) {}
ov.play().catch(function () {});
} else {
ov.style.display = "none"; ov.pause();
oi.style.display = "block"; oi.src = c.src;
}
});
document.getElementById("pplay").onclick = function () {
if (v.paused) { v.play(); this.innerHTML = "&#9646;&#9646; Pause"; }
else { v.pause(); this.innerHTML = "&#9654; Preview the cut"; }
};
}
async function loadEdl(p) {
var r = await api("preview_edl", { email: email, project_id: pid });
if (!r.ok) return;
EDL = r.edl; DIRTY = false;
tlDraw(); tlBind(); previewBind(p);
document.getElementById("edlsave").onclick = async function () {
var b = this; b.disabled = true; b.textContent = "Saving…";
var s = await api("save_edl_cuts", { email: email, project_id: pid,
cuts: (EDL.cuts || []).map(function (c) {
return { shot: c.shot, start: c.start, seconds: c.seconds }; }) });
b.textContent = "Save these positions";
if (!s.ok) { b.disabled = false; showMsg("msg", s.error || "Those positions were refused."); return; }
hideMsg("msg");
EDL = s.edl; DIRTY = false; tlDraw();
showMsg("msg", s.status_text || "Saved.", "ok");
};
document.getElementById("edlreset").onclick = async function () {
var s = await api("reset_edl_cuts", { email: email, project_id: pid });
if (!s.ok) { showMsg("msg", s.error || "Could not reset."); return; }
EDL = s.edl; DIRTY = false; tlDraw();
showMsg("msg", s.status_text || "Back to the suggested cut.", "ok");
};
}
function bindPost() {
if (document.getElementById("tl")) loadEdl((DATA && DATA.project) || {});
var sb = document.getElementById("seams");
function paintSeams(r) {
var el = document.getElementById("seamout");