diff --git a/script.html b/script.html index 6f90f17..d000055 100644 --- a/script.html +++ b/script.html @@ -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} @@ -255,6 +268,19 @@ function render(r) { : '') + '

Cutaways are laid across the joins between shots, ' + 'so the picture changes while the voice keeps going and the join is never on screen.

' + + '
' + + '
' + + '' + + '
cutaway
' + + '
' + + '' + + 'Nothing is rendered — this switches the picture as it plays.' + + '
' + + '

' + + '
' + + '' + + '' + + '
' + '' + '' @@ -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 += '
' + + 'shot ' + c.shot + '
'; + }); + html += '
'; + html += '
'; + for (var t = 0; t <= dur; t += 8) { + html += '' + t + 's'; + } + html += '
'; + 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 = "▮▮ Pause"; } + else { v.pause(); this.innerHTML = "▶ 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");