The script

Read it through, then direct it. Every shot can have its own setting and camera move.

Loading…

+ Number(price).toFixed(2) + '' + '
' + videoSecondsOf(p) + ' seconds · ' + (p.segments || 4) + ' shots' + (modelFor(p) ? ' · ' + esc(modelFor(p).label) : '') + '
' + '
' + (canEditScript ? (used === 1 ? "1 rewrite" : used + " rewrites") + " so far" : "Words approved") + '
'; // 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.

' + '' + '' + '
' + '
'; var staleNote = (stale && canEditScript) ? '
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 = '' + PRESETS.map(function (c) { return ''; // 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.

' + '' + '' + '' + '' + '
' + '
' + '
' + '

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 '
' + '' + esc(p.description || ' + '' + '' + '
'; }).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 '
shot ' + x.index + ' · ' + esc((p.description || "").slice(0, 60)) + ' — ' + esc(x.why || "") + '
'; }).join("") + '' : '

No picture fitted any shot well enough to use.

'; var ap = document.getElementById("applyp"); if (ap) ap.onclick = async function () { ap.disabled = true; ap.textContent = "Applying…"; var res = await api("apply_image_placements", { email: email, project_id: pid, placements: SUGGESTED }); if (!res.ok) { ap.disabled = false; ap.textContent = "Use these"; showMsg("msg", res.error || "Could not apply."); return; } showMsg("msg", res.status_text || "Placed.", "ok"); load(); }; }; loadPics(); } function wire(locked) { [].forEach.call(document.querySelectorAll("[data-stage]"), function (b) { b.onclick = function () { var want = parseInt(b.getAttribute("data-stage"), 10); if (want === VIEW) return; VIEW = want; hideMsg("msg"); render(DATA); window.scrollTo({ top: 0, behavior: "smooth" }); }; }); bindPics(); bindPreviz(); bindAlign(); bindFilm((DATA && DATA.project) || {}); bindPost(); if (locked) { bindBottom(); return; } // Save edits when the field loses focus, so typing is never interrupted. [].forEach.call(document.querySelectorAll("[data-scene],[data-cam],[data-photo]"), function (el) { var evt = el.tagName === "SELECT" ? "change" : "blur"; el.addEventListener(evt, async function () { var idx = el.getAttribute("data-scene") || el.getAttribute("data-cam") || el.getAttribute("data-photo"); var body = { email: email, project_id: pid, index: parseInt(idx, 10) }; if (el.hasAttribute("data-scene")) body.scene = el.value; if (el.hasAttribute("data-cam")) body.camera = el.value; if (el.hasAttribute("data-photo")) body.photo_id = el.value; var r = await api("update_shot", body); if (!r.ok) showMsg("msg", r.error || "That change did not save."); else hideMsg("msg"); }); }); // Switching source changes which fields apply, so redraw the card after. [].forEach.call(document.querySelectorAll("[data-srcgrp]"), function (g) { g.addEventListener("click", async function (e) { var c = e.target.closest("[data-src]"); if (!c) return; var idx = parseInt(g.getAttribute("data-srcgrp"), 10); var r = await api("update_shot", { email: email, project_id: pid, index: idx, source: c.getAttribute("data-src") }); if (!r.ok) { showMsg("msg", r.error || "Could not change that."); return; } hideMsg("msg"); load(); }); }); // Throwing a cutaway away. The picture is drawn from the writer's guess at // what the line is about, so a wrong one is common and there was no way to // get rid of it - only to redraw and hope. [].forEach.call(document.querySelectorAll("[data-brollrm]"), function (b) { b.addEventListener("click", async function () { hideMsg("msg"); var idx = parseInt(b.getAttribute("data-brollrm"), 10); b.disabled = true; b.textContent = "Removing…"; var r = await api("update_shot", { email: email, project_id: pid, index: idx, broll_clear: true }); if (!r.ok) { b.disabled = false; b.textContent = "Remove cutaway"; showMsg("msg", r.error || "Could not remove that."); return; } showMsg("msg", "Cutaway removed from shot " + idx + ".", "ok"); load(); }); }); [].forEach.call(document.querySelectorAll("[data-broll]"), function (b) { b.addEventListener("click", async function () { hideMsg("msg"); var idx = parseInt(b.getAttribute("data-broll"), 10); b.disabled = true; b.textContent = "Making…"; var r = await api("make_broll", { email: email, project_id: pid, index: idx }); if (!r.ok) { b.disabled = false; b.textContent = "Make cutaway"; showMsg("msg", r.error || "Could not make that."); return; } var waited = 0; var t = setInterval(async function () { waited += 5; var g = await api("get_project", { email: email, project_id: pid }); var seg = (g.ok && g.script && g.script.segments || []).filter(function (x) { return parseInt(x.index, 10) === idx; })[0]; if (seg && seg.broll_url) { clearInterval(t); load(); return; } if (waited > 180) { clearInterval(t); b.disabled = false; b.textContent = "Make cutaway"; showMsg("msg", "That cutaway is taking unusually long."); } }, 5000); }); }); [].forEach.call(document.querySelectorAll("[data-make]"), function (b) { b.addEventListener("click", async function () { hideMsg("msg"); var idx = parseInt(b.getAttribute("data-make"), 10); // Flush any pending edits in this card before drawing from them. var card = b.closest(".shot"); var sc = card.querySelector("[data-scene]"), cm = card.querySelector("[data-cam]"), ph = card.querySelector("[data-photo]"); await api("update_shot", { email: email, project_id: pid, index: idx, scene: sc ? sc.value : undefined, camera: cm ? cm.value : undefined, photo_id: ph ? ph.value : undefined }); b.disabled = true; b.textContent = "Drawing…"; var r = await api("make_shot_image", { email: email, project_id: pid, index: idx }); if (!r.ok) { b.disabled = false; b.textContent = "Try again"; showMsg("msg", r.error || "Could not make that frame."); return; } // Drawing takes about half a minute and runs on the server, so the call // above returns straight away. Watch the shot until the frame appears. var waited = 0; var tick = setInterval(async function () { waited += 4; var g = await api("get_project", { email: email, project_id: pid }); var seg = (g.ok && g.script && g.script.segments || []).filter(function (x) { return parseInt(x.index, 10) === idx; })[0]; if (seg && seg.draw_error) { clearInterval(tick); b.disabled = false; b.textContent = "Try again"; showMsg("msg", "That frame did not draw: " + seg.draw_error); return; } if (seg && seg.plate_url) { clearInterval(tick); load(); return; } if (waited > 120) { clearInterval(tick); b.disabled = false; b.textContent = "Try again"; showMsg("msg", "That frame is taking unusually long. Try again in a moment."); } }, 4000); }); }); bindBottom(); } async function bindFilm(p) { var bal = document.getElementById("balsec"); if (bal) { var b = await api("get_balance", { email: email }); var have = (b.ok && b.balance_seconds) || 0; var need = videoSecondsOf(p); bal.textContent = have + "s left" + (have < need ? " — not enough" : ""); if (have < need) bal.style.color = "var(--bad)"; } // While it films, keep the page honest about where it has got to. if (p.status === "queued_to_film" || p.status === "filming") { var t = setInterval(async function () { var g = await api("get_project", { email: email, project_id: pid }); if (!g.ok) return; var st = g.project.status; var note = document.getElementById("filmnote"); if (note) note.textContent = g.project.film_note || st; if (st === "ready" || st === "film_failed") { clearInterval(t); load(); } }, 8000); } var fb = document.getElementById("film"); if (fb) fb.onclick = async function () { hideMsg("msg"); fb.disabled = true; fb.textContent = "Starting…"; var r = await api("request_render", { email: email, project_id: pid }); if (!r.ok) { fb.disabled = false; fb.textContent = "Film it"; showMsg("msg", r.error || "Could not start filming."); return; } showMsg("msg", r.status_text || "Filming.", "ok"); load(); }; } // ---------------------------------------------------------------- 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; // Above the bar: where each shot begins, with the frame it opens on. The end // of a shot is always the start of the next one, so only starts are marked. var strip = document.getElementById("tlshots"); if (strip) { var segs = ((DATA && DATA.script && DATA.script.segments) || []); strip.innerHTML = segs.map(function (s, k) { var at = k * 8, wide = (8 / dur) * 100; return '
' + 'shot ' + s.index + (s.plate_url ? 'shot ' + s.index + ' opening frame' : '') + '
'; }).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.

' + '
' + r.versions.map(function (v, i) { return '
' + '' + '
' + esc(when(v.at) || "earlier") + (v.voice_name ? ' · ' + esc(v.voice_name) : '') + (v.cost_usd ? ' · $' + Number(v.cost_usd).toFixed(2) : '') + '
' + (v.note ? '
' + esc(v.note) + '
' : '') + '' + '
'; }).join("") + '
'; [].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 = '
' + 'opening still' + '
' + '' + '' + '' + PZ.length + ' lines · ' + total.toFixed(1) + 's spoken, about ' + filmed.toFixed(1) + 's once filmed, against ' + bought + 's of shots' + (filmed > bought ? '
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 '
' + 'shot ' + x.index + ' · ' + x.seconds.toFixed(1) + 's spoken, ~' + f.toFixed(1) + 's filmed' + (f > 8 ? ' over' : '') + ' · ' + esc(x.line.slice(0, 60)) + '
'; }).join("") + '
'; 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 '
' + 'shot ' + s.index + ' · ' + (s.start || 0).toFixed(1) + '-' + (s.end || 0).toFixed(1) + 's spoken · ~' + (s.filmed || 0).toFixed(1) + 's filmed' + (over ? ' runs long' : '') + '
' + esc(s.line || "") + '
'; }).join("") + (filmed < bought - 1.5 ? '
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 = '
' + 'share picture' + '
' + '

' + esc(r.size || "") + ' · ' + kb + ' KB' + ' · well inside the 600KB most carriers allow, so it will not be ' + 'downsampled.

' + '' + '' + '' + '' + 'Download the picture' + '

' + esc(link) + '

' + '
'; api("share_link", { email: email, project_id: pid }).then(function (s) { if (!(s && s.ok)) return; SHARE_LINK = s.url || ""; var el = document.getElementById("viewcount"); if (el) { el.innerHTML = '
' + esc(SHARE_LINK) + '
Opened ' + (s.views || 0) + ' time' + ((s.views === 1) ? '' : 's') + '.'; } var t = document.getElementById("sharemsg"); if (t && SHARE_LINK) t.value = t.value.replace(/https?:\/\/\S+/, SHARE_LINK); }); var sl = document.getElementById("sharelink"); if (sl) sl.onclick = async function () { var title = (DATA && DATA.script && DATA.script.title) || "A video"; // The native sheet is the shortest path into a text message on a phone. if (navigator.share) { try { await navigator.share({ title: title, text: msg, url: link }); return; } catch (e) { if (e && e.name === "AbortError") return; } } try { await navigator.clipboard.writeText(link); sl.textContent = "Link copied"; setTimeout(function () { sl.textContent = "Share the video"; }, 1600); } catch (e) { showMsg("msg", "Copy this: " + link); } }; var c = document.getElementById("sharecopy"); if (c) c.onclick = function () { var t = document.getElementById("sharemsg"); t.select(); navigator.clipboard.writeText(t.value).then(function () { c.textContent = "Copied"; setTimeout(function () { c.textContent = "Copy the message"; }, 1600); }).catch(function () { c.textContent = "Select it and copy"; }); }; } b.onclick = async function () { hideMsg("msg"); b.disabled = true; b.textContent = "Choosing a frame…"; var r = await api("share_image", { 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("share_image", { email: email, project_id: pid }); if (g.ok && !g.pending) { clearInterval(t); done(g); return; } if (waited > 180) { clearInterval(t); done({ ok: false, error: "That is taking unusually long." }); } }, 4000); }); } b.disabled = false; b.textContent = "Pick another frame"; if (!r.ok) { showMsg("msg", r.error || "Could not make that picture."); return; } if (r.note) { showMsg("msg", r.note); return; } note.textContent = r.status_text || ""; paint(r); }; api("share_image", { email: email, project_id: pid }).then(function (r) { if (r && r.ok && !r.pending && r.image) { b.textContent = "Pick another frame"; note.textContent = r.status_text || ""; paint(r); } }); } function bindPost() { loadTakes(); bindShare(); // The editing bay is opt-in. Most people want to watch the thing and leave. var eo = document.getElementById("edlopen"); if (eo) eo.onclick = function () { var w = document.getElementById("edlwrap"); var open = w.style.display === "none"; w.style.display = open ? "" : "none"; this.textContent = open ? "Close the video editor" : "Open the video editor"; if (open) { if (!EDL) loadEdl((DATA && DATA.project) || {}); else tlDraw(); w.scrollIntoView({ behavior: "smooth", block: "start" }); } }; var sb = document.getElementById("seams"); function paintSeams(r) { var el = document.getElementById("seamout"); if (!el) return; el.innerHTML = '

' + esc(r.status_text || "") + '

' + (r.seams || []).map(function (x) { var col = x.verdict === "smooth" ? "var(--good)" : (x.verdict === "visible" ? "var(--accent2)" : "var(--bad)"); return '
' + 'shot ' + x.after_shot + ' → ' + x.before_shot + ' at ' + x.at_seconds + 's · ' + 'shift ' + x.shift_x + ',' + x.shift_y + ' · drift ' + x.drift + ' · ' + '' + esc(x.verdict) + '' + (x.recommend_broll ? ' · worth a cutaway' : '') + '
'; }).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.") + '

'; return; } el.innerHTML = '

' + esc(r.status_text || "") + '

' + r.seams.map(function (x) { var col = x.verdict === "smooth" ? "var(--good)" : (x.verdict === "visible" ? "var(--accent2)" : "var(--bad)"); return '
' + 'shot ' + x.after_shot + ' → ' + x.before_shot + ' at ' + x.at_seconds + 's · ' + 'shift ' + x.shift_x + ',' + x.shift_y + ' · drift ' + x.drift + ' · ' + '' + esc(x.verdict) + '' + (x.recommend_broll ? ' · worth a cutaway' : '') + '
'; }).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.

' + (r.shots || []).map(function (s) { return '
' + 'Shot ' + s.index + ' · ' + (s.start || 0).toFixed(1) + 's to ' + (s.end || 0).toFixed(1) + 's
'; }).join(""); } showMsg("msg", "Read through in " + (r.spoken || 0).toFixed(1) + " seconds.", "ok"); }; var rv = document.getElementById("revise"); if (rv) rv.onclick = async function () { hideMsg("msg"); var fb = document.getElementById("fb").value.trim(); if (fb.length < 5) { showMsg("msg", "Tell us what you would like changed."); return; } rv.disabled = true; rv.textContent = "Sending…"; var wsel = document.getElementById("writer"); var res = await api("revise_script", { email: email, project_id: pid, feedback: fb, writer: (wsel && wsel.value) || "" }); if (!res.ok) { rv.disabled = false; rv.textContent = "Rewrite the words"; showMsg("msg", res.error || "That did not go through."); return; } showMsg("msg", "Rewriting now…", "ok"); load(); }; var ap = document.getElementById("approve"); if (ap) ap.onclick = async function () { hideMsg("msg"); ap.disabled = true; ap.textContent = "Approving…"; var res = await api("approve_script", { email: email, project_id: pid }); if (!res.ok) { ap.disabled = false; ap.textContent = "Approve and lock it"; showMsg("msg", res.error || "That did not go through."); return; } showMsg("msg", res.status_text || "Approved.", "ok"); load(); }; } async function load() { if (!pid || !email) return; if (!PRESETS.length) { var c = await api("camera_presets", {}); if (c.ok) { PRESETS = c.presets || []; MODELS = c.models || []; WRITERS = c.writers || []; } } var r = await api("get_project", { email: email, project_id: pid }); if (!r.ok) { showMsg("msg", r.error || "Could not load that script."); return; } if (!PHOTOS.length && r.project && r.project.pet_id) { var ph = await api("list_photos", { email: email, pet_id: r.project.pet_id }); if (ph.ok) PHOTOS = ph.photos || []; } render(r); } load();