diff --git a/script.html b/script.html
index 9e8705b..302add4 100644
--- a/script.html
+++ b/script.html
@@ -271,8 +271,1396 @@ function render(r) {
var warn = (s._warnings && s._warnings.length)
? '
Worth a look: ' + s._warnings.map(esc).join(" · ") + '
' : "";
+ // The price as the server computes it NOW. price_usd is stamped on the row
+ // when the project is created, so it goes stale the moment the markup
+ // constant changes - which it just did, from 5x to actual cost. The stored
+ // value is only a fallback for a response that predates the fresh quote.
+ var price = (r.quote && typeof r.quote.price_usd === 'number')
+ ? r.quote.price_usd : Number(p.price_usd || 0);
+
var head = '
'
- + '
$' + Number(p.price_usd || 0).toFixed(2) + '
'
+ // Built from a code point so no dollar sign sits against a quote in this
+ // file's patch text - that sequence is a substitution token to
+ // patch_git_file and once shipped a broken page (BROWSER_TESTS.md:265).
+ + '
';
+
+ // The whole piece, which is what was actually written - the shots below are
+ // cut from it. Older scripts predate the field, so fall back to the shot
+ // lines rather than showing an empty box.
+ var narrative = s.full_text
+ || (s.segments || []).map(function (x) { return (x.line || "").trim(); }).join(" ").trim();
+ var stale = !!s.shots_stale;
+ var full = !narrative ? "" :
+ '
The whole piece
'
+ + (canEditScript
+ ? '
'
+ + 'This is the script. The shots further down are cut from it, '
+ + 'so change the words here and press Setup shots to divide them again.'
+ + '
'
+ + ''
+ + '
'
+ : '
' + esc(narrative) + '
');
+
+ // Cutting the narrative into shots. Explicit, because it speaks the whole
+ // piece through ElevenLabs to find real word endings - about a cent, fifteen
+ // seconds, and it rewrites where every shot begins and ends. This used to
+ // happen unasked on page load.
+ // Two jobs that used to be one button, separated because they are not the
+ // same thing and do not cost the same thing.
+ //
+ // Setup shots divides the words. Local Python at the engine's own
+ // measured speaking rate. Free, instant, no network call.
+ // Script audio reads it aloud so you can hear it. ElevenLabs, about 16c
+ // for an 80s piece. Measures; moves nothing.
+ var vp = r.voice || {};
+ var voiceLine = vp.formula
+ ? '
The voice: '
+ + esc(vp.formula) + '. In the finished film: ' + esc(vp.film_voice || '') + '.
'
+ : '';
+ var setup = !canEditScript ? '' :
+ '
Setup shots
'
+ + voiceLine
+ + '
Divides the piece into shots at '
+ + esc(vp.engine || 'the engine') + '’s own measured speaking rate. Runs here in '
+ + 'code — nothing is sent to a language model and nothing is spoken, so it is '
+ + 'instant and free. Press it whenever the words change.
'
+ + ''
+ + ''
+ + ''
+
+ + '
Script audio
'
+ + '
'
+ + (vp.readthrough_is_standin
+ ? 'Reads the whole piece aloud so you can hear the timing and the jokes. '
+ + 'This is ElevenLabs standing in. ' + esc(vp.engine || 'The engine')
+ + ' performs the real thing itself from the written description above and will '
+ + 'phrase it differently — there is no way to hear its real voice without '
+ + 'paying to film it.'
+ : 'Reads the whole piece aloud in the same voice that will be dubbed onto the film.')
+ // The ElevenLabs rate is 22 dollars per 131,000 characters. A measured
+ // run came back at 15.89 cents for 946 characters, so quote it from what
+ // is actually on screen. Deliberately spelled out: a dollar sign before a
+ // quote is a substitution token to patch_git_file and once shipped a broken
+ // page - BROWSER_TESTS.md:265 - so none appear in this file's patch text.
+ + ' Costs about ' + Math.max(1, Math.round(narrative.length * 22 / 1310))
+ + ' cents at this length, and changes none of the words.
The words above have changed since these shots were cut, so this is '
+ + 'the previous division. Press Setup shots to cut the new words.
' : '';
+
+ var cams = '';
+
+ // Post production only exists once there is footage to cut.
+ // The owner's own photographs, scoped to this video only. Described once,
+ // then matched to shots by reading the script rather than hand-assigned.
+ var own = locked ? '' :
+ '
Your own pictures
'
+ + '
'
+ + '
Paste or drop photographs here. Describe each one, '
+ + 'and they can be cut in where the script talks about them, or laid across a join to cover it.
'
+ + '
Paste an image, drop one here, or click to choose
'
+ + ''
+ + ''
+ + '
'
+ + ''
+ + '
'
+ + ''
+ + '
';
+
+ // Filming, from the browser. This was the last step that needed an operator.
+ var film = '';
+ if (p.status === 'approved' || p.status === 'queued_to_film'
+ || p.status === 'filming' || p.status === 'film_failed'
+ || p.status === 'ready') {
+ var busy = (p.status === 'queued_to_film' || p.status === 'filming');
+ // The opening frame is what carries the animal's likeness into the video.
+ // Without one the model works from the written description and invents a
+ // plausible stranger, so say so before any money is spent.
+ var noFrame = (s.segments || []).filter(function (x) {
+ return x.source !== 'previous' && !x.plate_url;
+ }).map(function (x) { return x.index; });
+ film = '
Film it
'
+ + (noFrame.length && !busy
+ ? '
Shot' + (noFrame.length > 1 ? 's ' : ' ') + noFrame.join(', ')
+ + ' still ' + (noFrame.length > 1 ? 'have' : 'has') + ' no opening frame. '
+ + 'That frame is what carries your pet’s actual likeness — their collar, '
+ + 'their markings, their eyes — into the video. '
+ + 'One will be drawn automatically before filming, which adds about half a minute '
+ + 'per shot. Make them yourself above if you want to see them first.
'
+ : '')
+ + '
'
+ + '
' + videoSecondsOf(p) + 's
'
+ + '
of your time credit
'
+ + '
checking your balance…
'
+ + (p.status === 'film_failed'
+ ? '
That did not finish: ' + esc(p.film_note || 'unknown reason')
+ + '. Your seconds were returned.
'
+ : '')
+ + (busy
+ ? '
' + esc(p.film_note || 'Queued…') + '
'
+ : ''
+ + 'About a minute a shot. '
+ + 'Anything still missing — an opening still, a cutaway — is made '
+ + 'automatically first, so nothing above is required.'
+ + (p.status === 'ready'
+ ? '
This has already been filmed. '
+ + 'Filming again spends the seconds again and replaces the video.
'
+ : ''))
+ + '
';
+ }
+
+ var post = '';
+ if (p.status === 'ready' || p.cut_url) {
+ post = '
The cut
'
+ + (p.cut_url
+ ? ''
+ : '')
+ + '
Cutaways are laid across the joins between shots, '
+ + 'so the picture changes while the voice keeps going and the join is never on screen.
'
+ + ''
+ + '
'
+ + '
'
+ + ''
+ + '
'
+ + '
'
+ + ''
+ + '0.0 / 0.0s'
+ + 'Nothing is rendered — this switches the picture as it plays.'
+ + ''
+ + ''
+ + ''
+ + '
'
+ + ''
+ + ''
+ + '
'
+ + ''
+ + ''
+ + ''
+ + ''
+ + '
'
+ + '
Send it to someone
'
+ + '
'
+ + '
Texting the video itself puts it through '
+ + 'carrier transcoding and it arrives looking worse than it left. Send the picture as '
+ + 'the hook and let the link carry the video at full quality.
'
+ + ''
+ + ''
+ + ''
+ + '
';
+ }
+
+ var actions = !canEditScript
+ ? '
These words are settled. '
+ + 'Everything after this arranges how they are shot, so the writing is left alone.
'
+ : '
'
+ + '
Change the words
'
+ + '
'
+ + 'Be specific. “Too polite, he would be smugger” beats “make it funnier”. '
+ + 'To change a word yourself, edit the piece above and press Setup shots.'
+ + '
'
+ + (WRITERS.length
+ ? '
'
+ + 'Different models are different comedians. The same note sent '
+ + 'to each one comes back with different jokes.'
+ + '
'
+ : '')
+ + ''
+ + ''
+ // What the writing has cost so far. Rewrites are no longer capped at
+ // three - they are billed onto the video at cost, with no markup - so
+ // the number that matters is the running total, shown where the decision
+ // to spend another one is actually made.
+ + (Number(r.script_usd || 0) > 0
+ ? 'Writing so far: '
+ + Math.round(Number(r.script_usd) * 100) + 'c'
+ + (Number(r.last_write_usd || 0) > 0
+ ? ' (last rewrite ' + Math.max(1, Math.round(Number(r.last_write_usd) * 100)) + 'c)'
+ : '')
+ + ' — added to the bill at cost, not marked up.'
+ : 'Rewrites are added to the '
+ + 'bill at what they cost, not marked up.')
+ // "What happens if I approve?" was a fair question with no answer on the
+ // page. Two lines, next to the button that does it.
+ + '
Approve and lock it settles '
+ + 'the writing: the words, the shot division and the running order stop being editable, '
+ + 'and the project moves on to setting up each shot — the scene, the camera and '
+ + 'the opening frame — and then to filming. Nothing is filmed and nothing is '
+ + 'charged for filming at that point. It is the point after which rewriting means '
+ + 'starting a new video.
'
+ + '
';
+
+ var body;
+ if (VIEW === 1) {
+ // The narrative reads first, then what changes it, then what it is cut
+ // into. The rewrite box used to sit at the very bottom, under eight shot
+ // cards, which is a long way from the words it rewrites.
+ body = head + warn + full
+ + actions
+ + setup
+ + '
The words, shot by shot
'
+ + staleNote
+ + s.segments.map(linesHtml).join("");
+ } else if (VIEW === 2) {
+ body = head
+ + '
How it will be laid out. '
+ + 'Each shot has a setting, a camera move and an opening frame that carries your '
+ + 'pet’s likeness. Cutaways sit across the joins so the cut is never on screen. '
+ + 'All of it has been chosen for you — change anything you disagree with, or '
+ + 'go straight to filming.
'
+ + '
Hear it first
'
+ + '
'
+ // "A hundredth of a cent" was wrong by three orders of magnitude. A
+ // measured run of an 80s script came back at 15.89 cents - the
+ // ElevenLabs rate is 22 dollars per 131,000 characters.
+ + '
Filming costs real money; speaking the '
+ + 'words costs a few cents. Run it through in your pet’s voice '
+ + 'over the opening stills first, and only film once the timing and the jokes land.
'
+ + ''
+ + ''
+ + ''
+ // It no longer moves anything. Setup shots does the division, locally
+ // and for nothing, at the performing engine's own measured rate. This
+ // reads the result back so you can hear it and check the timing.
+ + '
This speaks the whole piece once and measures '
+ + 'where every word actually falls, so you can check the timing against the shots. '
+ + 'It does not move the shot boundaries — Setup shots, on the words page, does '
+ + 'that in code at the engine’s own measured rate.
'
+ + ''
+ + ''
+ + '
'
+ + '
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, reachOf(p.status)) + 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 '
';
+
+ var img = document.getElementById("pzimg"),
+ aud = document.getElementById("pzaud"),
+ btn = document.getElementById("pzplay"),
+ where = document.getElementById("pzwhere");
+
+ function stop() {
+ aud.pause();
+ if (PZTIMER) { clearTimeout(PZTIMER); PZTIMER = null; }
+ btn.innerHTML = "▶ Play the run-through";
+ }
+
+ function playFrom(i) {
+ if (i >= PZ.length) { stop(); return; }
+ var x = PZ[i];
+ img.src = x.plate || "";
+ where.textContent = "shot " + x.index + " of " + PZ.length + " · " + x.seconds.toFixed(1) + "s";
+ aud.src = x.url;
+ aud.onended = function () { playFrom(i + 1); };
+ aud.play().catch(function () { stop(); });
+ }
+
+ btn.onclick = function () {
+ if (!aud.paused) { stop(); return; }
+ btn.innerHTML = "▮▮ Stop";
+ playFrom(0);
+ };
+}
+
+function bindAlign() {
+ var b = document.getElementById("alignbtn");
+ if (!b) return;
+ var out = document.getElementById("alignout");
+
+ function paint(r) {
+ // The shot length belongs to the engine. Hardcoding 8 reported every Omni
+ // shot 2s short and flagged well-fitting lines as overruns.
+ var each = shotSecondsOf((DATA && DATA.project) || {});
+ var bought = ((DATA && DATA.script && DATA.script.segments) || []).length * each;
+ var filmed = r.filmed || 0;
+ out.innerHTML = '
Measured: '
+ + (r.spoken || 0).toFixed(1) + 's spoken, about ' + filmed.toFixed(1)
+ + 's filmed, against ' + bought + 's of shots.
'
+ + (r.audio ? '' : '')
+ + (r.shots || []).map(function (s) {
+ // Under the clock is FINE. The engine finishes the line and waits -
+ // measured, Omni "barely stretches, it finishes early". Only an
+ // OVERRUN is a problem, because that is cut off mid-word.
+ //
+ // This used to flag anything more than 1.2s from the clip length in
+ // either direction, so a 7.0s line in a 10s shot was marked exactly
+ // like a genuine 11.4s overrun, and sent people rewriting lines that
+ // fitted perfectly well.
+ var over = (s.filmed || 0) > each + 0.3;
+ 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();
+ };
+
+ // Read the cached measurement only. This used to call without `refresh`,
+ // which still STARTS a run when nothing is cached - so merely opening the
+ // page spoke the script and moved every shot boundary, unasked and charged.
+ if (DATA && DATA.project && DATA.project.align_url) {
+ 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.") + '
';
+ }).join("");
+ };
+
+ var cb = document.getElementById("cut");
+ if (cb) cb.onclick = async function () {
+ hideMsg("msg");
+ cb.disabled = true; cb.textContent = "Cutting…";
+ var r = await api("render_cut", { email: email, project_id: pid });
+ if (!r.ok) { cb.disabled = false; cb.textContent = "Cut the B-roll in"; showMsg("msg", r.error || "Could not cut."); return; }
+ var waited = 0;
+ var t = setInterval(async function () {
+ waited += 5;
+ var g = await api("get_project", { email: email, project_id: pid });
+ if (g.ok && g.project && g.project.cut_url) { clearInterval(t); load(); return; }
+ if (waited > 300) {
+ clearInterval(t);
+ cb.disabled = false; cb.textContent = "Cut the B-roll in";
+ showMsg("msg", "That is taking unusually long. Try again shortly.");
+ }
+ }, 5000);
+ };
+}
+
+function bindBottom() {
+ // Saving the narrative does NOT re-cut the shots: cutting costs money and
+ // takes fifteen seconds, so it is a button of its own.
+ var sn = document.getElementById("savenarr");
+ if (sn) sn.onclick = async function () {
+ hideMsg("msg");
+ var txt = (document.getElementById("narr") || {}).value || "";
+ if (txt.trim().length < 10) { showMsg("msg", "There is no script there to save."); return; }
+ sn.disabled = true; sn.textContent = "Saving…";
+ var r = await api("set_narrative", { email: email, project_id: pid, text: txt });
+ sn.disabled = false; sn.textContent = "Save the words";
+ if (!r.ok) { showMsg("msg", r.error || "That did not save."); return; }
+ showMsg("msg", r.status_text || "Saved.", "ok");
+ load();
+ };
+
+ // Flush an unsaved edit before either button acts, so both work on the
+ // words that are on screen rather than the words on file.
+ async function flushNarrative() {
+ var box = document.getElementById("narr");
+ if (box && box.value.trim().length >= 10) {
+ await api("set_narrative", { email: email, project_id: pid, text: box.value });
+ }
+ }
+
+ var su = document.getElementById("setupshots");
+ if (su) su.onclick = async function () {
+ hideMsg("msg");
+ su.disabled = true; su.textContent = "Dividing…";
+ await flushNarrative();
+ var r = await api("setup_shots", { email: email, project_id: pid });
+ su.disabled = false; su.textContent = "Setup shots";
+ if (!r.ok) { showMsg("msg", r.error || "Could not divide that."); return; }
+ // Notes are the splitter telling you the script is the wrong length for
+ // the shots bought. That is a writing problem and only you can fix it, so
+ // it is shown rather than swallowed.
+ var out = document.getElementById("setupout");
+ if (out) {
+ out.innerHTML = (r.notes || []).map(function (t) {
+ return '
' + esc(t) + '
'; }).join("");
+ }
+ showMsg("msg", r.status_text || "Divided into shots.", "ok");
+ load();
+ };
+
+ // Reading it aloud. Separate button, separate money, and it moves nothing.
+ var ra = document.getElementById("readaloud");
+ if (ra) ra.onclick = async function () {
+ hideMsg("msg");
+ ra.disabled = true; ra.textContent = "Speaking it…";
+ await flushNarrative();
+ 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);
+ });
+ }
+ ra.disabled = false; ra.textContent = "Read it aloud";
+ if (!r.ok) { showMsg("msg", r.error || "Could not read that."); return; }
+ if (r.note) { showMsg("msg", r.note); return; }
+ // THE PLAYER. This is what was missing: the audio was made and uploaded
+ // and then rendered into an element that only exists on the Layout view,
+ // so pressing the button produced fifteen seconds of nothing.
+ var out = document.getElementById("readout");
+ if (out) {
+ out.innerHTML =
+ (r.audio ? '' : '')
+ + '
' + (r.spoken || 0).toFixed(1)
+ + 's spoken by ElevenLabs. The shot division above is unchanged — this is a '
+ + 'read-through, not a re-cut.