paytolaugh/script.html
gator 4d553585d6 Cut the shots where the clock says, not where a word count guesses
"I am not" and "extraordinarily unimpressed" are both three words and nowhere
near the same length, so any word budget is a guess about something we can
measure. Three attempts at tuning one were wrong in both directions.

Speaking the monologue once returns the start and end of every character, so
word boundaries are exact. This cuts each shot at the word whose end sits
nearest the boundary, preferring a sentence ending when one is close. On the
first real test it produced a 20 word shot landing at 7.8s and cutting on a
full stop, where the budget had said 18.
2026-09-08 14:46:15 +00:00

1263 lines
59 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Direct the shots — PayToLaugh</title>
<meta name="description" content="Read the script, set the scene and camera for each shot, then approve it.">
<link rel="stylesheet" href="/assets/app.css">
<style>
.shot{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px;margin-bottom:18px;
display:grid;grid-template-columns:190px 1fr;gap:22px;align-items:start}
@media(max-width:720px){.shot{grid-template-columns:1fr}}
.shot .still{width:100%;aspect-ratio:9/16;border-radius:10px;border:1px solid var(--line);
object-fit:cover;display:block;background:#000}
.shot .noimg{width:100%;aspect-ratio:9/16;border-radius:10px;border:1px dashed var(--line);
background:#0f1117;display:flex;align-items:center;justify-content:center;text-align:center;
color:var(--muted);font-size:12.5px;padding:14px}
.shot .n{font:600 11px/1 var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--accent)}
.shot .cont{font:11px/1 var(--mono);color:var(--accent2);margin-left:8px}
.shot .line{font-size:17px;line-height:1.5;margin:10px 0 16px}
.shot .foot{font:11.5px/1 var(--mono);color:var(--muted)}
.mini{font-size:13px;font-weight:600;margin:0 0 5px;display:block}
.mini span{color:var(--muted);font-weight:400}
.shot textarea{min-height:62px}
.warn{background:#2a2112;border:1px solid #5b4520;color:#f0d9a7;border-radius:10px;padding:13px 16px;margin-bottom:16px;font-size:14px}
.drop{border:1px dashed var(--line);border-radius:12px;padding:26px 20px;text-align:center;color:var(--muted);
background:#0f1117;cursor:pointer;font-size:14.5px}
.drop.hot{border-color:var(--accent);color:var(--ink)}
.pics{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:14px;margin-top:16px}
.pic img{width:100%;aspect-ratio:1;object-fit:cover;border-radius:9px;border:1px solid var(--line);display:block}
.pic input{margin-top:6px;font-size:13px;padding:7px 9px}
.pic .rm{background:none;border:0;color:var(--muted);cursor:pointer;font:11px/1.6 var(--mono);text-decoration:underline;padding:0}
.place{border-top:1px solid var(--line);padding:8px 0;font:12.5px/1.6 var(--mono);color:var(--muted)}
.full{background:#0f1117;border:1px solid var(--line);border-radius:12px;padding:18px 20px;margin-bottom:24px;
color:var(--muted);font-size:15.5px;line-height:1.7}
.tl{position:relative;height:56px;background:#0f1117;border:1px solid var(--line);border-radius:8px;
overflow:hidden;margin-top:12px;user-select:none;touch-action:none}
.tlcut{position:absolute;top:6px;bottom:6px;background:#1f6feb;border-radius:5px;cursor:grab;
font:11px/1.5 var(--mono);color:#fff;overflow:hidden;box-sizing:border-box;
display:flex;align-items:center;gap:6px;padding-right:11px}
.tlcut.moved{background:#8957e5}
.tlcut img,.tlcut video{height:100%;width:34px;object-fit:cover;flex:0 0 auto;display:block}
.tlcut b{font-weight:600;white-space:nowrap;overflow:hidden;padding-left:2px}
.tlshots{position:relative;height:46px;margin-top:14px}
.tlshot{position:absolute;top:0;bottom:0;border-left:1px solid var(--line);padding-left:4px;
font:10px/1.3 var(--mono);color:var(--muted);overflow:hidden;box-sizing:border-box}
.tlshot img{height:28px;width:auto;border-radius:3px;display:block;margin-top:2px;border:1px solid var(--line)}
.tlcut .grip{position:absolute;top:0;right:0;bottom:0;width:9px;cursor:ew-resize;background:rgba(255,255,255,.28)}
.tlhead{position:absolute;top:0;bottom:0;width:2px;background:var(--accent2);pointer-events:none}
.tlruler{position:absolute;left:0;right:0;bottom:0;height:14px;font:9px/14px var(--mono);color:var(--muted)}
.tlruler span{position:absolute;transform:translateX(-50%)}
.steps{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:24px}
.steps button{background:#0f1117;border:1px solid var(--line);color:var(--muted);
border-radius:999px;padding:8px 15px;font:12px/1 var(--mono);cursor:pointer}
.steps button.done{color:var(--ink)}
.steps button.on{background:var(--accent);border-color:var(--accent);color:#fff;cursor:default}
.steps button[disabled]{opacity:.35;cursor:not-allowed}
.stage{position:relative;width:100%;max-width:300px}
.stage>video{width:100%;aspect-ratio:9/16;border-radius:10px;background:#000;display:block}
.ovl{position:absolute;inset:0;border-radius:10px;overflow:hidden;background:#000;display:none}
.ovl>*{width:100%;height:100%;object-fit:cover;display:block}
</style>
</head>
<body>
<div class="wrap">
<header class="page">
<h1 id="title">The script</h1>
<p id="sub">Read it through, then direct it. Every shot can have its own setting and camera move.</p>
</header>
<section>
<div class="msg" id="msg"></div>
<div id="out"><p style="color:var(--muted)">Loading…</p></div>
</section>
<footer>PayToLaugh &middot; your pet has opinions.</footer>
</div>
<script src="/assets/app.js"></script>
<script>
navBar("videos");
var email = requireSession();
var pid = new URLSearchParams(location.search).get("id");
var poll = null, PRESETS = [], PHOTOS = [], DATA = null;
if (!pid) {
document.getElementById("out").innerHTML =
'<div class="empty">No script chosen. <a href="/videos.html">Back to my videos</a>.</div>';
}
function photoOptions(sel) {
if (!PHOTOS.length) return '<option value="">No photos uploaded</option>';
return PHOTOS.map(function (p) {
var label = p.kind.charAt(0).toUpperCase() + p.kind.slice(1);
return '<option value="' + esc(p.id) + '"' + (p.id === sel ? ' selected' : '') + '>'
+ esc(label) + '</option>';
}).join("");
}
function shotHtml(s, locked) {
var img = s.plate_url
? '<img class="still" src="' + esc(s.plate_url) + '" alt="Shot ' + s.index + ' opening frame">'
: '<div class="noimg">No opening frame yet.<br>Set the scene, then make one.</div>';
var cont = s.source === "previous" && s.index > 1;
var toggle = s.index > 1
? '<div class="chips" data-srcgrp="' + s.index + '" style="margin-bottom:14px">'
+ '<span class="chip' + (cont ? '' : ' on') + '" data-src="photo">Start from a photo</span>'
+ '<span class="chip' + (cont ? ' on' : '') + '" data-src="previous">Continue from shot ' + (s.index - 1) + '</span>'
+ '</div>'
: '';
var settingLabel = cont
? '<label class="mini">The setting <span>— stays as it was; the camera is what moves</span></label>'
: '<label class="mini">The setting <span>— anywhere, including places a real pet could not be</span></label>';
var picker = cont
? '<p class="foot" style="margin:0">Continues the previous shot. Filming uses that shot&rsquo;s '
+ 'real last frame; the still here is a preview of the camera move.</p>'
: '<label class="mini">Which photo of them</label>'
+ '<select data-photo="' + s.index + '">' + photoOptions(s.photo_id) + '</select>';
// A cutaway belonging to this shot straddles the cut into the next one, so
// the registration jump happens off screen.
var br = s.broll || {};
var brollBlock = '';
if (br.subject) {
brollBlock = '<div style="margin-top:16px;padding-top:14px;border-top:1px solid var(--line)">'
+ '<label class="mini">Cutaway <span>— shown while this line is spoken, covering the cut</span></label>'
+ '<p class="foot" style="margin:0 0 8px">' + esc(br.subject) + ' · ' + esc(br.kind || 'image') + '</p>'
+ (s.broll_url
? (s.broll_kind === 'video'
? '<video src="' + esc(s.broll_url) + '" controls playsinline style="width:120px;border-radius:8px;border:1px solid var(--line)"></video>'
: '<img src="' + esc(s.broll_url) + '" alt="cutaway" style="width:120px;border-radius:8px;border:1px solid var(--line);display:block">')
: '')
+ (locked ? '' : '<button type="button" class="btn ghost" data-broll="' + s.index + '" style="margin-top:10px">'
+ (s.broll_url ? 'Remake cutaway' : 'Make cutaway') + '</button>')
+ '</div>';
}
var controls = locked ? '' :
toggle
+ settingLabel
+ '<textarea data-scene="' + s.index + '" placeholder="a field of tall grass at golden hour">' + esc(s.scene || "") + '</textarea>'
+ '<label class="mini" style="margin-top:12px">Camera</label>'
+ '<input type="text" list="cams" data-cam="' + s.index + '" value="' + esc(s.camera || "") + '" placeholder="tight head shot, camera static">'
+ '<div class="row" style="margin-top:12px">'
+ '<div>' + picker + '</div>'
+ '<div style="display:flex;align-items:flex-end">'
+ '<button type="button" class="btn ghost" data-make="' + s.index + '" style="width:100%">'
+ (s.plate_url ? "Redraw the opening still" : "Draw the opening still") + '</button>'
+ '<span class="foot" style="display:block;margin-top:6px">One picture, not the video. '
+ 'It is drawn from their photo and is what carries their likeness.</span></div>'
+ '</div>';
return '<div class="shot">'
+ '<div>' + img + '</div>'
+ '<div>'
+ '<span class="n">Shot ' + (s.index || "") + '</span>'
+ (s.continues ? '<span class="cont">continues the last shot</span>' : '')
+ '<p class="line">' + esc(s.line || "") + '</p>'
+ '<p class="foot">' + (s.word_count || 0) + ' words · about ' + (s.est_seconds || 0) + 's</p>'
+ '<div style="margin-top:16px">' + controls + '</div>'
+ brollBlock
+ '</div></div>';
}
// Four jobs, one at a time. Which one you are on follows from the project's
// status; VIEW lets you step back to look at an earlier one without pretending
// the work has been undone.
var STAGES = ["The words", "The layout", "Filming", "The cut"];
var VIEW = null;
function stageOf(st) {
if (st === "ready" || st === "film_failed") return 4;
if (st === "queued_to_film" || st === "filming") return 3;
if (st === "approved") return 2;
return 1;
}
function stepper(stage, view) {
return '<div class="steps">' + STAGES.map(function (n, k) {
var i = k + 1;
var cls = i === view ? "on" : (i <= stage ? "done" : "");
return '<button type="button" class="' + cls + '" data-stage="' + i + '"'
+ (i <= stage ? '' : ' disabled') + '>' + i + ' &middot; ' + esc(n) + '</button>';
}).join("") + '</div>';
}
// Stage one shows the writing and nothing else: no frames, no camera, no
// settings. Just what the pet actually says.
function linesHtml(s) {
return '<div class="shot" style="grid-template-columns:1fr;padding:16px 20px"><div>'
+ '<span class="n">Shot ' + (s.index || "") + '</span>'
+ '<p class="line" style="margin:8px 0 6px">' + esc(s.line || "") + '</p>'
+ '<p class="foot">' + (s.word_count || 0) + ' words &middot; about '
+ (s.est_seconds || 0) + 's</p></div></div>';
}
function render(r) {
DATA = r;
var p = r.project, s = r.script, out = document.getElementById("out");
var used = parseInt(p.revisions || 0, 10);
var left = (r.max_revisions || 3) - used;
if (p.status === "scripting") {
out.innerHTML = '<div class="card"><p style="margin:0;color:var(--muted)">'
+ 'Writing now. This usually takes under a minute — the page updates itself.</p></div>';
if (!poll) poll = setInterval(load, 5000);
return;
}
if (poll) { clearInterval(poll); poll = null; }
if (p.status === "script_failed") {
// The reason was always stored; it just was not being shown. A dead or
// paywalled link is the usual cause, and only the reader can fix it.
var why = (s && s.error) ? s.error : "";
out.innerHTML = '<div class="card"><p style="margin:0 0 10px">The writer could not finish this one.</p>'
+ (why ? '<div class="warn" style="margin:0 0 16px">' + esc(why) + '</div>'
: '<p class="foot" style="margin:0 0 16px">No reason was recorded.</p>')
+ '<a class="btn" style="display:inline-block;text-decoration:none" href="/create.html">Start again</a></div>';
return;
}
if (!s || !s.segments) { out.innerHTML = '<div class="empty">No script yet.</div>'; return; }
document.getElementById("title").textContent = s.title || "The script";
document.getElementById("sub").textContent = s.premise || "";
var stage = stageOf(p.status);
if (VIEW === null || VIEW > stage) VIEW = stage;
// Shot controls belong to the layout stage. Elsewhere the shots are shown
// but not touchable, which is what "locked" means to shotHtml.
var locked = VIEW !== 2;
var canEditScript = (stage === 1);
var warn = (s._warnings && s._warnings.length)
? '<div class="warn"><strong>Worth a look:</strong> ' + s._warnings.map(esc).join(" · ") + '</div>' : "";
var head = '<div class="quote" style="margin-bottom:22px">'
+ '<div><div class="big">$' + Number(p.price_usd || 0).toFixed(2) + '</div>'
+ '<div class="sub">' + ((p.segments || 4) * 8) + ' seconds · ' + (p.segments || 4) + ' shots</div></div>'
+ '<div class="sub">' + (canEditScript
? left + " rewrite" + (left === 1 ? "" : "s") + " left"
: "Words approved") + '</div></div>';
var full = s.full_text
? '<h2 class="sec">The whole piece</h2><div class="full">' + esc(s.full_text) + '</div>' : "";
var cams = '<datalist id="cams">' + PRESETS.map(function (c) {
return '<option value="' + esc(c) + '">'; }).join("") + '</datalist>';
// 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 ? '' :
'<h2 class="sec" style="margin-top:34px">Your own pictures</h2>'
+ '<div class="card">'
+ '<p class="foot" style="margin:0 0 14px">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.</p>'
+ '<div class="drop" id="drop" tabindex="0">Paste an image, drop one here, or click to choose</div>'
+ '<input type="file" id="picker" accept="image/*" multiple style="display:none">'
+ '<div class="pics" id="pics"></div>'
+ '<div style="margin-top:16px">'
+ '<button type="button" class="btn ghost" id="suggest">Suggest where they fit</button>'
+ '<span id="sugnote" class="foot" style="margin-left:12px"></span></div>'
+ '<div id="places"></div>'
+ '</div>';
// 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 = '<h2 class="sec" style="margin-top:34px">Film it</h2><div class="card">'
+ (noFrame.length && !busy
? '<div class="warn">Shot' + (noFrame.length > 1 ? 's ' : ' ') + noFrame.join(', ')
+ ' still ' + (noFrame.length > 1 ? 'have' : 'has') + ' no opening frame. '
+ 'That frame is what carries your pet&rsquo;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.</div>'
: '')
+ '<div class="quote" style="margin-bottom:16px">'
+ '<div><div class="big">' + ((p.segments || 4) * 8) + 's</div>'
+ '<div class="sub">of your time credit</div></div>'
+ '<div class="sub" id="balsec">checking your balance…</div></div>'
+ (p.status === 'film_failed'
? '<div class="warn">That did not finish: ' + esc(p.film_note || 'unknown reason')
+ '. Your seconds were returned.</div>'
: '')
+ (busy
? '<p class="foot" id="filmnote">' + esc(p.film_note || 'Queued…') + '</p>'
: '<button type="button" class="btn" id="film">'
+ (p.status === 'film_failed' ? 'Try filming again'
: (p.status === 'ready' ? 'Film it again' : 'Film it'))
+ '</button>'
+ '<span class="foot" style="margin-left:12px">About a minute a shot. '
+ 'Anything still missing — an opening still, a cutaway — is made '
+ 'automatically first, so nothing above is required.</span>'
+ (p.status === 'ready'
? '<p class="foot" style="margin:10px 0 0">This has already been filmed. '
+ 'Filming again spends the seconds again and replaces the video.</p>'
: ''))
+ '</div>';
}
var post = '';
if (p.status === 'ready' || p.cut_url) {
post = '<h2 class="sec" style="margin-top:34px">The cut</h2><div class="card">'
+ (p.cut_url
? '<video src="' + esc(p.cut_url) + '" controls playsinline style="width:190px;aspect-ratio:9/16;border-radius:10px;border:1px solid var(--line);background:#000;display:block;margin-bottom:14px"></video>'
: '')
+ '<p class="foot" style="margin:0 0 12px">Cutaways are laid across the joins between shots, '
+ 'so the picture changes while the voice keeps going and the join is never on screen.</p>'
+ '<button type="button" class="btn ghost" id="edlopen" style="margin-bottom:16px">Open the video editor</button>'
+ '<div id="edlwrap" style="margin:6px 0 18px;display:none">'
+ '<div class="stage" style="margin-bottom:12px">'
+ '<video id="pvid" playsinline preload="metadata"></video>'
+ '<div class="ovl" id="povl"><img id="povlimg" alt="cutaway"><video id="povlvid" muted playsinline></video></div>'
+ '</div>'
+ '<button type="button" class="btn ghost" id="pplay">&#9654; Preview the cut</button>'
+ '<span class="foot" style="margin-left:12px" id="ptime">0.0 / 0.0s</span>'
+ '<span class="foot" style="margin-left:12px">Nothing is rendered &mdash; this switches the picture as it plays.</span>'
+ '<div class="tlshots" id="tlshots"></div>'
+ '<div class="tl" id="tl"></div>'
+ '<p class="foot" id="tlinfo" style="margin:8px 0 0"></p>'
+ '<div style="margin-top:12px">'
+ '<button type="button" class="btn ghost" id="edlsave" disabled>Save these positions</button>'
+ '<button type="button" class="btn ghost" id="edlreset" style="margin-left:8px">Back to the suggested cut</button>'
+ '</div></div>'
+ '<button type="button" class="btn ghost" id="seams">Measure the joins</button>'
+ '<button type="button" class="btn" id="cut" style="margin-left:10px">'
+ (p.cut_url ? 'Cut it again' : 'Cut the B-roll in') + '</button>'
+ '<div id="seamout" style="margin-top:14px"></div>'
+ '<div id="takes" style="margin-top:20px"></div>'
+ '</div>';
}
var actions = !canEditScript
? '<div class="card"><p style="margin:0"><strong>These words are settled.</strong> '
+ 'Everything after this arranges how they are shot, so the writing is left alone.</p></div>'
: '<div class="card">'
+ '<h2 class="sec">Change the words</h2>'
+ '<div class="field"><label for="fb">What would you change</label>'
+ '<span class="hint">Be specific. “Too polite, he would be smugger” beats “make it funnier”. '
+ 'Setting and camera you can just edit directly above.</span>'
+ '<textarea id="fb" ' + (left <= 0 ? "disabled" : "") + ' placeholder="Shot three is too wordy, and he would never admit the robot is good at anything."></textarea></div>'
+ '<button class="btn ghost" id="revise" ' + (left <= 0 ? "disabled" : "") + '>'
+ (left > 0 ? "Rewrite the words" : "No rewrites left") + '</button>'
+ '<button class="btn" id="approve" style="margin-left:10px">Approve and lock it</button>'
+ '</div>';
var body;
if (VIEW === 1) {
body = head + warn + full
+ '<h2 class="sec">The words, shot by shot</h2>'
+ s.segments.map(linesHtml).join("")
+ actions;
} else if (VIEW === 2) {
body = head
+ '<p class="note" style="margin:0 0 22px"><strong>How it will be laid out.</strong> '
+ 'Each shot has a setting, a camera move and an opening frame that carries your '
+ 'pet&rsquo;s likeness. Cutaways sit across the joins so the cut is never on screen. '
+ 'All of it has been chosen for you &mdash; change anything you disagree with, or '
+ 'go straight to filming.</p>'
+ '<h2 class="sec">Hear it first</h2>'
+ '<div class="card" id="pzcard">'
+ '<p class="foot" style="margin:0 0 14px">Filming costs real money; speaking the '
+ 'words costs about a hundredth of a cent. Run it through in your pet&rsquo;s voice '
+ 'over the opening stills first, and only film once the timing and the jokes land.</p>'
+ '<button type="button" class="btn ghost" id="pzmake">Speak the script</button>'
+ '<button type="button" class="btn" id="alignbtn" style="margin-left:8px">Cut it to the clock</button>'
+ '<span class="foot" style="margin-left:12px" id="pznote"></span>'
+ '<p class="foot" style="margin:10px 0 0">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 &mdash; not the words themselves.</p>'
+ '<div id="alignout" style="margin-top:14px"></div>'
+ '<div id="pzout" style="margin-top:16px"></div>'
+ '</div>'
+ '<h2 class="sec" style="margin-top:34px">The shots</h2>'
+ 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 '<div class="pic">'
+ '<img src="' + esc(p.url) + '" alt="' + esc(p.description || "") + '">'
+ '<input type="text" value="' + esc(p.description || "") + '" readonly>'
+ '<button type="button" class="rm" data-rmpic="' + esc(p.id) + '">remove</button>'
+ '</div>';
}).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 '<div class="place">shot ' + x.index + ' · '
+ esc((p.description || "").slice(0, 60)) + ' — ' + esc(x.why || "") + '</div>';
}).join("")
+ '<button type="button" class="btn" id="applyp" style="margin-top:14px">Use these</button>'
: '<p class="foot">No picture fitted any shot well enough to use.</p>';
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();
});
});
[].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 = (p.segments || 4) * 8;
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 '<div class="tlshot" style="left:' + ((at / dur) * 100).toFixed(3) + '%;width:'
+ wide.toFixed(3) + '%" data-seek="' + at + '" title="shot ' + s.index + ' starts at ' + at + 's">'
+ 'shot ' + s.index
+ (s.plate_url ? '<img src="' + esc(s.plate_url) + '" alt="shot ' + s.index + ' opening frame">' : '')
+ '</div>';
}).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"
? '<video src="' + esc(c.src) + '#t=' + (c["in"] || 0) + '" preload="metadata" muted></video>'
: '<img src="' + esc(c.src) + '" alt="">';
html += '<div class="tlcut' + (c.moved ? ' moved' : '') + '" data-i="' + i + '" '
+ 'style="left:' + left.toFixed(3) + '%;width:' + wide.toFixed(3) + '%" '
+ 'title="cutaway over the end of shot ' + c.shot + '">'
+ thumb + '<b>' + c.seconds + 's</b>'
+ '<div class="grip" data-grip="' + i + '"></div></div>';
});
html += '<div class="tlhead" id="tlhead" style="left:0"></div>';
html += '<div class="tlruler">';
for (var t = 0; t <= dur; t += 8) {
// 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 += '<span style="' + style + '">' + t + 's</span>';
}
html += '</div>';
tl.innerHTML = html;
var n = (EDL.cuts || []).length;
document.getElementById("tlinfo").textContent = n
? n + " cutaway" + (n === 1 ? "" : "s") + " over " + dur + " seconds. Drag to move one, or pull its right edge to change how long it runs. The voice underneath never stops."
: "No cutaways on this one, so the talking head runs the whole way.";
document.getElementById("edlsave").disabled = !DIRTY;
}
// 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 = "&#9654; 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 = "&#9646;&#9646; 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 = '<h3 class="sec" style="font-size:14px;margin:0 0 10px">Earlier takes</h3>'
+ '<p class="foot" style="margin:0 0 12px">' + esc(r.status_text || "") + ' '
+ 'Bringing one back keeps the one you are leaving.</p>'
+ '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:14px">'
+ r.versions.map(function (v, i) {
return '<div>'
+ '<video src="' + esc(v.cut_url || v.video_url) + '" controls playsinline preload="metadata" '
+ 'style="width:100%;aspect-ratio:9/16;border-radius:9px;border:1px solid var(--line);background:#000;display:block"></video>'
+ '<div class="foot" style="margin-top:6px">' + esc(when(v.at) || "earlier")
+ (v.voice_name ? ' &middot; ' + esc(v.voice_name) : '')
+ (v.cost_usd ? ' &middot; &#36;' + Number(v.cost_usd).toFixed(2) : '') + '</div>'
+ (v.note ? '<div class="foot" style="opacity:.7">' + esc(v.note) + '</div>' : '')
+ '<button type="button" class="btn ghost" data-restore="' + i + '" '
+ 'style="margin-top:8px;width:100%">Use this one</button>'
+ '</div>';
}).join("")
+ '</div>';
[].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 = '<div class="stage" style="margin-bottom:12px">'
+ '<img id="pzimg" alt="opening still" style="width:100%;aspect-ratio:9/16;'
+ 'border-radius:10px;background:#000;object-fit:cover;display:block">'
+ '</div>'
+ '<audio id="pzaud"></audio>'
+ '<button type="button" class="btn" id="pzplay">&#9654; Play the run-through</button>'
+ '<span class="foot" style="margin-left:12px" id="pzwhere">'
+ PZ.length + ' lines &middot; ' + total.toFixed(1) + 's spoken, about '
+ filmed.toFixed(1) + 's once filmed, against ' + bought + 's of shots</span>'
+ (filmed > bought
? '<div class="warn" style="margin-top:12px">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.</div>'
: (filmed < bought * 0.88
? '<div class="warn" style="margin-top:12px">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.</div>'
: ''))
+ (over.length
? '<div class="warn" style="margin-top:12px">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.</div>'
: '')
+ '<div style="margin-top:14px">'
+ PZ.map(function (x) {
var f = (x.seconds || 0) * FILMED;
return '<div class="foot" style="padding:6px 0;border-top:1px solid var(--line)">'
+ 'shot ' + x.index + ' &middot; ' + x.seconds.toFixed(1) + 's spoken, ~'
+ f.toFixed(1) + 's filmed'
+ (f > 8 ? ' <span style="color:var(--accent2)">over</span>' : '')
+ ' &middot; ' + esc(x.line.slice(0, 60)) + '</div>';
}).join("")
+ '</div>';
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 = "&#9654; 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 = "&#9646;&#9646; Stop";
playFrom(0);
};
}
function bindAlign() {
var b = document.getElementById("alignbtn");
if (!b) return;
var out = document.getElementById("alignout");
function paint(r) {
var bought = ((DATA && DATA.script && DATA.script.segments) || []).length * 8;
var filmed = r.filmed || 0;
out.innerHTML = '<p class="foot" style="margin:0 0 8px">Measured: '
+ (r.spoken || 0).toFixed(1) + 's spoken, about ' + filmed.toFixed(1)
+ 's filmed, against ' + bought + 's of shots.</p>'
+ (r.audio ? '<audio controls src="' + esc(r.audio) + '" style="width:100%;max-width:420px"></audio>' : '')
+ (r.shots || []).map(function (s) {
var fit = Math.abs((s.filmed || 0) - 8) <= 1.2;
return '<div class="foot" style="padding:6px 0;border-top:1px solid var(--line)">'
+ 'shot ' + s.index + ' &middot; ' + (s.start || 0).toFixed(1) + '-'
+ (s.end || 0).toFixed(1) + 's spoken &middot; ~' + (s.filmed || 0).toFixed(1) + 's filmed'
+ (fit ? '' : ' <span style="color:var(--accent2)">off the clock</span>')
+ '<br>' + esc(s.line || "") + '</div>';
}).join("")
+ (filmed < bought - 1.5
? '<div class="warn" style="margin-top:12px">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 &mdash; it needs more written, or a '
+ 'shorter length.</div>'
: '')
+ (filmed > bought + 1.5
? '<div class="warn" style="margin-top:12px">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.</div>'
: '');
}
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);
}
});
}
function bindPost() {
loadTakes();
// 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 = '<p class="foot" style="margin:0 0 8px">' + esc(r.status_text || "") + '</p>'
+ (r.seams || []).map(function (x) {
var col = x.verdict === "smooth" ? "var(--good)"
: (x.verdict === "visible" ? "var(--accent2)" : "var(--bad)");
return '<div class="foot" style="padding:6px 0;border-top:1px solid var(--line)">'
+ 'shot ' + x.after_shot + ' → ' + x.before_shot + ' at ' + x.at_seconds + 's · '
+ 'shift ' + x.shift_x + ',' + x.shift_y + ' · drift ' + x.drift + ' · '
+ '<span style="color:' + col + '">' + esc(x.verdict) + '</span>'
+ (x.recommend_broll ? ' · worth a cutaway' : '')
+ '</div>';
}).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 = '<p class="foot">' + esc(r.status_text || "Measuring…") + '</p>';
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 = '<p class="foot">' + esc(r.status_text || "Nothing to measure.") + '</p>';
return;
}
el.innerHTML = '<p class="foot" style="margin:0 0 8px">' + esc(r.status_text || "") + '</p>'
+ r.seams.map(function (x) {
var col = x.verdict === "smooth" ? "var(--good)"
: (x.verdict === "visible" ? "var(--accent2)" : "var(--bad)");
return '<div class="foot" style="padding:6px 0;border-top:1px solid var(--line)">'
+ 'shot ' + x.after_shot + ' → ' + x.before_shot + ' at ' + x.at_seconds + 's · '
+ 'shift ' + x.shift_x + ',' + x.shift_y + ' · drift ' + x.drift + ' · '
+ '<span style="color:' + col + '">' + esc(x.verdict) + '</span>'
+ (x.recommend_broll ? ' · worth a cutaway' : '')
+ '</div>';
}).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() {
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 res = await api("revise_script", { email: email, project_id: pid, feedback: fb });
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 || [];
}
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();
</script>
</body>
</html>