diff --git a/script.html b/script.html
index d108c45..a69b320 100644
--- a/script.html
+++ b/script.html
@@ -83,9 +83,9 @@ navBar("videos");
var email = requireSession();
var pid = new URLSearchParams(location.search).get("id");
var poll = null, PRESETS = [], PHOTOS = [], DATA = null;
-// Shot length is a property of the engine the project will be filmed on, not
-// a constant. Veo shoots 8s, Omni 1.1 shoots 10s, so every duration and every
-// price derived from "segments" has to ask which engine first.
+// Shot length is a property of the engine the project will be filmed on, not a
+// constant. Veo shoots 8s, Omni 1.1 shoots 10s, so every duration and price
+// derived from "segments" has to ask which engine first.
var MODELS = [];
function modelFor(p) {
var id = (p && p.render_model) || "";
@@ -249,7 +249,9 @@ function render(r) {
? '
Worth a look: ' + s._warnings.map(esc).join(" · ") + '
';
@@ -392,2140 +394,4 @@ function render(r) {
+ 'go straight to filming.'
+ '
Hear it first
'
+ '
'
- + '
Filming costs real money; speaking the '
- + 'words costs about a hundredth of a cent. Run it through in your pet’s voice '
- + 'over the opening stills first, and only film once the timing and the jokes land.
'
- + ''
- + ''
- + ''
- + '
Cutting to the clock speaks the whole piece once, '
- + 'measures where every word actually falls, and moves the shot boundaries to real word '
- + 'endings. It rewrites how the lines are divided — not the words themselves.
'
- + ''
- + ''
- + '
'
- + '
The shots
'
- + s.segments.map(function (x) { return shotHtml(x, false); }).join("")
- + own
- + film;
- } else if (VIEW === 3) {
- body = head + film;
- } else {
- body = head + post + film;
- }
-
- out.innerHTML = cams + stepper(stage, VIEW) + body;
-
- wire(locked);
-}
-
-var PICS = [], SUGGESTED = [];
-
-function renderPics() {
- var el = document.getElementById("pics");
- if (!el) return;
- el.innerHTML = PICS.map(function (p) {
- return '
'
- + ''
- + ''
- + ''
- + '
';
- }).join("");
- [].forEach.call(el.querySelectorAll("[data-rmpic]"), function (b) {
- b.onclick = async function () {
- var r = await api("delete_project_image", { email: email, image_id: b.getAttribute("data-rmpic") });
- if (!r.ok) { showMsg("msg", r.error || "Could not remove that."); return; }
- await loadPics();
- };
- });
-}
-
-async function loadPics() {
- var r = await api("list_project_images", { email: email, project_id: pid });
- PICS = (r.ok && r.images) || [];
- renderPics();
-}
-
-async function addPic(file) {
- hideMsg("msg");
- if (!file || !/^image\//.test(file.type)) { showMsg("msg", "That is not an image."); return; }
- // Asked for up front: a picture with no description cannot be matched to
- // anything, so it would just sit there unused.
- var desc = prompt("What is in this picture? A short description is what lets it be matched to the script.");
- if (!desc || desc.trim().length < 3) { showMsg("msg", "A description is needed to place the picture."); return; }
- var drop = document.getElementById("drop");
- if (drop) drop.textContent = "Uploading…";
- var img;
- try { img = await resizeImage(file, 1600, 0.85); }
- catch (e) { showMsg("msg", e.message || "Could not read that image."); return; }
- var r = await api("add_project_image", { email: email, project_id: pid,
- description: desc.trim(), data_b64: img.b64 });
- if (drop) drop.textContent = "Paste an image, drop one here, or click to choose";
- if (!r.ok) { showMsg("msg", r.error || "That upload did not work."); return; }
- await loadPics();
-}
-
-function bindPics() {
- var drop = document.getElementById("drop");
- if (!drop) return;
- var picker = document.getElementById("picker");
-
- drop.onclick = function () { picker.click(); };
- picker.onchange = function () {
- [].forEach.call(picker.files || [], addPic);
- picker.value = "";
- };
- drop.addEventListener("dragover", function (e) { e.preventDefault(); drop.classList.add("hot"); });
- drop.addEventListener("dragleave", function () { drop.classList.remove("hot"); });
- drop.addEventListener("drop", function (e) {
- e.preventDefault(); drop.classList.remove("hot");
- [].forEach.call((e.dataTransfer && e.dataTransfer.files) || [], addPic);
- });
- // Paste anywhere on the page, which is how people actually do this.
- document.addEventListener("paste", function (e) {
- var items = (e.clipboardData && e.clipboardData.items) || [];
- for (var i = 0; i < items.length; i++) {
- if (items[i].type && items[i].type.indexOf("image") === 0) {
- var f = items[i].getAsFile();
- if (f) { e.preventDefault(); addPic(f); }
- return;
- }
- }
- });
-
- var sg = document.getElementById("suggest");
- if (sg) sg.onclick = async function () {
- hideMsg("msg");
- if (!PICS.length) { showMsg("msg", "Add a picture first."); return; }
- sg.disabled = true; sg.textContent = "Reading the script…";
- var r = await api("suggest_image_placements", { email: email, project_id: pid });
- if (!r.ok) { sg.disabled = false; sg.textContent = "Suggest where they fit"; showMsg("msg", r.error || "Could not suggest."); return; }
- // Reading the script is a model call, far too slow to answer inside one
- // request, so it runs in the background and we wait for it here.
- if (r.pending) {
- document.getElementById("sugnote").textContent = r.status_text || "Reading the script…";
- var waited = 0;
- r = await new Promise(function (done) {
- var t = setInterval(async function () {
- waited += 4;
- var g = await api("suggest_image_placements", { email: email, project_id: pid });
- if (g.ok && !g.pending) { clearInterval(t); done(g); return; }
- if (waited > 150) { clearInterval(t); done({ ok: false, error: "That is taking unusually long." }); }
- }, 4000);
- });
- }
- sg.disabled = false; sg.textContent = "Suggest where they fit";
- document.getElementById("sugnote").textContent = r.status_text || "";
- if (!r.ok) { showMsg("msg", r.error || "Could not suggest."); return; }
- SUGGESTED = r.placements || [];
- var byId = {};
- PICS.forEach(function (p) { byId[p.id] = p; });
- document.getElementById("places").innerHTML = SUGGESTED.length
- ? SUGGESTED.map(function (x) {
- var p = byId[x.image_id] || {};
- return '
';
- }).join("");
- }
-
- var html = "";
- (EDL.cuts || []).forEach(function (c, i) {
- var left = (c.start / dur) * 100, wide = (c.seconds / dur) * 100;
- // Show the actual picture. A plain rectangle says nothing about what is
- // going to be on screen, which is the only thing worth judging here.
- var thumb = c.kind === "video"
- ? ''
- : '';
- html += '
'
- + thumb + '' + c.seconds + 's'
- + '
';
- });
- html += '';
- html += '
';
- for (var t = 0; t <= dur; t += 8) {
- // The end labels are centred on the track edges, so half of each falls
- // outside it. Pin the first left and the last right instead.
- var pos = (t / dur) * 100;
- var style = t === 0 ? 'left:2px;transform:none'
- : (t >= dur ? 'right:2px;left:auto;transform:none'
- : 'left:' + pos.toFixed(2) + '%');
- 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;
-}
-
-// Move the picture to a moment without disturbing the edit.
-function seekTo(t) {
- var v = document.getElementById("pvid");
- if (!v || !EDL) return;
- v.currentTime = Math.max(0, Math.min(t, EDL.duration || 0));
- v.dispatchEvent(new Event("timeupdate"));
-}
-
-function tlBind() {
- var tl = document.getElementById("tl");
- if (!tl) return;
- var drag = null;
-
- var strip = document.getElementById("tlshots");
- if (strip) strip.addEventListener("click", function (e) {
- var s = e.target.closest("[data-seek]");
- if (s) seekTo(parseFloat(s.getAttribute("data-seek")));
- });
-
- function timeAt(clientX) {
- var r = tl.getBoundingClientRect();
- return ((clientX - r.left) / (r.width || 1)) * (EDL.duration || 0);
- }
-
- tl.addEventListener("pointerdown", function (e) {
- var grip = e.target.closest("[data-grip]");
- var block = e.target.closest("[data-i]");
- if (!block) {
- // Empty track: scrub. This is how you get to a particular moment.
- drag = { mode: "seek" };
- seekTo(timeAt(e.clientX));
- tl.setPointerCapture(e.pointerId);
- e.preventDefault();
- 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;
- if (drag.mode === "seek") { seekTo(timeAt(e.clientX)); 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;
- }
-
- function resetPlayButton() {
- var b = document.getElementById("pplay");
- if (b) b.innerHTML = "▶ Preview the cut";
- }
- // Playing to the end used to leave the button reading "Pause" for good.
- v.addEventListener("ended", resetPlayButton);
- v.addEventListener("pause", resetPlayButton);
- v.addEventListener("play", function () {
- var b = document.getElementById("pplay");
- if (b) b.innerHTML = "▮▮ Pause";
- });
-
- 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 tm = document.getElementById("ptime");
- if (tm && EDL) tm.textContent = v.currentTime.toFixed(1) + " / " + (EDL.duration || 0).toFixed(1) + "s";
- 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 () {
- // Starting again from the end should start again, not sit there.
- if (v.ended || v.currentTime >= (EDL.duration || 0) - 0.05) v.currentTime = 0;
- if (v.paused) v.play(); else v.pause();
- };
-}
-
-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");
- };
-}
-
-// Every film keeps the take it replaced. Without this, a re-film to fix one
-// thing could quietly make another worse with no way back.
-async function loadTakes() {
- var el = document.getElementById("takes");
- if (!el) return;
- var r = await api("list_versions", { email: email, project_id: pid });
- if (!r.ok || !(r.versions || []).length) { el.innerHTML = ""; return; }
- el.innerHTML = '
Earlier takes
'
- + '
' + esc(r.status_text || "") + ' '
- + 'Bringing one back keeps the one you are leaving.
';
- [].forEach.call(el.querySelectorAll("[data-restore]"), function (b) {
- b.onclick = async function () {
- if (!confirm("Bring that take back as the current video? The one you are leaving is kept.")) return;
- b.disabled = true; b.textContent = "Restoring…";
- var s = await api("restore_version", { email: email, project_id: pid,
- index: parseInt(b.getAttribute("data-restore"), 10) });
- if (!s.ok) { b.disabled = false; b.textContent = "Use this one"; showMsg("msg", s.error || "Could not restore that."); return; }
- showMsg("msg", s.status_text || "Restored.", "ok");
- load();
- };
- });
-}
-
-// The run-through: each opening still held for exactly as long as its line
-// takes to say. No video is generated, so this costs nothing to watch again.
-var PZ = null, PZTIMER = null;
-
-function pzPaint(lines) {
- var el = document.getElementById("pzout");
- if (!el) return;
- var segs = ((DATA && DATA.script && DATA.script.segments) || []);
- var byIdx = {};
- (lines || []).forEach(function (l) { byIdx[l.index] = l; });
- PZ = segs.map(function (s) {
- var l = byIdx[s.index] || {};
- return { index: s.index, url: l.url || "", seconds: l.seconds || 0,
- over: !!l.over, plate: s.plate_url || "", line: s.line || "" };
- }).filter(function (x) { return x.url; });
-
- if (!PZ.length) { el.innerHTML = ""; return; }
- var total = PZ.reduce(function (a, b) { return a + (b.seconds || 0); }, 0);
- var bought = segs.length * 8;
- // Spoken here, but filmed by Veo, which was asked to over-enunciate so the
- // mouth is readable and therefore runs slower. Measured on a real clip:
- // 17 words took 7.02s filmed against about 5.7s spoken, so ~1.25x.
- var FILMED = 1.25;
- var filmed = total * FILMED;
- var over = PZ.filter(function (x) { return (x.seconds || 0) * FILMED > 8.0; });
-
- el.innerHTML = '
Filmed, this would run about '
- + filmed.toFixed(1) + ' seconds but only ' + bought + ' seconds are being '
- + 'shot. Something will be cut off. Shorten the words, or buy more length.
'
- : (filmed < bought * 0.88
- ? '
Filmed, this would run about '
- + filmed.toFixed(1) + ' seconds across ' + bought + ' seconds of video, '
- + 'leaving roughly ' + ((bought - filmed) / PZ.length).toFixed(1) + ' seconds '
- + 'of silence at the end of every shot. That is where trailing sound effects '
- + 'and echo come from: the model is handed a gap and fills it. More words per '
- + 'shot is the fix.
'
- : ''))
- + (over.length
- ? '
Shot' + (over.length > 1 ? 's ' : ' ')
- + over.map(function (x) { return x.index; }).join(", ")
- + ' will not fit in eight seconds once filmed: '
- + over.map(function (x) { return x.index + " would run " + (x.seconds * FILMED).toFixed(1) + "s"; }).join(", ")
- + '. Trim those lines before filming.
'
- : '')
- + '
'
- + PZ.map(function (x) {
- var f = (x.seconds || 0) * FILMED;
- return '
The whole piece only runs about '
- + filmed.toFixed(1) + ' seconds filmed, but ' + bought + ' seconds are being shot. '
- + 'No arrangement of these words fills the video — it needs more written, or a '
- + 'shorter length.
'
- : '')
- + (filmed > bought + 1.5
- ? '
The whole piece runs about '
- + filmed.toFixed(1) + ' seconds filmed against ' + bought + ' seconds of video, so the '
- + 'end would be cut off. Trim it, or buy more length.
'
- : '');
- }
-
- b.onclick = async function () {
- hideMsg("msg");
- b.disabled = true; b.textContent = "Measuring…";
- var r = await api("align_script", { email: email, project_id: pid, refresh: true });
- if (r.ok && r.pending) {
- var waited = 0;
- r = await new Promise(function (done) {
- var t = setInterval(async function () {
- waited += 4;
- var g = await api("align_script", { email: email, project_id: pid });
- if (g.ok && !g.pending) { clearInterval(t); done(g); return; }
- if (waited > 240) { clearInterval(t); done({ ok: false, error: "That is taking unusually long." }); }
- }, 4000);
- });
- }
- b.disabled = false; b.textContent = "Cut it to the clock";
- if (!r.ok) { showMsg("msg", r.error || "Could not measure that."); return; }
- if (r.note) { showMsg("msg", r.note); return; }
- paint(r);
- showMsg("msg", "Shots re-cut at real word endings.", "ok");
- load();
- };
-
- api("align_script", { email: email, project_id: pid }).then(function (r) {
- if (r && r.ok && !r.pending && r.audio) paint(r);
- });
-}
-
-function bindPreviz() {
- var b = document.getElementById("pzmake");
- if (!b) return;
- var note = document.getElementById("pznote");
-
- async function poll() {
- var waited = 0;
- return await new Promise(function (done) {
- var t = setInterval(async function () {
- waited += 4;
- var g = await api("make_previz", { email: email, project_id: pid });
- if (g.ok && !g.pending) { clearInterval(t); done(g); return; }
- if (g.ok && g.pending && note) note.textContent = g.status_text || "";
- if (waited > 240) { clearInterval(t); done({ ok: false, error: "That is taking unusually long." }); }
- }, 4000);
- });
- }
-
- b.onclick = async function () {
- hideMsg("msg");
- b.disabled = true; b.textContent = "Speaking…";
- var r = await api("make_previz", { email: email, project_id: pid });
- if (r.ok && r.pending) { note.textContent = r.status_text || ""; r = await poll(); }
- b.disabled = false; b.textContent = "Speak it again";
- if (!r.ok) { note.textContent = ""; showMsg("msg", r.error || "Could not speak that."); return; }
- note.textContent = r.status_text || "";
- pzPaint(r.lines || []);
- };
-
- // Already spoken? Show it without asking again.
- api("make_previz", { email: email, project_id: pid }).then(function (r) {
- if (r && r.ok && !r.pending && (r.lines || []).length) {
- b.textContent = "Speak it again";
- note.textContent = r.status_text || "";
- pzPaint(r.lines);
- }
- });
-}
-
-// The picture is the hook; the link carries the video. Never the video itself
-// over MMS - carriers transcode it into mush.
-function bindShare() {
- var b = document.getElementById("sharebtn");
- if (!b) return;
- var note = document.getElementById("sharenote"),
- out = document.getElementById("shareout");
-
- // The file URL ends in /public with no filename. The headers are already
- // correct (video/mp4, filename=final.mp4) so anything that reads them is
- // fine, but clients that sniff the extension from the URL string are not.
- // A filename query is accepted and ignored by the file service.
- // Filled in by share_link, which owns the address. A link straight to the
- // file would be invisible to us; this one is counted server side.
- var SHARE_LINK = "";
- function named(url) { return SHARE_LINK || ""; }
-
- function paint(r) {
- var kb = Math.round((r.bytes || 0) / 1024);
- var link = named(r.video || "");
- var msg = ((DATA && DATA.script && DATA.script.title)
- ? DATA.script.title + " — " : "")
- + ((DATA && DATA.project && DATA.project.pet_name) || "your pet")
- + " has something to say. " + link;
- out.innerHTML = '
'
- + ''
- + '
'
- + '
' + esc(r.size || "") + ' · ' + kb + ' KB'
- + ' · well inside the 600KB most carriers allow, so it will not be '
- + 'downsampled.
';
- }).join("");
- }
-
- if (sb) sb.onclick = async function () {
- sb.disabled = true; sb.textContent = "Measuring…";
- var el = document.getElementById("seamout");
- var r = await api("seam_report", { email: email, project_id: pid });
- if (!r.ok) {
- sb.disabled = false; sb.textContent = "Measure the joins";
- showMsg("msg", r.error || "Could not measure.");
- return;
- }
- // Measuring downloads every shot and searches each join for the best
- // alignment, which is far too slow to answer inside one request.
- if (r.pending) {
- el.innerHTML = '
' + esc(r.status_text || "Measuring…") + '
';
- var waited = 0;
- var t = setInterval(async function () {
- waited += 4;
- var g = await api("seam_report", { email: email, project_id: pid });
- if (g.ok && !g.pending) {
- clearInterval(t);
- sb.disabled = false; sb.textContent = "Measure the joins";
- paintSeams(g);
- return;
- }
- if (waited > 180) {
- clearInterval(t);
- sb.disabled = false; sb.textContent = "Measure the joins";
- showMsg("msg", "Measuring is taking unusually long.");
- }
- }, 4000);
- return;
- }
- sb.disabled = false; sb.textContent = "Measure the joins";
- if (!r.seams || !r.seams.length) {
- el.innerHTML = '
' + esc(r.status_text || "Nothing to measure.") + '