Filming again used to overwrite the video outright. Models drift between generations, so re-filming to fix one thing can make another worse, and there was no way back. Every take is now kept and listed under the cut, newest first, with what it cost and which voice it used. Restoring one keeps the take you are leaving, so going back is reversible too. This deliberately lands before the "remake this shot" button, because that is what will make people re-film often enough to need it.
1121 lines
52 KiB
HTML
1121 lines
52 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%)}
|
|
.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 · 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’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’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">▶ 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 — 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 = 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();
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------- 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 = "▶ 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 = '<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 ? ' · ' + esc(v.voice_name) : '')
|
|
+ (v.cost_usd ? ' ·
|
|
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>
|
|
+ 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();
|
|
};
|
|
});
|
|
}
|
|
|
|
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>
|