paytolaugh/script.html
gator d175b3b926 Poll for the placement suggestion instead of waiting on it
Matching pictures to the script is a model call and cannot answer inside the
few seconds the API gateway allows, so it returned "Function timed out". It now
starts in the background and this waits for it, the same way frames and joins
already do.
2026-09-07 21:25:52 +00:00

679 lines
32 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}
</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>';
}
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 locked = p.status === "approved";
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">' + (locked ? "Approved" : left + " rewrite" + (left === 1 ? "" : "s") + " left") + '</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="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>';
}
var actions = locked
? '<div class="card"><p style="margin:0"><strong>Approved.</strong> Filming is switched on once payment is live. Nothing has been charged.</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>';
out.innerHTML = cams + head + warn + full
+ '<h2 class="sec">The shots</h2>'
+ s.segments.map(function (x) { return shotHtml(x, locked); }).join("")
+ own
+ film
+ post
+ actions;
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) {
bindPics();
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();
};
}
function bindPost() {
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>