paytolaugh/trailer.html

2194 lines
128 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Trailers — PayToLaugh</title>
<meta name="robots" content="noindex,nofollow">
<link rel="stylesheet" href="/assets/app.css">
<style>
.tabs{display:flex;gap:6px;flex-wrap:wrap;border-bottom:1px solid var(--line);margin:0 0 20px}
.tab{border:1px solid var(--line);border-bottom:0;background:none;color:var(--muted);
padding:10px 16px;border-radius:10px 10px 0 0;cursor:pointer;font:inherit;font-size:14px}
.tab.on{color:var(--ink);border-color:var(--line);background:var(--panel)}
.tab.lead{color:var(--accent)}
.scene{color:var(--accent2);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
.tab .sc{color:var(--accent2);margin-left:9px;font-weight:600;font-size:11px;
border:1px solid var(--accent2);border-radius:999px;padding:1px 7px;
font-variant-numeric:tabular-nums}
.tab .st{color:var(--muted);margin-left:7px;font-size:12px}
.head{display:flex;gap:18px;flex-wrap:wrap;align-items:baseline;margin-bottom:6px}
.head h2{margin:0;font-size:22px}
.tag{color:var(--muted);font-style:italic}
.facts{color:var(--muted);font-size:13px;margin:2px 0 18px}
.cast{display:flex;flex-wrap:wrap;gap:14px;margin:0 0 22px}
.ch{border:1px solid var(--line);border-radius:10px;padding:10px;font-size:13px;width:250px}
.ch b{display:block}
.ch .vo{color:var(--muted);font-size:12px}
.ch .ap{color:var(--muted);font-size:12px;margin-top:4px}
.sheets{display:flex;gap:5px;margin-bottom:8px}
.sheets img{width:33%;border-radius:6px;border:1px solid var(--line);display:block;
aspect-ratio:3/4;object-fit:cover;object-position:top}
img[data-zoom]{cursor:zoom-in}
.lookset{border-top:1px dashed var(--line);margin-top:8px;padding-top:8px}
.lookset .lw{color:var(--muted);font-size:12px;margin-bottom:5px}
.lookset .lw b{display:inline;color:inherit}
.lookset.idle{opacity:.55}
.whoin{display:flex;flex-wrap:wrap;gap:10px;margin:10px 0}
.whoin .p{display:flex;gap:8px;border:1px solid var(--line);border-radius:10px;
padding:8px;width:310px;font-size:12px;align-items:flex-start}
.whoin .p img{width:64px;aspect-ratio:3/4;object-fit:cover;object-position:top;
border-radius:6px;border:1px solid var(--line);flex:none}
.whoin .p .none{width:64px;aspect-ratio:3/4;border-radius:6px;flex:none;
border:1px dashed var(--line);display:flex;align-items:center;
justify-content:center;color:var(--muted);font-size:10px;text-align:center}
.whoin .p .t{flex:1;min-width:0}
.whoin .p .t b{display:block;font-size:13px}
.whoin .p .t .w{color:var(--muted);margin:2px 0 6px}
.whoin .p .t .st{color:#b4531f}
.whoin .p .t .new{color:#1f7a3a;font-weight:600}
.whoin .p input{width:100%;box-sizing:border-box;font:inherit;padding:5px 7px;
border:1px solid var(--line);border-radius:6px;background:transparent;color:inherit}
.whoin .p .go{margin-top:5px;font-size:12px;padding:3px 9px}
.guide{border:1px solid var(--line);border-radius:12px;padding:14px 16px;margin:0 0 18px}
.guide h4{margin:0 0 10px;font-size:15px}
.guide ol{list-style:none;margin:0;padding:0;display:grid;gap:7px}
.guide li{display:flex;gap:10px;align-items:baseline;font-size:13px;color:var(--muted)}
.guide li .n{flex:none;width:20px;height:20px;border-radius:50%;border:1px solid var(--line);
display:inline-flex;align-items:center;justify-content:center;font-size:11px}
.guide li.done .n{background:#1f7a3a;border-color:#1f7a3a;color:#fff}
.guide li.now{color:inherit;font-weight:600}
.guide li.now .n{background:#4B8BF5;border-color:#4B8BF5;color:#fff}
.guide li .d{font-weight:400;color:var(--muted);display:block;margin-top:2px}
.guide li .btn{margin-left:4px}
.fchk{font-size:12px;margin:0 0 10px;padding:7px 10px;border-radius:8px;border:1px solid var(--line)}
.fchk.ok{color:#1f7a3a;border-color:transparent;padding-left:0}
.fchk.bad{border-color:#b4531f}
.fchk .w{display:block;margin:3px 0}
.fchk .w.serious{color:#b4531f;font-weight:600}
.fchk .go{font-size:12px;padding:2px 9px;margin-left:6px}
.vars{border:1px solid var(--line);border-radius:10px;padding:10px;margin:0 0 10px}
.vars h5{margin:0 0 3px;font-size:13px}
.vars p{margin:0 0 8px;font-size:12px;color:var(--muted)}
.vars .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:10px}
.vars .v{font-size:12px}
.vars .v img{width:100%;border-radius:7px;border:2px solid var(--line);display:block}
.vars .v.good img{border-color:#1f7a3a}
.vars .v.bad img{border-color:#b4531f}
.vars .v .say{margin:4px 0;color:var(--muted);min-height:2.6em}
.vars .v.bad .say{color:#b4531f}
.vars .v .btn{font-size:12px;padding:3px 10px}
.fnote{margin:0 0 10px;font-size:12px}
.fnote label{display:block;color:var(--muted);margin-bottom:3px}
.fnote input{width:100%;box-sizing:border-box;font:inherit;font-size:12px;padding:5px 8px;
border:1px solid var(--line);border-radius:6px;background:transparent;color:inherit}
.fnote input.saved{border-color:#1f7a3a}
.ffault{display:flex;gap:8px;margin:0 0 10px}
.ffault input{flex:1;min-width:0;font:inherit;font-size:12px;padding:5px 8px;
border:1px solid var(--line);border-radius:6px;background:transparent;color:inherit}
.ffault .go{font-size:12px;padding:3px 10px;white-space:nowrap}
.whole{border:1px solid var(--line);border-radius:10px;padding:12px;margin:0 0 18px}
.whole h4{margin:0 0 4px;font-size:14px}
.whole p{margin:0 0 8px;color:var(--muted);font-size:13px}
.whole textarea{width:100%;box-sizing:border-box}
#zoom{position:fixed;inset:0;background:rgba(0,0,0,.9);z-index:99;display:none;
align-items:center;justify-content:center;cursor:zoom-out;padding:18px}
#zoom.on{display:flex}
#zoom img{max-width:100%;max-height:100%;border-radius:6px}
#zoom .cap{position:fixed;left:0;right:0;bottom:10px;text-align:center;color:#ddd;font-size:13px}
.shot{border-top:1px solid var(--line);padding:16px 0;display:flex;gap:16px}
.scenebar{display:flex;align-items:center;gap:12px;width:100%;box-sizing:border-box;border:0;border-radius:10px;padding:10px 14px;margin:14px 0 4px;cursor:pointer;font:inherit;font-size:14px;text-align:left;color:#fff}
.scenebar.ok{background:#1f7a3a}
.scenebar.todo{background:#a8322a}
.scenebar .n{font:12px var(--mono,monospace);opacity:.85;min-width:64px}
.scenebar .t{flex:1;font-weight:600}
.scenebar .st{font-size:12px;opacity:.9}
.scenebar .arrow{opacity:.8}
.sceneshare{display:flex;gap:8px;align-items:center;margin:0 0 8px;font-size:12px;color:var(--muted)}
.sharelink{font:12px var(--mono,monospace);word-break:break-all}
.shot .num{width:40px;flex:none;color:var(--muted);font-variant-numeric:tabular-nums}
.shot .body{flex:1;min-width:0}
.shot h3{margin:0 0 3px;font-size:15px}
.shot .where{color:var(--muted);font-size:12px;margin-bottom:8px}
.shot .act{font-size:14px;margin:0 0 8px}
.shot .cam,.shot .cont{color:var(--muted);font-size:12.5px;margin:0 0 5px}
.frames{display:flex;gap:10px;margin:10px 0}
.frames figure{margin:0;flex:1;min-width:0}
.frames img,.frames video{width:100%;border-radius:8px;border:1px solid var(--line);display:block}
.frames figcaption{color:var(--muted);font-size:11px;margin-top:3px}
.line{display:flex;gap:9px;align-items:flex-start;margin:7px 0}
.line .say{flex:1;min-width:0;font-size:14px}
.line .who{color:var(--accent);font-size:12px}
.line .dir{color:var(--muted);font-size:12px}
.bg{display:flex;gap:12px;align-items:flex-start;border:1px dashed var(--line);border-radius:10px;padding:8px 10px;margin:8px 0}
.bg img{width:180px;border-radius:6px;border:1px solid var(--line);cursor:zoom-in}
.bg .bgtxt{flex:1;min-width:200px}
.bg .bgtxt b{font-size:13px}
.bg textarea{width:100%;box-sizing:border-box;font:inherit;font-size:13px;min-height:64px;margin:4px 0}
.bg .tools{display:flex;gap:6px;flex-wrap:wrap}
.bg .tiny{font-size:12px;color:var(--muted)}
.play{border:1px solid var(--line);background:none;color:var(--ink);border-radius:999px;
width:30px;height:30px;flex:none;cursor:pointer;font:inherit;line-height:1}
.play:hover{border-color:var(--accent)}
.dur{color:var(--muted);font-size:12px;align-self:center}
.dur.over{color:#e0685f}
.note{width:100%;box-sizing:border-box;margin-top:8px;font:inherit;font-size:13px;
background:transparent;color:var(--ink);border:1px dashed var(--line);
border-radius:8px;padding:8px 10px;resize:vertical}
.note:focus{border-style:solid;border-color:var(--accent);outline:none}
.note.saved{border-color:var(--accent2)}
.probs{border:1px solid #e0685f;border-radius:10px;padding:12px 14px;margin:0 0 18px;font-size:13px}
.probs b{color:#e0685f}
.probs ul{margin:6px 0 0;padding-left:18px}
.bar{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin:0 0 20px}
.soon{color:var(--muted);font-size:12px}
.lip{color:var(--accent2);font-size:11px;border:1px solid var(--accent2);
border-radius:999px;padding:0 6px}
.vobadge{color:var(--muted);font-size:11px;border:1px solid var(--line);
border-radius:999px;padding:0 6px}
.spine{border:1px solid var(--line);border-radius:10px;padding:14px 16px;margin:0 0 14px}
.spine h4{margin:0 0 8px;font-size:15px}
.spine .eng{font-size:14px;border-left:2px solid var(--accent);padding-left:11px;margin:0 0 12px}
.spine .kv{font-size:13px;margin:3px 0;color:var(--muted)}
.spine .kv b{color:var(--ink);font-weight:600}
.spine h5{margin:12px 0 4px;font-size:12px;letter-spacing:.06em;text-transform:uppercase;
color:var(--muted)}
.spine ul{margin:0;padding-left:18px;font-size:13px}
.spine details{margin-top:10px}
.spine summary{cursor:pointer;color:var(--muted);font-size:13px}
.gaps{border:1px solid var(--accent);border-radius:10px;padding:14px 16px;margin:0 0 14px}
.gaps h4{margin:0 0 4px;font-size:15px;color:var(--accent)}
.gaps .verdict{font-size:14px;margin:0 0 10px}
.gaps ol{margin:0;padding-left:20px;font-size:13px}
.gaps li{margin-bottom:7px}
.gaps .fix{color:var(--muted);display:block}
.gaps .sh{color:var(--muted);font-variant-numeric:tabular-nums}
.rd{border:1px solid var(--line);border-radius:10px;padding:12px 14px;margin-bottom:10px}
.rd h4{margin:0 0 6px;font-size:14px}
.rd h4 .n{color:var(--accent2);font-weight:600;margin-left:8px}
.rd blockquote{margin:0 0 8px;font-size:14px;border-left:2px solid var(--line);padding-left:10px}
.rd .kv{color:var(--muted);font-size:12.5px;margin:2px 0}
.rd ul{margin:6px 0 0;padding-left:18px;font-size:13px}
.rd .wrong{color:#e0685f}
.tools{display:flex;gap:7px;flex-wrap:wrap;align-items:center;margin-top:10px}
.tools button{font-size:12px;padding:5px 10px}
.chk{font-size:12px;border-radius:999px;padding:1px 8px;border:1px solid var(--line);color:var(--muted)}
.chk.ok{color:var(--accent2);border-color:var(--accent2)}
.chk.bad{color:#e0685f;border-color:#e0685f}
.eyes{margin:8px 0 0;font-size:12.5px}
.eyes .f{margin:4px 0;padding-left:10px;border-left:2px solid var(--line)}
.eyes .f.serious{border-left-color:#e0685f}
.eyes .f b{color:var(--ink)}
.eyes .fix{color:var(--muted);display:block;margin-top:1px}
.eyes .sum{color:var(--muted);font-style:italic;margin-bottom:5px}
.ins{border-top:1px dashed var(--line);text-align:center;padding:6px 0}
.ins button{font-size:12px;padding:4px 12px;color:var(--muted)}
.rough{width:100%;box-sizing:border-box;margin-top:8px;font:inherit;font-size:13px;
background:transparent;color:var(--ink);border:1px solid var(--accent);
border-radius:8px;padding:9px 11px;resize:vertical}
.busy{color:var(--accent);font-size:12px}
.timing{border:1px solid var(--line);border-radius:10px;padding:11px 14px;margin:0 0 16px;font-size:13px}
.timing.over{border-color:#e0685f}
.timing ul{margin:7px 0 0;padding-left:18px;color:var(--muted);font-size:12.5px}
.cuts{border:1px solid var(--line);border-radius:10px;padding:14px 16px;margin:0 0 16px}
.cuts h4{margin:0 0 4px;font-size:15px}
.cuts .lede{color:var(--muted);font-size:13px;margin:0 0 12px}
.cut{border-top:1px solid var(--line);padding:11px 0}
.cut:first-of-type{border-top:0}
.cut b{font-size:14px}
.cut .len{color:var(--accent2);font-variant-numeric:tabular-nums;margin-left:8px;font-size:13px}
.cut .strat{font-size:13px;margin:3px 0}
.cut .why{color:var(--muted);font-size:12.5px;margin:3px 0}
.cut .sac{color:#e0a05f;font-size:12.5px;margin:3px 0}
.cut .ids{color:var(--muted);font-size:11.5px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
margin:4px 0 0;word-break:break-word}
.cut .stat{color:var(--accent2);font-size:12.5px;margin:3px 0}
.playbox{margin:10px 0 0;background:#000;border-radius:10px;overflow:hidden}
.playbox video{width:100%;display:block;aspect-ratio:16/9;background:#000}
.playbox .bar2{display:flex;gap:10px;align-items:center;padding:7px 10px;font-size:12px;
color:var(--muted);background:var(--panel)}
.reel{border:1px solid var(--accent);border-radius:10px;padding:14px 16px;margin:0 0 18px}
.reel h4{margin:0 0 4px;font-size:15px;color:var(--accent)}
.reel .lede{color:var(--muted);font-size:13px;margin:0 0 12px}
.reelframes{display:flex;gap:12px}
.reelframes figure{margin:0;flex:1;min-width:0}
.reelframes img{width:100%;border-radius:10px;border:1px solid var(--line);display:block;
aspect-ratio:16/9;object-fit:cover;background:#000}
.reelframes img.on{border-color:var(--accent);box-shadow:0 0 0 2px rgba(255,170,80,.25)}
.reelframes figcaption{color:var(--muted);font-size:11px;margin-top:4px;text-align:center}
.reelnow{margin:12px 0 0;text-align:center}
.reelnow .t{font-size:15px;font-weight:600}
.reelnow .w{color:var(--muted);font-size:12.5px;margin-top:2px}
.reelnow .said{font-size:14px;margin-top:8px;max-width:46em;margin-left:auto;margin-right:auto}
.reelnow .said .who{color:var(--accent);font-size:12px;margin-right:6px}
.reelbar{display:flex;gap:8px;align-items:center;justify-content:center;
flex-wrap:wrap;margin-top:12px}
.reelbar .pos{color:var(--muted);font-size:12px;font-variant-numeric:tabular-nums}
.reelprog{height:4px;background:var(--line);border-radius:999px;margin-top:12px;overflow:hidden}
.reelprog i{display:block;height:100%;background:var(--accent);width:0}
.shot.lit{background:rgba(255,170,80,.06);border-radius:10px}
</style>
</head>
<body>
<div class="wrap">
<header class="page">
<h1 id="title">Trailers</h1>
<p id="sub"></p>
</header>
<div class="msg" id="msg"></div>
<section><div class="card">
<div class="tabs" id="tabs"></div>
<div id="pane"><p class="empty">Loading&hellip;</p></div>
</div></section>
</div>
<script src="/assets/app.js"></script>
<script>
(function () {
if (!requireSession()) return;
navBar("trailer");
var RUN = null, DRAFTS = [], CUR = null, PKG = {}, NOTES = {}, REVIEWS = [];
var timer = null, EDITING = null;
var PLAYING = null, STATS = {}, QUEUE = [], QAT = 0;
var REELWANT = false; // the author asked to play, not merely to prepare
var runId = new URLSearchParams(location.search).get("run") || "";
var audio = new Audio();
if (!runId) { showMsg("msg", "No run was named in the link."); return; }
function busyDraft(d) {
var s = String(d.draw_state || "");
return d.state === "writing" || (s && !/^(drawn|failed)/.test(s));
}
function busyRun() {
return RUN && /^(writing|reviewing|revising|judging)$/.test(String(RUN.stage || ""));
}
// Work happens in the worker's memory and does not survive a deploy, so a
// flag saying "in progress" is only believed while it is recent.
function stillFilming(s) {
if (!s.filming) return false;
var t = Date.parse(s.filming_since || "");
if (!t) return false; // no stamp: from before this existed
return (Date.now() - t) < 25 * 60 * 1000;
}
var DRAFTNOW = null;
var PAGE = 0, PAGE_FOR = null;
function busyShots() {
var ps = String(PKG.pics_state || "");
if (ps && ps !== "ready"
&& (Date.now() - (Date.parse(PKG.pics_started || "") || 0)) < 15 * 60 * 1000) return true;
if (String(PKG.look_state || "")
&& (Date.now() - (Date.parse(PKG.look_started || "") || 0)) < 15 * 60 * 1000) return true;
if (String(PKG.props_state || "")
&& (Date.now() - (Date.parse(PKG.props_started || "") || 0)) < 15 * 60 * 1000) return true;
return (PKG.shots || []).some(function (s) {
return s.writing || s.drawing || s.fixing || s.varying || stillFilming(s); });
}
// Preparing the storyboard audio is work too. Without this the page stops
// polling and the progress sits at whatever it last happened to see.
function busyReel() {
var st = String(PKG.reel_state || "");
if (!st || st === "ready" || /^failed/.test(st)) return false;
var t = Date.parse(PKG.reel_started || "");
if (!t) return true;
return (Date.now() - t) < 15 * 60 * 1000; // older than that is gone
}
async function load() {
var r = await api("run_status", { run_id: runId });
if (!r.ok) { showMsg("msg", r.error || "Could not load that run."); return; }
RUN = r.run; DRAFTS = r.drafts || [];
document.getElementById("title").textContent = RUN.book_title || "Trailers";
document.getElementById("sub").textContent = sub();
if (!CUR && DRAFTS.length) CUR = leadDraftId() || DRAFTS[0].id;
paintTabs();
var rv = await api("list_reviews", { run_id: runId });
REVIEWS = (rv && rv.reviews) || [];
await openTab(CUR);
tick();
}
function tick() {
var es = String(PKG.eyes_state || "");
var busyEyes = es && es !== "ready" && !/^failed/.test(es)
&& (Date.now() - (Date.parse(PKG.eyes_started || "") || 0)) < 20 * 60 * 1000;
var busy = DRAFTS.some(busyDraft) || busyRun() || busyShots() || busyReel()
|| busyEyes;
if (busy && !timer) timer = setInterval(refresh, 5000);
if (!busy && timer) { clearInterval(timer); timer = null; }
}
// A refresh redraws the whole storyboard. While the author is watching a
// clip, or typing a note, or has the background box open, that redraw
// would stop the player and throw the typing away - so it waits.
function authorBusy() {
var vids = document.querySelectorAll("#pane video");
for (var i = 0; i < vids.length; i++) if (!vids[i].paused && !vids[i].ended) return true;
var a = document.activeElement;
if (a && a.closest && a.closest("#pane") && /^(TEXTAREA|INPUT)$/.test(a.tagName)) return true;
return !!(BGEDIT || EDITING);
}
async function refresh() {
if (authorBusy()) { tick(); return; }
var r = await api("run_status", { run_id: runId });
if (!r.ok) return;
RUN = r.run; DRAFTS = r.drafts || [];
document.getElementById("sub").textContent = sub();
paintTabs();
var rv = await api("list_reviews", { run_id: runId });
REVIEWS = (rv && rv.reviews) || [];
await openTab(CUR);
tick();
}
function leadDraftId() {
var best = null;
DRAFTS.forEach(function (d) {
if (d.writer === RUN.lead_writer
&& (!best || Number(d.iteration) > Number(best.iteration))) best = d;
});
return best && best.id;
}
function sub() {
var bits = [];
if (RUN.stage) bits.push(RUN.stage);
bits.push(DRAFTS.length + " script" + (DRAFTS.length === 1 ? "" : "s"));
var spent = DRAFTS.reduce(function (a, d) {
return a + Number(d.write_usd || 0) + Number(d.draw_usd || 0); }, 0);
(PKG.shots || []).forEach(function (s) { spent += Number(s.film_usd || 0); });
if (spent) bits.push("$" + spent.toFixed(2) + " so far");
if (RUN.created_at) bits.push(when(RUN.created_at));
return bits.join(" · ");
}
// A CHAPTER IS FILMED IN PARTS of about 25 shots that share one cast and the
// same rooms. This says where each part has got to, and joins them.
// A VOICE OVER SOMEBODY LISTENING. In a phone call the audience wants to see
// whoever is talking. One press re-frames those shots onto the speaker.
function heardNotSeen() {
return (PKG.shots || []).filter(function (sh) {
var l = (sh.audio || []).filter(function (a) { return String(a.text || "").trim(); });
return l.length === 1 && !l[0].lip_sync && l[0].character_id
&& String(l[0].character_id) !== String(PKG.narrator || "")
&& String(l[0].text).trim().split(/\s+/).length > 3;
});
}
function speakerNoticeHtml(draft) {
var st = PKG.show_speaker || {};
if (st.state === "working") return '<div class="guide" style="margin-bottom:12px"><p class="cont">Re-framing those shots onto the person speaking&hellip; this takes a minute or two.</p></div>';
var n = heardNotSeen();
if (!n.length) return "";
return '<div class="guide" style="margin-bottom:12px"><h4>' + n.length + (n.length === 1 ? ' shot plays' : ' shots play') + ' a voice over somebody listening</h4>'
+ '<p class="cont">Shots ' + n.map(function (x) { return esc(x.id); }).join(", ") + '. In a conversation people expect to see whoever is talking. This rewrites those pictures to show the speaker, in the place where they are. Every word stays as it is. The pictures will need drawing again.</p>'
+ '<button type="button" class="btn primary" data-showspeakers="' + esc(draft.id) + '">Show the person speaking instead</button></div>';
}
// HOW THE FILM PLAYS. After every join the whole film is listened to, word by
// word with a time on each, and laid against the shots: what was dropped, where
// nothing is heard, where lips move with no sound.
function clock(t) { t = Math.max(0, Number(t) || 0); var m = Math.floor(t / 60), x = Math.floor(t - m * 60); return m + ":" + (x < 10 ? "0" : "") + x; }
function filmCheckHtml() {
var fc = PKG.film_check;
if (!fc || !(fc.rows || []).length) return "";
var bad = fc.rows.filter(function (r) { return (r.faults || []).length; });
var names = { dropped: "words dropped", spill: "line outside its shot", dead_air: "dead air", mute_talk: "lips move, no sound", unscripted: "unscripted words", silent_gap: "silence mid-conversation" };
var head = '<h4>How the film plays' + (fc.seconds ? ' &middot; ' + clock(fc.seconds) : '') + '</h4>'
+ '<p class="cont">The finished film was listened to word by word and laid against the shots. '
+ (bad.length ? '<b>' + bad.length + (bad.length === 1 ? ' shot needs' : ' shots need') + ' a look.</b>' : 'Every line is heard, in its own shot, with nothing odd either side.')
+ (fc.coverage != null ? ' ' + Math.round(fc.coverage * 100) + '% of the script is heard.' : '') + '</p>';
function row(r) {
var f = (r.faults || []).map(function (x) { return '<div style="color:#b00020"><b>' + esc(names[x.kind] || x.kind) + '</b> &mdash; ' + esc(x.what) + '</div>'; }).join("");
return '<tr style="vertical-align:top' + (f ? '' : ';opacity:.7') + '"><td style="white-space:nowrap;padding:3px 8px 3px 0">' + clock(r.from) + '&ndash;' + clock(r.to) + '</td>'
+ '<td style="white-space:nowrap;padding:3px 8px 3px 0"><a href="#shot-' + esc(r.shot) + '" data-gotoshot="' + esc(r.shot) + '">' + esc(r.shot) + '</a></td>'
+ '<td style="padding:3px 0">' + (r.says ? esc(r.says) : '<i>nobody speaks' + (r.title ? ' &middot; ' + esc(r.title) : '') + '</i>') + f + '</td></tr>';
}
return '<div class="guide" style="margin-bottom:12px">' + head
+ (bad.length ? '<table style="border-collapse:collapse;font-size:13px">' + bad.map(row).join("") + '</table>' : '')
+ '<details style="margin-top:6px"><summary>Every shot, with its time in the film</summary><table style="border-collapse:collapse;font-size:13px">' + fc.rows.map(row).join("") + '</table></details></div>';
}
// ONE MASTER PICTURE PER SET-UP. Shots that start from the same place, people
// and things in hand get their first frames from one camera move off one
// picture, so the room and the telephone cannot change between them.
function viewsHtml(draft) {
var vs = PKG.views_state || {}, shots = PKG.shots || [];
if (vs.state === "working") return '<div class="guide" style="margin-bottom:12px"><p class="cont">Photographing each set-up from one master picture&hellip; about five minutes. The page will show the new pictures when it is done.</p></div>';
var places = {}, many = 0;
shots.forEach(function (x) { var k = String(x.scene || "").toLowerCase().trim(); if (k) places[k] = (places[k] || 0) + 1; });
Object.keys(places).forEach(function (k) { if (places[k] > 1) many += 1; });
var done = shots.filter(function (x) { return x.view_of; }).length;
if (!many || !shots.some(function (x) { return x.opening_url; })) return "";
var ups = (PKG.setups || []).filter(function (u) { return (u.shots || []).length > 1 && u.master_url; });
var strip = ups.map(function (u) { return '<a href="' + esc(u.master_url) + '" target="_blank" title="' + esc(u.state || "") + '"><img src="' + esc(u.master_url) + '" style="height:72px;border-radius:4px;margin:0 6px 6px 0" alt=""></a>'; }).join("");
return '<div class="guide" style="margin-bottom:12px"><h4>One master picture for each set-up</h4>'
+ (done ? '<p class="cont">' + done + ' of ' + shots.length + ' shots take their first frame from a camera move off their set-up&rsquo;s master picture, so the room, the clothes and the things in hand are the same in every one.</p>' + strip
: '<p class="cont">' + many + ' places are seen in more than one shot. Drawn one at a time, a room and the things in it drift from picture to picture. This makes one wide master picture for each set-up (same place, same people, same things in hand), films a single camera move in from it, and gives each shot the frame at its own size of shot. About $0.70 a set-up. Shots already filmed will need filming again.</p>')
+ (vs.state === "failed" ? '<p class="cont" style="color:#b00020">' + esc(vs.note || "That did not work.") + '</p>' : '')
+ '<button type="button" class="btn' + (done ? '' : ' primary') + '" data-sceneviews="' + esc(draft.id) + '">' + (done ? 'Do it again for shots that have changed' : 'Photograph each set-up from one master') + '</button></div>';
}
// What a take really costs: the engine bills by the second (7 cents), five
// seconds at least, and a speaking shot runs its line plus the slate words.
function filmCost(x) { return Math.max(5, Math.ceil((Number(x.edit_seconds) || 5) + 1.5)) * 0.07; }
function filmCostAll(list) { return list.reduce(function (a, x) { return a + filmCost(x); }, 0); }
function partsHtml(draft) {
var parts = DRAFTS.filter(function (d) { return /^part-/.test(String(d.writer)); });
var n = (PKG.shots || []).length;
if (!parts.length) {
if (PKG.kind !== "chapter" || n <= 40) return "";
return '<div class="guide" style="margin-bottom:12px"><h4>This is a whole chapter: ' + n + ' shots</h4>'
+ '<p class="cont">That is too much to draw and film in one go. Split it into parts of about 25 shots. The parts keep the same faces, voices, rooms and objects, and are joined into one film at the end.</p>'
+ '<button type="button" class="btn primary" data-splitparts="' + esc(draft.id) + '">Split it into parts</button></div>';
}
var films = parts.filter(function (d) { return d.film_url; }).length;
return '<div class="guide" style="margin-bottom:12px"><h4>The chapter, part by part</h4>'
+ '<p class="cont">Work through the parts in order - each tab is one part. Draw it, look at it, film it, then “Make the trailer” on that tab makes that part’s film.</p><ol style="margin:6px 0 8px 18px">'
+ parts.map(function (d) {
var st = d.film_url ? 'filmed ✓' : (/^drawn/.test(String(d.draw_state)) ? 'drawn, not filmed' : (d.draw_state ? esc(d.draw_state) : 'not drawn yet'));
return '<li><b>' + esc(d.label) + '</b> &middot; ' + d.shots + ' shots &middot; ' + Math.round(d.runtime_seconds / 6) / 10 + ' min &middot; ' + st
+ (d.film_url ? ' &middot; <a href="' + esc(d.film_url) + '" target="_blank">watch</a>' : '') + '</li>'; }).join("")
+ '</ol>' + (films >= 2 ? '<button type="button" class="btn primary" data-joinparts="1">Join the ' + films + ' filmed parts into one film</button>' : '')
+ '</div>';
}
// THE WRITERS SCORE EACH OTHER. Blind, against the checklist in the guide;
// nobody marks their own script; the winner becomes head writer.
function contestHtml() {
var ready = DRAFTS.filter(function (d) { return /^ready/.test(String(d.state)) && Number(d.iteration || 1) === Number(RUN.iteration || 1); });
if (ready.length < 2 || DRAFTS.some(function (d) { return /^part-/.test(String(d.writer)); })) return "";
var c = RUN.contest, label = {};
DRAFTS.forEach(function (d) { label[d.writer] = d.label || d.writer; });
var btn = RUN.stage === "judging"
? '<span class="busy">The writers are reading and scoring each other’s scripts… a few minutes.</span>'
: '<button type="button" class="btn' + (c ? '' : ' primary') + '" data-judge="1">'
+ (c ? 'Score them again' : 'Have the writers score each other’s scripts') + ' &middot; about $' + (0.12 * ready.length).toFixed(2) + '</button>';
var table = "";
if (c && (c.ranked || []).length) {
table = '<table style="border-collapse:collapse;font-size:13px;margin:8px 0"><tr><th style="text-align:left;padding:3px 10px 3px 0">script</th><th style="padding:3px 10px">score</th><th style="text-align:left;padding:3px 0">what the other writers said</th></tr>'
+ c.ranked.map(function (r, i) {
var n = (r.notes || [])[0] || {};
return '<tr style="border-top:1px solid var(--line)"><td style="padding:5px 10px 5px 0;white-space:nowrap">' + (i === 0 ? '★ ' : '') + '<b>' + esc(label[r.writer] || r.writer)
+ '</b></td><td style="padding:5px 10px;text-align:center"><b>' + esc(r.score) + '</b><br><span class="dir">' + esc((r.marks || []).join(", ")) + '</span></td><td style="padding:5px 0"><span style="color:#1f7a3a">+</span> '
+ esc(n.best || "") + '<br><span style="color:#b4531f">−</span> ' + esc(n.worst || "") + '</td></tr>'; }).join("")
+ '</table><p class="cont">The highest scoring script’s writer is now the head writer (★). You can still choose another.</p>';
}
return '<div class="guide" style="margin-bottom:12px"><h4>Which script is best?</h4>' + table + btn + '</div>';
}
// One more writer on this run: same book, same notes, nobody else pays again.
var ALLWRITERS = null;
function addWriterHtml() {
if (!ALLWRITERS) {
ALLWRITERS = [];
api("camera_presets", {}).then(function (r) {
ALLWRITERS = (r && r.writers) || []; if (ALLWRITERS.length) paintTabs(); });
return "";
}
var have = {}; DRAFTS.forEach(function (d) { have[d.writer] = 1; });
var free = ALLWRITERS.filter(function (w) { return !have[w.id]; });
if (!free.length) return "";
return '<span style="margin-left:10px;white-space:nowrap"><select id="addwsel" style="font-size:12px">'
+ free.map(function (w) { return '<option value="' + esc(w.id) + '">' + esc(w.label) + '</option>'; }).join("")
+ '</select> <button type="button" class="btn" data-addwriter="1" style="font-size:12px">+ add this writer &middot; about $1</button></span>';
}
function paintTabs() {
document.getElementById("tabs").innerHTML = DRAFTS.map(function (d) {
var s = String(d.draw_state || "");
var extra = d.state === "writing" ? '<span class="st">writing…</span>'
: d.state === "failed" ? '<span class="st">failed</span>'
: (s && !/^(drawn|failed)/.test(s)) ? '<span class="st">' + esc(s) + '</span>'
: d.score ? '<span class="sc" title="how well readers understood it">'
+ d.score + '</span>'
: '';
return '<button type="button" class="tab' + (d.id === CUR ? ' on' : '')
+ (d.writer === RUN.lead_writer ? ' lead' : '') + '" data-tab="' + esc(d.id) + '">'
+ esc(d.label || d.writer)
+ (Number(d.iteration) > 1 ? ' v' + d.iteration : '')
+ (d.writer === RUN.lead_writer ? ' ★' : '') + extra + '</button>';
}).join("") + addWriterHtml();
}
document.getElementById("tabs").addEventListener("click", function (e) {
var addb = e.target.closest ? e.target.closest("[data-addwriter]") : null;
if (addb) {
var aw = document.getElementById("addwsel");
addb.disabled = true;
api("add_writer", { run_id: runId, writer: aw ? aw.value : "" }).then(function (r) {
if (r && r.ok) { showMsg("msg", "That writer is reading the book now. Its script appears as a new tab in a few minutes.", "ok"); load(); }
else { showMsg("msg", (r && r.error) || "That writer could not be added."); addb.disabled = false; }
});
return;
}
var id = e.target.closest ? e.target.closest(".tab") : null;
if (!id) return;
CUR = id.getAttribute("data-tab"); EDITING = null;
paintTabs(); openTab(CUR);
});
async function openTab(id) {
var meta = DRAFTS.filter(function (d) { return d.id === id; })[0];
if (!meta) return;
var pane = document.getElementById("pane");
if (meta.state === "failed") {
pane.innerHTML = '<p class="empty"><b>' + esc(meta.label) + ' did not finish.</b><br>'
+ esc(meta.note || "No reason was recorded.") + '</p>'
+ '<div class="bar"><button type="button" class="btn" data-retry="'
+ esc(meta.id) + '">Try this writer again</button></div>';
return;
}
if (meta.state === "writing") {
pane.innerHTML = '<p class="empty">Working. It reads the whole book, works out what the '
+ 'story is, and then writes the trailer from that. Several minutes.</p>';
return;
}
var r = await api("get_draft", { draft_id: id });
if (!r.ok) { pane.innerHTML = '<p class="empty">' + esc(r.error || "") + '</p>'; return; }
PKG = r.draft.package || {};
NOTES = r.draft.notes || {};
STATS = {};
if ((PKG.cuts || []).some(function (c) { return c.project_id; })) {
var cs = await api("cut_stats", { draft_id: id });
((cs && cs.cuts) || []).forEach(function (x) { STATS[x.cut_id] = x; });
}
pane.innerHTML = paneHtml(meta, r.draft);
// "Prepare and play" promised both. Once the audio lands, play it.
if (REELWANT && String(PKG.reel_state || "") === "ready" && !REEL) {
REELWANT = false;
hideMsg("msg");
var rl = await api("reel", { draft_id: id });
if (rl.ok && (rl.shots || []).length) {
REEL = rl.shots; RAT = -1;
pane.innerHTML = paneHtml(meta, r.draft);
reelShow(0);
}
}
}
// The story the writer committed to before writing a shot. This is where a
// misreading of the book is visible and cheap to fix; by the time it is
// twenty-six shots and a storyboard, it is neither.
function spineHtml() {
var sp = PKG.spine;
if (!sp || typeof sp !== "object") return "";
var p = sp.protagonist || {};
function ul(items) {
return (!items || !items.length) ? ""
: '<ul>' + items.map(function (x) {
return '<li>' + esc(typeof x === "string" ? x : JSON.stringify(x)) + '</li>';
}).join("") + '</ul>';
}
var beats = (sp.beats || []).map(function (b) {
return '<li>' + esc(b.what || "")
+ (b.is_ending ? ' <b>(the ending)</b>' : '')
+ (b.why_it_matters ? ' <span class="kv">' + esc(b.why_it_matters) + '</span>' : '')
+ '</li>'; }).join("") + addWriterHtml();
return '<div class="spine"><h4>The story, as the writer worked it out from the book</h4>'
+ (sp.the_engine ? '<p class="eng">' + esc(sp.the_engine) + '</p>' : '')
+ (p.who ? '<div class="kv"><b>who</b> ' + esc(p.who) + '</div>' : '')
+ (p.wants ? '<div class="kv"><b>wants</b> ' + esc(p.wants) + '</div>' : '')
+ (p.obstacle ? '<div class="kv"><b>in the way</b> ' + esc(p.obstacle) + '</div>' : '')
+ (p.cost ? '<div class="kv"><b>cost</b> ' + esc(p.cost) + '</div>' : '')
+ (sp.what_a_viewer_must_understand && sp.what_a_viewer_must_understand.length
? '<h5>A viewer must come away knowing</h5>' + ul(sp.what_a_viewer_must_understand) : '')
+ (sp.moments_to_dramatise && sp.moments_to_dramatise.length
? '<h5>Must be shown, not narrated</h5>' + ul(sp.moments_to_dramatise) : '')
+ (sp.premise ? '<details><summary>the premise in full</summary><p>'
+ esc(sp.premise) + '</p></details>' : '')
+ (beats ? '<details><summary>the beats of the book</summary><ol>' + beats + '</ol></details>' : '')
+ (sp.withhold ? '<details><summary>what the trailer is withholding</summary><p>'
+ esc(sp.withhold) + '</p></details>' : '')
+ '</div>';
}
function barHtml(meta) {
var shots = (PKG.shots || []), cast = (PKG.characters || []);
var undrawn = shots.filter(function (s) { return !s.opening_url; }).length;
// A clip filmed from a picture or words that have since changed is not the shot any more.
var unfilmed = shots.filter(function (s) { return !s.clip_url || s.clip_stale; }).length;
var changed = shots.filter(function (s) { return s.clip_url && s.clip_stale; }).length;
var unfilmedUsd = filmCostAll(shots.filter(function (s) { return !s.clip_url || s.clip_stale; }));
var filmed = shots.filter(function (s) { return s.clip_url; }).length; // a changed shot is still filmed
var imgs = boardImages(shots, cast);
var s = String(meta.draw_state || "");
var drawing = s && !/^(drawn|failed)/.test(s);
// A part of a chapter is always "the script" - there is nobody to choose between.
var isLead = meta.writer === RUN.lead_writer || /^part-/.test(String(meta.writer));
var out = [];
if (!isLead) out.push('<button type="button" class="btn primary" data-lead="'
+ esc(meta.id) + '">Make this the head writer</button>');
else out.push('<span class="soon">★ head writer</span>');
if (drawing) out.push('<button type="button" class="btn" disabled>Drawing… ' + esc(s) + '</button>');
else if (undrawn) out.push('<button type="button" class="btn" data-draw="' + esc(meta.id)
+ '">Draw ' + (undrawn === shots.length ? 'this storyboard'
: undrawn + ' undrawn shot' + (undrawn === 1 ? '' : 's'))
// The worker counts what it will really draw - looks, back views and
// frames - so the price here is the price. `imgs` knew only about three
// sheets a head and two frames a shot.
+ ' &middot; $' + ((Number(PKG.pictures_to_draw) || imgs) * 0.068).toFixed(2) + '</button>');
else out.push('<span class="soon">storyboard drawn'
+ (meta.draw_usd ? ' &middot; $' + Number(meta.draw_usd).toFixed(2) : '') + '</span>');
if (isLead) {
var others = DRAFTS.filter(function (d) {
return d.writer !== RUN.lead_writer && /^ready/.test(String(d.state)); }).length;
if (RUN.stage === "reviewing")
out.push('<button type="button" class="btn" disabled>Readers are watching it…</button>');
else if (others)
out.push('<button type="button" class="btn" data-review="1" title="'
+ 'Each other model reads ONLY this script - no book - and says what it understood.">'
+ (REVIEWS.length ? "Test it again" : "Does it make sense? Ask " + others + " readers")
+ '</button>');
if ((PKG.shots || []).length >= 6 && !busyCuts())
out.push('<button type="button" class="btn" data-cuts="' + esc(meta.id)
+ '" title="Several shorter versions of what is already filmed. '
+ 'Costs nothing to propose or to watch.">'
+ ((PKG.cuts || []).length ? "Propose more cuts" : "Propose shorter cuts")
+ '</button>');
out.push('<button type="button" class="btn" data-measure="' + esc(meta.id)
+ '" title="Speaks every line in the cast voices and reports what the '
+ 'trailer really runs.">'
+ ((PKG.timing && PKG.timing.measured_seconds) ? "Time it again" : "Time it for real")
+ '</button>');
if (RUN.stage === "revising")
out.push('<button type="button" class="btn" disabled>Rewriting…</button>');
else {
var over = (PKG.timing && Number(PKG.timing.over_by)) || 0;
// Always on offer. It used to appear only once a note existed, and
// a note typed a moment ago does not repaint the bar - so an author
// wrote their notes and found nothing to press.
if (true)
out.push('<button type="button" class="btn' + (over > 5 ? ' primary' : '')
+ '" data-revise="1">'
+ (over > 5 ? 'Cut ' + Math.round(over) + ' seconds and revise'
: 'Revise the whole script') + '</button>');
}
if (filmed) {
var es = String(PKG.eyes_state || "");
var looking = es && es !== "ready" && !/^failed/.test(es)
&& (Date.now() - (Date.parse(PKG.eyes_started || "") || 0)) < 20 * 60 * 1000;
if (looking)
out.push('<button type="button" class="btn" disabled>Looking at the '
+ 'footage… ' + esc(es) + '</button>');
else
out.push('<button type="button" class="btn" data-eyes="' + esc(meta.id)
+ '" title="Samples frames from every clip and asks what is wrong '
+ 'with the picture, then whether it matches the script.">'
+ (es === "ready" ? "Check the picture again" : "Check the picture")
+ ' &middot; ' + "$" + (filmed * 0.02).toFixed(2)
+ '</button>');
}
if (unfilmed && !undrawn)
out.push('<button type="button" class="btn primary" data-film="' + esc(meta.id)
+ '">Film ' + (changed ? 'the ' + unfilmed + ' shots that are new or have changed' : (filmed ? 'the ' + unfilmed + ' remaining shots' : 'all ' + shots.length + ' shots'))
+ ' &middot; about $' + unfilmedUsd.toFixed(2) + '</button>');
if (filmed)
out.push('<button type="button" class="btn primary" data-assemble="' + esc(meta.id)
+ '">Make the trailer &middot; ' + filmed + ' of ' + shots.length + ' shots</button>');
}
return out.join("");
}
// A cut may move shots as well as drop them, which makes it a different
// film rather than a shorter one. Worth saying out loud.
function reordered(ids) {
var a = (ids || []).map(String);
var b = a.slice().sort();
return a.join("") !== b.join("");
}
// What the storyboard will actually cost to draw. Three sheets for every
// character who is seen or heard, one more per extra day they are dressed
// differently, and two frames per shot.
function boardImages(shots, cast) {
var seen = {};
shots.forEach(function (s) {
(s.visible_characters || []).forEach(function (c) { seen[String(c)] = 1; });
(s.audio || []).forEach(function (a) {
if (String(a.text || "").trim()) seen[String(a.character_id)] = 1; });
});
var present = cast.filter(function (c) { return seen[String(c.id)]; });
var days = {};
shots.forEach(function (s) {
var d = String(s.day || "").trim();
if (!d) return;
(s.visible_characters || []).forEach(function (c) {
(days[String(c)] = days[String(c)] || {})[d] = 1; });
});
var extra = 0;
present.forEach(function (c) {
var wd = c.wardrobe_by_day || {};
var base = Object.keys(wd).sort()[0] || "";
Object.keys(days[String(c.id)] || {}).forEach(function (d) {
if (d !== base && wd[d]) extra += 1; });
});
return present.length * 3 + extra + shots.length * 2;
}
function mmss(n) {
return Math.floor(n / 60) + ":" + String(Math.round(n % 60)).padStart(2, "0");
}
// The header's runtime is the sum of what the writer typed. This is what the
// lines take when the cast actually says them, which is a different number
// and the only one worth cutting against.
function timingHtml() {
var t = PKG.timing;
if (!t || !t.measured_seconds) return "";
var over = Number(t.over_by || 0);
var line = "Spoken by the cast this script runs <b>" + mmss(t.measured_seconds)
+ "</b>, against a " + mmss(t.target_seconds) + " target";
if (over > 5) line += " — <b>" + Math.round(over) + " seconds over</b>."
+ " Revising the whole script will cut that much.";
else if (over < -15) line += " — " + Math.round(-over) + " seconds short.";
else line += " — that fits.";
var worst = (t.longest_lines || []).slice(0, 3).map(function (l) {
return "<li>" + Number(l.seconds || 0).toFixed(1) + "s &middot; shot "
+ esc(String(l.shot)) + " &middot; “"
+ esc(String(l.text || "").slice(0, 80)) + "”</li>"; }).join("");
return '<div class="timing' + (over > 5 ? " over" : "") + '"><div>' + line + "</div>"
+ (worst && over > 5 ? "<ul>" + worst + "</ul>" : "") + "</div>";
}
// A job lives in the worker's memory and dies with it. Every deploy kills
// one, and the flag it set stays set, so anything older than this is treated
// as gone rather than leaving the page waiting on it for ever.
function busyCuts() {
if (String(PKG.cuts_state || "") !== "thinking") return false;
var t = Date.parse(PKG.cuts_started || "");
if (!t) return false;
return (Date.now() - t) < 15 * 60 * 1000;
}
// ---- the storyboard player -------------------------------------------
// One call fetches the whole reel; after that nothing touches the network,
// which is what makes stop and jump-to instant.
var REEL = null, RAT = -1, RTIMER = null;
var reelAudio = new Audio();
function reelHtml() {
var st = String(PKG.reel_state || "");
var shots = (PKG.shots || []);
if (!shots.length) return "";
var running = st && st !== "ready" && !/^failed/.test(st);
var out = '<div class="reel"><h4>Watch the storyboard</h4>'
+ '<p class="lede">The frames in order with the lines spoken over them — '
+ 'the trailer before it is filmed. Sequence problems are obvious here '
+ 'and nearly invisible in the script below.</p>';
if (running)
out += '<p class="busy">Speaking every shot… ' + esc(st) + '</p>';
else if (st === "ready") {
var spoken = shots.filter(function (s) { return s.reel_url; }).length;
var silent = shots.length - spoken;
out += '<p class="lede">' + spoken + ' shots have dialogue and are spoken'
+ (silent ? '; ' + silent + ' are silent and hold on the frame' : '')
+ '.</p>';
}
else if (/^failed/.test(st))
out += '<p class="cont">' + esc(st) + '</p>';
else if (st)
out += '<p class="cont">The last attempt stopped before it finished — '
+ 'the server restarted under it. Ask again.</p>';
if (!REEL) {
// A storyboard is its pictures. With some missing it plays as a slide
// show with holes, which tells the author nothing about the sequence.
var toDraw = Number(PKG.pictures_to_draw || 0);
if (toDraw > 0)
out += '<p class="cont" style="text-align:center">' + toDraw + ' picture'
+ (toDraw === 1 ? ' is' : 's are') + ' still to be drawn. Use <b>Draw this storyboard</b> '
+ 'above, then come back here.</p>';
out += '<div class="tools" style="justify-content:center">'
+ '<button type="button" class="btn primary" data-reelgo="1"'
+ ((running || toDraw > 0) ? ' disabled' : '') + '>'
+ (st === "ready" ? "Play the storyboard" : "Prepare and play")
+ '</button></div></div>';
return out;
}
var s = REEL[RAT] || REEL[0] || {};
var lines = (s.lines || []).map(function (l) {
return '<div><span class="who">' + esc(l.who || "")
+ (l.lip_sync ? "" : " (v.o.)") + '</span>' + esc(l.text || "") + '</div>';
}).join("") || '<div class="w">no dialogue in this shot</div>';
out += '<div class="reelframes">'
+ '<figure><img id="rfa" class="on" src="' + esc(s.opening_url || "")
+ '" alt=""><figcaption>opens on</figcaption></figure>'
+ '<figure><img id="rfb" src="' + esc(s.closing_url || "")
+ '" alt=""><figcaption>ends on</figcaption></figure></div>'
+ '<div class="reelnow"><div class="t">' + esc(s.id) + ' · '
+ esc(s.title || "") + '</div>'
+ '<div class="w">' + esc(s.setting || "")
+ (s.scene ? ' · ' + esc(s.scene) : "")
+ (s.day ? ' · ' + esc(s.day) : "") + '</div>'
+ '<div class="said">' + lines + '</div></div>'
+ '<div class="reelprog"><i id="rprog"></i></div>'
+ '<div class="reelbar">'
+ '<button type="button" class="btn" data-reelstep="-1">◀ back</button>'
+ '<button type="button" class="btn primary" data-reeltoggle="1">'
+ (reelAudio.paused ? "Play" : "Pause") + '</button>'
+ '<button type="button" class="btn" data-reelstep="1">next ▶</button>'
+ '<button type="button" class="btn" data-reelstop="1">Stop</button>'
+ '<button type="button" class="btn" data-reeljump="' + esc(s.id)
+ '">Jump to this shot</button>'
+ '<span class="pos">' + (RAT + 1) + ' of ' + REEL.length + '</span>'
+ '</div></div>';
return out;
}
function reelPaint() {
var host = document.getElementById("reelhost");
if (host) host.innerHTML = reelHtml();
}
// The two frames split the shot's running time: the opening frame holds for
// the first half, the closing frame for the second. It is a storyboard, not
// an animation, but it reads as movement and it marks where the shot lands.
function reelShow(i) {
if (!REEL || i < 0 || i >= REEL.length) { reelStop(); return; }
RAT = i;
reelPaint();
var s = REEL[i];
var dur = Math.max(Number(s.seconds) || 0, Number(s.edit_seconds) || 0, 2.5);
if (s.audio_url) { reelAudio.src = s.audio_url; reelAudio.play().catch(function(){}); }
else reelAudio.pause();
var t0 = Date.now(), swapped = false;
clearInterval(RTIMER);
RTIMER = setInterval(function () {
var gone = (Date.now() - t0) / 1000;
var bar = document.getElementById("rprog");
if (bar) bar.style.width = Math.min(100, 100 * gone / dur) + "%";
if (!swapped && gone > dur / 2) {
swapped = true;
var a = document.getElementById("rfa"), b = document.getElementById("rfb");
if (a && b && REEL[RAT].closing_url) { a.classList.remove("on"); b.classList.add("on"); }
}
if (gone >= dur) reelShow(RAT + 1);
}, 120);
}
function reelStop() {
clearInterval(RTIMER); RTIMER = null;
reelAudio.pause();
REEL = null; RAT = -1;
openTab(CUR);
}
// A cut is an ordering, not a render. Playing one is free, so all of them
// can be looked at before anything is joined into a file.
function cutsHtml(meta) {
if (meta.writer !== RUN.lead_writer) return "";
var shots = PKG.shots || [];
var filmed = shots.filter(function (s) { return s.clip_url; }).length;
var cuts = PKG.cuts || [];
var st = String(PKG.cuts_state || "");
var out = '<div class="cuts"><h4>Shorter cuts of this same shoot</h4>'
+ '<p class="lede">Nothing here is re-filmed. A cut is an order to play '
+ 'the shots in, so making another one costs nothing — which is why '
+ 'the question is not “what is the right length” but “which '
+ 'version do people watch to the end”.</p>';
if (busyCuts()) out += '<p class="busy">Working out what to drop…</p>';
else if (st === "thinking") out += '<p class="cont">The last attempt stopped '
+ 'before it finished — the server restarted under it. Ask again.</p>';
else if (/^failed/.test(st)) out += '<p class="cont">' + esc(st) + '</p>';
out += cuts.map(function (c) {
var stat = STATS[c.id];
var play = (PLAYING === c.id)
? '<div class="playbox"><video id="cutv" controls playsinline></video>'
+ '<div class="bar2"><span id="cutat">loading…</span>'
+ '<button type="button" class="btn" data-stop="1">Close</button></div></div>'
: '';
var tools = '<button type="button" class="btn" data-play="' + esc(c.id) + '">'
+ (PLAYING === c.id ? 'Playing' : 'Watch it · free') + '</button>';
if (c.state === "made" && c.video_url)
tools += '<a class="btn" href="/videos.html">Published · in My videos</a>';
else if (c.state === "making")
tools += '<span class="busy">joining…</span>';
else if (filmed)
tools += '<button type="button" class="btn primary" data-makecut="' + esc(c.id)
+ '">Publish this version</button>';
return '<div class="cut"><b>' + esc(c.name || "") + '</b>'
+ '<span class="len">' + mmss(c.seconds || 0) + ' · '
+ (c.shot_ids || []).length + ' shots</span>'
+ (c.strategy ? '<p class="strat">' + esc(c.strategy) + '</p>' : '')
+ (c.why ? '<p class="why">' + esc(c.why) + '</p>' : '')
+ (c.sacrifices ? '<p class="sac">gives up: ' + esc(c.sacrifices) + '</p>' : '')
+ (stat ? '<p class="stat">' + stat.views + ' viewer'
+ (stat.views === 1 ? '' : 's') + ' · median ' + stat.median_percent
+ '% watched · ' + stat.completion_rate + '% reached the end</p>' : '')
+ '<p class="ids">' + esc((c.shot_ids || []).join(" "))
+ (reordered(c.shot_ids) ? ' &middot; reordered' : '') + '</p>'
+ '<div class="tools">' + tools + '</div>' + play + '</div>';
}).join("");
if (!cuts.length && st !== "thinking")
out += '<p class="cont">No cuts proposed yet.</p>';
return out + '</div>';
}
function reviewsHtml(meta) {
var mine = REVIEWS.filter(function (r) {
return r.subject === meta.writer && Number(r.iteration) === Number(meta.iteration); });
if (!mine.length) return "";
var gaps = mine.filter(function (r) { return r.reviewer === "__gaps__"; })[0];
var readers = mine.filter(function (r) { return r.reviewer !== "__gaps__"; });
var out = "";
if (gaps) {
var v = gaps.strengths && gaps.strengths.verdict;
var list = gaps.suggestions || [];
out += '<div class="gaps"><h4>What this trailer does not tell a viewer</h4>'
+ (v ? '<p class="verdict">' + esc(v) + '</p>' : '')
+ (list.length ? '<ol>' + list.map(function (g) {
return '<li>' + esc(g.what || "")
+ '<span class="fix"><span class="sh">shot ' + esc(g.shot || "?") + '</span> &rarr; '
+ esc(g.fix || "") + '</span></li>'; }).join("") + '</ol>' : '')
+ '</div>';
}
out += readers.map(function (r) {
var s = r.strengths || {}, e = r.errors || {};
if (typeof s !== "object") s = {};
if (typeof e !== "object") e = {};
function ul(items, cls) {
return (!items || !items.length) ? ""
: '<ul class="' + cls + '">' + items.map(function (x) {
return '<li>' + esc(typeof x === "string" ? x : JSON.stringify(x)) + '</li>';
}).join("") + '</ul>';
}
return '<div class="rd"><h4>' + esc(r.label) + ' watched it without reading the book'
+ (r.score ? '<span class="n" title="how much of the real story they got">'
+ r.score + '/100</span>' : '') + '</h4>'
+ (s.summary ? '<blockquote>' + esc(s.summary) + '</blockquote>' : '')
+ '<div class="kv">main character: ' + esc(s.who || "not clear to them") + '</div>'
+ '<div class="kv">what they want: ' + esc(s.wants || "not clear to them") + '</div>'
+ '<div class="kv">what is in the way: ' + esc(s.obstacle || "not clear to them") + '</div>'
+ (s.would_watch ? '<div class="kv">would they watch the film: '
+ esc(s.would_watch) + '</div>' : '')
+ (s.unanswered && s.unanswered.length
? '<div class="kv">still asking:</div>' + ul(s.unanswered, "") : '')
+ (e.missed && e.missed.length ? '<div class="kv">they missed:</div>' + ul(e.missed, "") : '')
+ (e.got_wrong && e.got_wrong.length
? '<div class="kv wrong">the trailer misled them:</div>' + ul(e.got_wrong, "wrong") : '')
+ '</div>';
}).join("");
return '<div class="revs">' + out + '</div>';
}
function paneHtml(meta, draft) {
var p = PKG, shots = (p.shots || []).slice().sort(function (a, b) {
return String(a.id) < String(b.id) ? -1 : 1; });
var cast = p.characters || [], voice = {};
cast.forEach(function (c) { voice[c.id] = c; });
var secs = shots.reduce(function (a, s) { return a + (Number(s.edit_seconds) || 0); }, 0);
var head = '<div class="head"><h2>' + esc(p.title || meta.label) + '</h2>'
+ (p.tagline ? '<span class="tag">' + esc(p.tagline) + '</span>' : '') + '</div>'
+ (p.logline ? '<p>' + esc(p.logline) + '</p>' : '')
+ '<p class="facts">' + esc(meta.label)
+ (Number(meta.iteration) > 1 ? ' &middot; revision ' + meta.iteration : '')
+ ' &middot; ' + shots.length + ' shots &middot; '
+ Math.floor(secs / 60) + ':' + String(Math.round(secs % 60)).padStart(2, "0")
+ ' &middot; ' + cast.length + ' characters'
+ (PKG.book_words ? ' &middot; spoken lines: <b>' + PKG.book_words.quote + '</b> from the book, <b>' + PKG.book_words.close
+ '</b> close to it, <b>' + PKG.book_words["new"] + '</b> written for the trailer' : '') + '</p>'
+ timingHtml();
var probs = draft.problems
? '<div class="probs"><b>The checker has objections</b><ul>'
+ draft.problems.split("\n").filter(Boolean).map(function (x) {
return '<li>' + esc(x) + '</li>'; }).join("") + '</ul></div>'
: "";
var faceless = cast.filter(function (c) { return !c.head_url && c.id !== "Narrator" && !/no visible body/i.test(c.appearance || "")
&& !((c.look_candidates || {}).items || []).length; }).length;
var castBar = faceless
? '<p class="tiny" style="margin:6px 0 8px">' + faceless + ' of the cast have no face yet - the reference sheets are drawn with the first picture. '
+ (PKG.cast_state ? '<b>Drawing the cast&hellip;</b>' : '<button type="button" class="btn small" data-drawcast="' + esc(draft.id) + '">Draw the cast now</button> (about $' + (faceless * 4 * 0.068).toFixed(2) + ', no shots, no video)') + '</p>'
: '';
var castHtml = castBar + '<div class="cast">' + cast.map(function (c) {
var nm = c.name || c.id;
var sheets = c.head_url
? '<div class="sheets"><img data-zoom="' + esc(nm + " — the face every picture is drawn from")
+ '" src="' + esc(c.head_url) + '" alt=""></div>'
: (c.id !== "Narrator" && !/no visible body/i.test(c.appearance || "") ? '<div class="sheets tiny" style="color:var(--muted)">' + (((c.look_candidates || {}).items || []).length ? 'candidates drawn - choose one' : 'no face drawn yet') + '</div>' : '');
return '<div class="ch">' + sheets + '<b>' + esc(nm) + '</b>'
+ '<span class="vo">' + esc(c.role || "") + (c.voice_id ? " · voiced" : " · silent")
+ '</span><div class="ap">' + esc((c.appearance || "").slice(0, 150)) + '</div>'
+ (c.id !== "Narrator" ? '<div class="ap"><a href="/looks.html?draft=' + encodeURIComponent(draft.id) + '&character=' + encodeURIComponent(c.id)
+ '&run=' + encodeURIComponent((RUN && RUN.id) || '') + '">' + (c.look_chosen ? 'cast from candidate ' + (c.look_chosen.index + 1) : 'draw candidates and cast') + '</a></div>' : '')
+ looksHtml(c) + '</div>';
}).join("") + '</div>';
// A CHAPTER IS A HUNDRED SHOTS. Twelve to a page, with back and next, so
// the page stays quick and the author is never scrolling through a wall.
var per = 12, pages = (shots.length > 24 && !hasScenes(shots)) ? Math.ceil(shots.length / per) : 1;
if (PAGE_FOR !== draft.id) { PAGE_FOR = draft.id; PAGE = 0; }
PAGE = Math.max(0, Math.min(PAGE, pages - 1));
var shown = pages > 1 ? shots.slice(PAGE * per, (PAGE + 1) * per) : shots;
var pager = pages > 1
? '<div class="bar" style="justify-content:center;gap:12px;margin:14px 0">'
+ '<button type="button" class="btn" data-page="prev"' + (PAGE === 0 ? ' disabled' : '') + '>&#9664; Back</button>'
+ '<span>Shots <b>' + esc(shown[0].id) + '</b> to <b>' + esc(shown[shown.length - 1].id) + '</b> of ' + shots.length
+ ' &middot; page <select data-pagepick="1">' + Array.apply(null, Array(pages)).map(function (_, i) {
return '<option value="' + i + '"' + (i === PAGE ? ' selected' : '') + '>' + (i + 1) + '</option>'; }).join("")
+ '</select> of ' + pages + '</span>'
+ '<button type="button" class="btn" data-page="next"' + (PAGE >= pages - 1 ? ' disabled' : '') + '>Next &#9654;</button></div>'
: "";
var body;
if (hasScenes(shots)) body = scenesHtml(shots, voice, draft);
else {
body = pager + (PAGE === 0 ? insertHtml("start") : "");
shown.forEach(function (s) { body += shotHtml(s, voice) + insertHtml(s.id); });
body += pager;
}
DRAFTNOW = draft;
return head + partsHtml(draft) + contestHtml() + guideHtml(meta) + probs + '<div class="bar">' + barHtml(meta) + '</div>'
+ '<div id="reelhost">' + reelHtml() + '</div>'
+ spineHtml() + cutsHtml(meta) + reviewsHtml(meta) + wholeHtml() + filmCheckHtml() + speakerNoticeHtml(draft) + viewsHtml(draft) + castHtml + propsHtml() + body;
}
// THINGS THAT MUST STAY THE SAME. A face is held steady by a reference
// picture; so is an urn, and so is a car. Without one, the object the whole
// story is about was three different objects in three shots.
function propsHtml() {
var props = PKG.props, busy = String(PKG.props_state || "");
var head = '<div class="props"><h3 style="margin:18px 0 4px">Things that must look the same in every shot</h3>';
if (busy) head += '<p class="busy">' + esc(busy) + '\u2026 about a minute.</p>';
if (PKG.props_note) head += '<p class="cont" style="color:#b4531f">' + esc(PKG.props_note) + '</p>';
if (!props || !props.length)
return head + '<p class="cont">An object or a car that appears in more than one shot needs a reference '
+ 'picture, the way each character has one - otherwise it is drawn differently every time. '
+ (busy ? '' : '<button type="button" class="btn" data-findprops="1">Find them in this script &middot; about $0.30</button>')
+ '</p></div>';
return head + '<div class="cast">' + props.map(function (p) {
var pics = [["url", p.kind === "vehicle" ? "outside" : ""], ["interior_url", "inside"]].map(function (f) {
return p[f[0]] ? '<img data-zoom="' + esc(p.name + (f[1] ? " \u2014 " + f[1] : "")) + '" src="' + esc(p[f[0]]) + '" alt="">' : ''; }).join("");
var where = (p.shots || []).map(function (x) { return x.id; }).join(", ");
var drawn = (p.shots || []).filter(function (x) {
return (PKG.shots || []).some(function (q) { return String(q.id) === String(x.id) && q.opening_url; }); }).length;
return '<div class="ch"><div class="sheets">' + (pics || '<i class="lw">not drawn yet</i>') + '</div>'
+ '<b>' + esc(p.name || p.id) + '</b><span class="vo">seen in shots <input type="text" style="width:12em;font-size:12px" data-propshotlist="'
+ esc(p.id) + '" value="' + esc(where) + '"></span>'
+ '<textarea rows="5" style="width:100%;font-size:12px;margin-top:6px" data-propdesc="' + esc(p.id) + '">'
+ esc(p.description || "") + '</textarea>'
+ (busy ? '' : '<div class="tools" style="margin-top:6px">'
+ '<button type="button" class="btn" data-propsave="' + esc(p.id) + '" title="Saves the description and the list of shots. The picture is drawn again only if the description changed.">Save</button>'
+ '<button type="button" class="btn" data-propsave="' + esc(p.id) + '" data-again="1">Draw its picture again &middot; $'
+ (p.kind === "vehicle" ? "0.14" : "0.07") + '</button>'
+ (drawn ? '<button type="button" class="btn" data-propshots="' + esc(p.id) + '">Redraw the ' + drawn
+ ' shot' + (drawn === 1 ? '' : 's') + ' that show it &middot; $' + (drawn * 0.14).toFixed(2) + '</button>' : '')
+ '<button type="button" class="btn" data-propdel="' + esc(p.id) + '">Stop tracking it</button></div>')
+ '</div>';
}).join("") + '</div>'
+ (busy ? '' : '<p class="cont"><button type="button" class="btn" data-findprops="1">Look through the script again &middot; about $0.10</button></p>')
+ '</div>';
}
// Every look a character has, in the order the story reaches them: the
// clothes in words, then front, side and back. A look nobody wears any more
// is kept (the author may change their mind back) but shown faded.
function looksHtml(c) {
var looks = c.looks || {};
var keys = Object.keys(looks).sort(function (a, b) {
return String(looks[a].first_shot || "").localeCompare(String(looks[b].first_shot || "")); });
if (!keys.length) return "";
var nm = c.name || c.id;
return keys.map(function (k) {
var L = looks[k], pics = [["front_url", "front"], ["side_url", "side"], ["back_url", "back"]];
var imgs = pics.map(function (p) {
return L[p[0]] ? '<img data-zoom="' + esc(nm + " — " + p[1] + " — " + (L.wear || ""))
+ '" src="' + esc(L[p[0]]) + '" alt="">' : ''; }).join("");
return '<div class="lookset' + (L.in_use === false ? ' idle' : '') + '">'
+ '<div class="lw">from shot <b>' + esc(L.first_shot || "?") + '</b>: ' + esc(L.wear || "")
+ (L.state ? ' <span style="color:#b4531f">· ' + esc(L.state) + '</span>' : '')
+ '</div>' + (imgs ? '<div class="sheets">' + imgs + '</div>'
: '<div class="lw"><i>not drawn yet</i></div>')
+ '</div>';
}).join("");
}
// WHAT TO DO NEXT. The steps of making a trailer, in order, with the one
// button that matters now. Everything else on the page is still there for
// anyone who wants it; nobody has to understand it to get a film made.
function guideHtml(meta) {
var shots = PKG.shots || [];
if (!shots.length || (meta.writer !== RUN.lead_writer && !/^part-/.test(String(meta.writer)))) return "";
var ds = String(meta.draw_state || "");
var drawing = ds && !/^(drawn|failed)/.test(ds);
var toDraw = Number(PKG.pictures_to_draw || 0);
var drawn = shots.filter(function (x) { return x.opening_url; });
var unchecked = drawn.filter(function (x) {
return !x.clip_url && !((x.frame_check || {}).verdict); }).length;
var faulty = drawn.filter(function (x) {
return !x.clip_url && (x.frame_check || {}).verdict === "broken"; });
var ps = String(PKG.pics_state || "");
var checking = ps && ps !== "ready";
var reelReady = String(PKG.reel_state || "") === "ready";
var unfilmed = shots.filter(function (x) { return !x.clip_url || x.clip_stale; }).length;
var unfilmedUsd2 = filmCostAll(shots.filter(function (x) { return !x.clip_url || x.clip_stale; }));
var objections = String((DRAFTNOW || {}).problems || "").split("\n").filter(Boolean).length;
var price = function (n) { return "$" + n.toFixed(2); };
var steps = [];
steps.push({ t: "Read the script", done: true,
d: objections ? "The checker has " + objections + " objection" + (objections === 1 ? "" : "s")
+ " - they are listed just below." : "" });
steps.push({ t: "Tell the writer what to change", done: true, optional: true,
d: "Write in the box \u201cYour notes on the script as a whole\u201d, or under any shot, then press "
+ "\u201cRevise the whole script\u201d. Skip this if you are happy." });
var s3 = { t: "Draw the pictures", done: toDraw === 0 && drawn.length > 0 };
if (drawing) s3.d = "Drawing now\u2026 " + esc(ds) + ". You can leave this page open.";
else if (!s3.done) s3.btn = '<button type="button" class="btn primary" data-draw="' + esc(meta.id)
+ '">Draw ' + toDraw + ' picture' + (toDraw === 1 ? '' : 's') + ' \u00b7 ' + price(toDraw * 0.068) + '</button>';
steps.push(s3);
var s4 = { t: "Check the pictures", done: s3.done && !unchecked && !faulty.length };
if (checking) s4.d = "Looking at every picture for mistakes\u2026 " + esc(ps);
else if (s3.done && unchecked) {
s4.d = "A second model looks at every picture for the mistakes these tools make - the wrong time "
+ "of day, the wrong clothes, extra people, six fingers - and redraws any that are broken.";
s4.btn = '<button type="button" class="btn primary" data-chkpics="1">Check ' + unchecked
+ ' picture' + (unchecked === 1 ? '' : 's') + ' \u00b7 ' + price(unchecked * 0.02) + '</button>';
} else if (s3.done && faulty.length)
s4.d = faulty.length + " picture" + (faulty.length === 1 ? " still has" : "s still have")
+ " a problem: shot" + (faulty.length === 1 ? " " : "s ")
+ faulty.map(function (x) { return x.id; }).join(", ")
+ ". Scroll to " + (faulty.length === 1 ? "it" : "them") + " and press \u201cFix this\u201d, "
+ "or carry on if you think the checker is being fussy.";
steps.push(s4);
var s5 = { t: "Listen to the storyboard", done: reelReady };
if (s3.done && !reelReady)
s5.btn = '<button type="button" class="btn' + (s4.done ? ' primary' : '')
+ '" data-reelgo="1">Play it with the voices</button>';
steps.push(s5);
var s6 = { t: "Film it", done: unfilmed === 0 };
if (s3.done && unfilmed)
s6.btn = '<button type="button" class="btn' + (s4.done && reelReady ? ' primary' : '')
+ '" data-film="' + esc(meta.id) + '">Film ' + unfilmed + ' shot' + (unfilmed === 1 ? '' : 's')
+ ' \u00b7 ' + price(unfilmedUsd2) + '</button>';
steps.push(s6);
var nowSet = false;
return '<div class="guide"><h4>What to do next <a href="/trailer-guide.html" target="_blank" style="font-weight:400;font-size:12px;margin-left:8px">what makes a good trailer?</a></h4><ol>' + steps.map(function (st, i) {
var isNow = !nowSet && !st.done && !st.optional;
if (isNow) nowSet = true;
return '<li class="' + (st.done ? 'done' : '') + (isNow ? ' now' : '') + '">'
+ '<span class="n">' + (st.done && !st.optional ? '\u2713' : (i + 1)) + '</span>'
+ '<span>' + esc(st.t) + (st.optional ? ' <i>(if you want)</i>' : '')
+ ((isNow || (st.btn && /primary/.test(st.btn))) && st.btn ? ' ' + st.btn : '')
+ (st.d && (isNow || st.optional || i === 0) ? '<span class="d">' + st.d + '</span>' : '')
+ '</span></li>';
}).join("") + '</ol></div>';
}
// What the checker saw in this shot's opening picture, in its own sentence.
function frameCheckHtml(s) {
var fc = s.frame_check || {};
if (!s.opening_url || s.clip_url || !fc.verdict || s.fixing || s.drawing) return "";
var faults = (fc.faults || []).filter(function (f) { return f && f.what; });
var dup = (fc.duplicated || []).filter(Boolean);
var retried = s.frame_retried ? ' <i>(' + esc(s.frame_retried) + ')</i>' : '';
if (!faults.length && !dup.length)
return '<div class="fchk ok">\u2713 picture checked - nothing wrong' + retried + '</div>';
var rows = dup.map(function (d) {
return { what: "The same person appears twice: " + d, serious: true }; })
.concat(faults.map(function (f) { return { what: f.what, serious: f.severity === "serious", zoom: f.zoom_url || "" }; }));
var line = function (r, i) {
// The close-up the checker was looking at. Nobody should have to
// take "six fingers" on trust - and sometimes it is simply wrong.
var zm = r.zoom ? '<img data-zoom="' + esc("what the checker zoomed in on") + '" src="' + esc(r.zoom)
+ '" alt="" style="height:54px;border-radius:5px;vertical-align:middle;margin-right:6px;border:1px solid var(--line)">' : '';
return '<span class="w' + (r.serious ? ' serious' : '') + '">' + zm + esc(r.what)
+ ' <button type="button" class="btn go" data-fixfault="' + esc(s.id) + '|' + i + '">Fix this</button></span>';
};
// What matters is said out loud. What merely differs from the description
// is there for anyone who wants it, and is not an alarm.
var big = "", small = "", nsmall = 0;
rows.forEach(function (r, i) { if (r.serious) big += line(r, i); else { small += line(r, i); nsmall++; } });
return '<div class="fchk ' + (fc.verdict === "broken" ? 'bad' : '') + '">'
+ (big ? '<b>The checker looked at this picture and sees:</b>' + retried + big
: '<span style="color:#1f7a3a">\u2713 picture checked - nothing that needs fixing</span>' + retried)
+ (nsmall ? '<details style="margin-top:4px"><summary style="cursor:pointer;color:var(--muted)">'
+ nsmall + ' small thing' + (nsmall === 1 ? '' : 's') + ' it noticed \u2014 look only if the picture bothers you</summary>'
+ small + '</details>' : '')
+ '</div>';
}
// A note to the CAMERA. It is added to what the video model is told when
// this shot is filmed; it redraws nothing. Useful when the still is right
// and the moving picture goes wrong - a hand that grows a finger, a car door
// that swings from the wrong edge.
function filmNoteHtml(s) {
if (!s.opening_url) return "";
return '<div class="fnote"><label>A note to the camera for this shot (optional) \u2014 it changes how the '
+ 'shot is filmed, not the pictures. Press \u201cFilm\u201d again afterwards.</label>'
+ '<input type="text" maxlength="500" data-filmnote="' + esc(s.id) + '" value="' + esc(s.film_note || "")
+ '" placeholder="For example: his left hand stays in his jacket pocket the whole time.">'
+ ((s.audio || []).some(function (a) { return a.lip_sync; })
? '<label style="margin-top:6px">How this shot’s sound is made '
+ '<select data-audiomode="' + esc(s.id) + '">'
+ [["copy", "Our recording, exactly (words and timing guaranteed; no room sound)"],
["layer", "Our recording, and the camera adds the sounds of the place"],
["voice", "The camera performs the line in our actor’s voice, in the place"]].map(function (o) {
return '<option value="' + o[0] + '"' + ((s.audio_mode || "copy") === o[0] ? ' selected' : '') + '>' + o[1] + '</option>'; }).join("")
+ '</select></label>' : '')
+ '</div>';
}
// Several versions of one picture, best first, each with what the checker
// made of it. Picking the best of four needs no vocabulary at all.
function variantsHtml(s) {
if (s.varying) return '<p class="busy">Drawing several versions of this picture and checking each one\u2026 about a minute.</p>';
var v = s.variants;
if (!v || !(v.items || []).length)
return (v && v.error) ? '<p class="cont" style="color:#b4531f">' + esc(v.error) + '</p>' : "";
return '<div class="vars"><h5>' + v.items.length + ' versions of this picture \u2014 best first</h5>'
+ '<p>' + (v.note ? 'Drawn with your note: \u201c' + esc(v.note) + '\u201d. ' : '')
+ 'Click a picture to see it large. Press \u201cUse this one\u201d under the one you like; '
+ 'the ending picture is then drawn to match it.' + (v.error ? ' ' + esc(v.error) : '') + '</p>'
+ '<div class="grid">' + v.items.map(function (it, i) {
var fc = it.check || {};
var faults = (fc.duplicated || []).filter(Boolean).map(function (d) { return "same person twice: " + d; })
.concat((fc.faults || []).filter(function (f) { return f && f.what && f.severity === "serious"; })
.map(function (f) { return f.what; }));
var minor = (fc.faults || []).filter(function (f) { return f && f.what && f.severity !== "serious"; });
var cls = fc.verdict === "broken" ? "bad" : (fc.verdict === "ok" ? "good" : "");
var say = faults.length ? faults[0]
: (minor.length ? "small things only: " + minor[0].what
: (fc.verdict ? "\u2713 the checker sees nothing wrong" : "not checked"));
return '<div class="v ' + cls + '"><img data-zoom="' + esc("shot " + s.id + " \u2014 version " + (i + 1) + ": " + say)
+ '" src="' + esc(it.url) + '" alt=""><div class="say">' + esc(String(say).slice(0, 150)) + '</div>'
+ '<button type="button" class="btn' + (i === 0 && cls !== "bad" ? ' primary' : '')
+ '" data-pickvar="' + esc(s.id) + '|' + i + '">Use this one</button></div>';
}).join("") + '</div></div>';
}
// "That is not right" - about the PICTURE, in the author's own words. The
// picture is what the shot is filmed from, so a fault here is inherited by
// the film and cannot be fixed by filming again.
function frameFaultHtml(s) {
// Why the last attempt at these pictures failed, in words - an author
// looking at an unchanged frame otherwise concludes the button is broken.
var err = s.draw_error ? '<p class="cont" style="color:#b4531f">' + esc(s.draw_error) + '</p>' : "";
// Available on a filmed shot too: a bad clip is very often a bad still,
// and that is exactly when the author needs to change the picture.
if (!s.opening_url) return err;
if (s.fixing || s.drawing)
return '<p class="busy">Redrawing this scene\u2019s pictures from your note\u2026</p>';
return '<div class="ffault"><input type="text" id="ff-' + esc(s.id) + '" maxlength="400" '
+ 'placeholder="Something wrong with this picture? Say what you see \u2014 \u201cit should be night\u201d, '
+ '\u201cthere should be nobody else in the garden\u201d\u2026">'
+ '<button type="button" class="btn go" data-ffgo="' + esc(s.id) + '">Redraw &middot; $0.16</button>'
+ '<button type="button" class="btn go" data-vargo="' + esc(s.id) + '" title="Draws four versions of this '
+ 'picture, checks each one, and lets you choose. Anything typed in the box is used as a note for all four.">'
+ 'Try 4 versions &middot; $0.35</button></div>' + err;
}
// WHO IS IN THIS SCENE, as they look in it. This is where an author changes
// a hat, a dress, a cut lip - in words, about one person, in one scene - and
// it holds from here on until they change it again.
function whoInHtml(s) {
var ids = s.visible_characters || [];
if (!ids.length) return "";
var byId = {}; (PKG.characters || []).forEach(function (c) { byId[String(c.id)] = c; });
var busy = !!String(PKG.look_state || "");
var prevLooks = {};
(PKG.shots || []).slice().sort(function (a, b) { return String(a.id).localeCompare(String(b.id)); })
.some(function (x) {
if (String(x.id) === String(s.id)) return true;
Object.keys(x.looks || {}).forEach(function (cid) { prevLooks[cid] = x.looks[cid]; });
return false; });
return '<div class="whoin">' + ids.map(function (cid) {
cid = String(cid);
var c = byId[cid] || {}, key = (s.looks || {})[cid], L = (c.looks || {})[key] || {};
var nm = c.name || cid;
var pic = L.front_url || c.front_url || c.head_url || "";
var isNew = prevLooks[cid] && prevLooks[cid] !== key;
var asked = (s.look_asks || {})[cid];
return '<div class="p">'
+ (pic ? '<img data-zoom="' + esc(nm + " in shot " + s.id + " — " + (L.wear || ""))
+ '" src="' + esc(pic) + '" alt="">'
: '<div class="none">not drawn yet</div>')
+ '<div class="t"><b>' + esc(nm) + '</b>'
+ '<div class="w">' + (isNew ? '<span class="new">changes here: </span>' : '')
+ esc(L.wear || "as described") + (L.state ? ' <span class="st">· ' + esc(L.state) + '</span>' : '')
+ (asked ? '<br><i>you asked: ' + esc(asked) + '</i>' : '') + '</div>'
+ '<input type="text" id="look-' + esc(s.id) + '-' + esc(cid) + '" maxlength="300" '
+ (busy ? 'disabled ' : '') + 'placeholder="Change how ' + esc(nm.split(" ")[0])
+ ' looks from this scene on…">'
+ '<button type="button" class="btn go" data-lookgo="' + esc(s.id) + '|' + esc(cid) + '"'
+ (busy ? ' disabled' : '') + '>' + (busy ? 'redrawing…' : 'Change') + '</button>'
+ '</div></div>';
}).join("") + '</div>';
}
// The author's note on the script as a whole. It is an ordinary note filed
// under ALL, so it saves itself like the others and the head writer is shown
// it before anything else.
function wholeHtml() {
return '<div class="whole"><h4>Your notes on the script as a whole</h4>'
+ '<p>What is missing, what should go, what order the story should be told in, '
+ 'what you want people to feel. The head writer reads this first when you press '
+ '<b>Revise the whole script</b>. Notes on a single shot go in the box under that shot.</p>'
+ '<textarea class="note" rows="7" data-note="ALL" placeholder="For example: open on Stewart, '
+ 'not on the men in suits. The traffic stop is the moment people will talk about and it is not here…">'
+ esc(NOTES.ALL || "") + '</textarea></div>';
}
function insertHtml(after) {
if (EDITING === "new:" + after) {
return '<div class="ins" style="text-align:left">'
+ '<textarea class="rough" id="newrough" rows="4" placeholder="'
+ 'What happens in this scene, who is in it and what they say. A sentence or two is enough '
+ '— the writer turns it into a proper shot."></textarea>'
+ '<div class="tools"><button type="button" class="btn primary" data-addgo="'
+ esc(after) + '">Write this scene</button>'
+ '<button type="button" class="btn" data-cancel="1">Cancel</button></div></div>';
}
return '<div class="ins"><button type="button" class="btn" data-add="' + esc(after)
+ '">+ Add a scene ' + (after === "start" ? "at the start" : "after " + esc(after)) + '</button></div>';
}
// The faults themselves, with the prompt change that would prevent each -
// which is the half that makes the check actionable rather than a verdict.
function faultsHtml(s) {
var vc = s.vision_check;
if (!vc || !(vc.faults || []).length) return "";
return '<div class="eyes">'
+ (vc.summary ? '<div class="sum">' + esc(vc.summary) + '</div>' : '')
+ vc.faults.map(function (f) {
return '<div class="f ' + (f.severity === "serious" ? "serious" : "")
+ '"><b>' + esc(f.kind || "") + '</b>'
+ (f.at != null ? ' at ' + Number(f.at).toFixed(1) + 's' : '')
+ ' &middot; ' + esc(f.what || "")
+ (f.prompt_fix ? '<span class="fix">fix: ' + esc(f.prompt_fix)
+ '</span>' : '') + '</div>';
}).join("") + '</div>';
}
// THE BACKGROUND of a shot: the place's picture with nobody in it, which
// every picture of that place is drawn from. Dialled in one shot at a time
// and carried forward until it should change ("crawl before we walk").
var BGEDIT = null;
function placeOf(s) {
if (s.place) return s.place;
var t = String(s.scene || "").toLowerCase().replace(/^(int|ext)\.?\s*\/?\s*(int|ext)?\.?\s*/, "").replace(/\s*[-–—]\s*(day|night|dawn|dusk|morning|evening|afternoon|late afternoon|continuous).*$/, "").replace(/[^a-z0-9]+/g, " ").trim();
return t;
}
function backgroundHtml(s) {
var sets = PKG.sets || {}, key = placeOf(s), st = sets[key] || {};
var url = s.set_ref || st.ref_url || "";
var busy = st.state === "drawing";
var editing = BGEDIT === s.id || (!url && !busy && BGEDIT === "auto:" + s.id);
var desc = st.description || ((s.setting || s.scene || "") + ". " + String(s.opening_image || "").replace(/\b(Dorothy|Toto|Em|Aunt Em|Henry|Uncle Henry)\b[^.]*\./g, "").trim() + " Nobody in the picture.");
var pic = url ? '<img src="' + esc(url) + '" data-zoom="' + esc("Background — " + (st.name || s.scene || key)) + '" alt="">'
: '<div class="tiny" style="width:180px">' + (busy ? 'drawing the background…' : 'no background yet') + '</div>';
var body = '<b>Background</b> <span class="tiny">' + esc(st.name || s.scene || key) + (s.place ? ' · set by you' : '') + '</span>';
if (st.error) body += '<div class="warn">' + esc(st.error) + '</div>';
if (editing) {
body += '<textarea data-bgdesc="' + esc(s.id) + '" placeholder="The place with nobody in it: what is there, where the light comes from, the time of day.">' + esc(desc) + '</textarea>'
+ '<div class="tools"><button type="button" class="btn small primary" data-bggen="' + esc(s.id) + '">' + (url ? 'Generate it again' : 'Generate background image') + ' &middot; $0.07</button>'
+ '<button type="button" class="btn small" data-bgcancel="1">Cancel</button></div>';
} else {
body += '<div class="tools">'
+ '<button type="button" class="btn small" data-bgedit="' + esc(s.id) + '"' + (busy ? ' disabled' : '') + '>' + (url ? 'Change the background' : 'Generate background image') + '</button>'
+ '<button type="button" class="btn small" data-bgcopy="' + esc(s.id) + '" title="This shot uses the background of the shot before it">Same as the previous shot</button>'
+ (url ? '<button type="button" class="btn small" data-bgforward="' + esc(s.id) + '" title="The following shots of this scene use this background">Use it for the rest of the scene</button>' : '')
+ (s.place ? '<button type="button" class="btn small" data-bgclear="' + esc(s.id) + '">Back to the scene\'s own</button>' : '')
+ '</div>'
+ (st.description && url ? '<div class="tiny" style="margin-top:4px">' + esc(String(st.description).slice(0, 220)) + '</div>' : '');
}
return '<div class="bg">' + pic + '<div class="bgtxt">' + body + '</div></div>';
}
// SCENES. Consecutive shots in one place are a scene. Every scene is a bar
// the author opens when they want it - green when every shot in it is
// filmed and current, red when it still needs work - so nobody scrolls
// through seventy cards to find one.
function hasScenes(shots) { return shots.length > 8 && shots.filter(function (x) { return x.scene; }).length > shots.length / 2; }
function sceneKey(s) { return String(s.place || s.scene || s.setting || "").toLowerCase().replace(/^(int|ext)\.?\s*\/?\s*(int|ext)?\.?\s*/, "").replace(/\s*[-–—]\s*(day|night|dawn|dusk|morning|evening|afternoon|late afternoon|continuous|the storm|the tornado).*$/, "").replace(/[^a-z0-9]+/g, " ").trim(); }
// Two keys name one place when one begins with the other ("farmyard",
// "farmyard gravel drive"). And a scene that cuts between the kitchen and
// the doorstep is ONE scene: a place that comes back within two groups
// folds everything since into itself.
function samePlace(a, b) { return a === b || (a && b && (a.indexOf(b) === 0 || b.indexOf(a) === 0)); }
function sceneGroups(shots) {
var groups = [];
shots.forEach(function (s) {
var k = sceneKey(s), cur = groups[groups.length - 1];
if (cur && samePlace(cur.key, k)) { cur.shots.push(s); return; }
var g = groups[groups.length - 2];
if (g && cur && samePlace(g.key, k) && cur.shots.length <= 3) {
groups.pop();
g.shots = g.shots.concat(cur.shots);
if (g.name.indexOf(cur.name) < 0) g.name += " / " + cur.name;
g.shots.push(s);
return;
}
groups.push({ key: k, name: s.scene || s.setting || k, shots: [s] });
});
return groups;
}
function shotDone(s) { return !!s.clip_url && !s.clip_stale && !s.picture_stale; }
var OPEN = {};
try { OPEN = JSON.parse(localStorage.getItem("ptl_open_scenes") || "{}"); } catch (e) { OPEN = {}; }
function rememberOpen() { try { localStorage.setItem("ptl_open_scenes", JSON.stringify(OPEN)); } catch (e) {} }
var SHARES = {};
function scenesHtml(shots, voice, draft) {
var out = insertHtml("start");
sceneGroups(shots).forEach(function (g, gi) {
var filmed = g.shots.filter(function (x) { return x.clip_url; }).length, done = g.shots.every(shotDone);
var busy = g.shots.some(function (x) { return x.writing || x.drawing || stillFilming(x); });
var id = draft.id + "|" + gi + "|" + g.key, open = !!OPEN[id];
var secs = g.shots.reduce(function (a, x) { return a + Number(x.edit_seconds || 0); }, 0);
out += '<button type="button" class="scenebar ' + (done ? 'ok' : 'todo') + '" data-scene="' + esc(id) + '">'
+ '<span class="arrow">' + (open ? '&#9660;' : '&#9654;') + '</span>'
+ '<span class="n">Scene ' + (gi + 1) + '</span>'
+ '<span class="t">' + esc(g.name) + '</span>'
+ '<span class="st">shots ' + esc(g.shots[0].id) + '&ndash;' + esc(g.shots[g.shots.length - 1].id) + ' &middot; ' + g.shots.length + ' shot' + (g.shots.length === 1 ? '' : 's') + ' &middot; ' + secs.toFixed(0) + ' s &middot; '
+ (busy ? 'working&hellip;' : done ? 'all filmed' : filmed + ' of ' + g.shots.length + ' filmed') + '</span></button>';
if (open) {
var ids = g.shots.filter(function (x) { return x.clip_url; }).map(function (x) { return x.id; });
out += '<div class="sceneshare">' + (ids.length ? '<button type="button" class="btn small ghost" data-sharescene="' + esc(ids.join(",")) + '" data-scenelabel="' + esc("Scene " + (gi + 1) + " - " + g.name) + '">Share this scene (' + ids.length + ' clip' + (ids.length === 1 ? '' : 's') + ', played in order)</button>' : 'Nothing filmed in this scene yet.')
+ (SHARES["scene:" + ids.join(",")] ? ' <a class="sharelink" href="' + esc(SHARES["scene:" + ids.join(",")]) + '" target="_blank">' + esc(SHARES["scene:" + ids.join(",")]) + '</a>' : '') + '</div>';
g.shots.forEach(function (x) { out += shotHtml(x, voice) + insertHtml(x.id); });
}
});
return out;
}
// THE OPENING PICTURE of a shot can be the previous shot's first frame, or
// its last frame once it is filmed - so "catching up with the bouncing
// ball" starts with the ball, the dog and Dorothy exactly where the last
// clip left them.
function prevShot(s) {
var all = (PKG.shots || []).slice().sort(function (a, b) { return String(a.id).localeCompare(String(b.id)); });
var i = all.findIndex(function (x) { return x.id === s.id; });
return i > 0 ? all[i - 1] : null;
}
function openingHtml(s) {
var p = prevShot(s);
if (!p) return "";
var from = s.opening_from ? ' <span class="tiny">now: the ' + esc(s.opening_from.frame) + ' frame of shot ' + esc(s.opening_from.shot) + '</span>' : (s.opening_url ? ' <span class="tiny">now: drawn for this shot</span>' : '');
return '<div class="sceneshare" style="margin:4px 0 8px"><b style="color:var(--ink)">Opening picture</b>'
+ '<button type="button" class="btn small ghost" data-useframe="first|' + esc(s.id) + '"' + (p.opening_url ? '' : ' disabled title="Shot ' + esc(p.id) + ' has no picture yet"') + '>Same as shot ' + esc(p.id) + '\'s first frame</button>'
+ '<button type="button" class="btn small ghost" data-useframe="last|' + esc(s.id) + '"' + (p.clip_url ? '' : ' disabled title="Shot ' + esc(p.id) + ' has not been filmed yet"') + '>Same as shot ' + esc(p.id) + '\'s last frame</button>'
+ from + '</div>';
}
function shotHtml(s, voice) {
if (s.writing) {
return '<div class="shot"><div class="num">' + esc(s.id) + '</div><div class="body">'
+ '<h3>' + esc(s.title || "New scene") + '</h3>'
+ '<p class="busy">' + (s.making ? 'Making this shot: ' + esc(s.making) + ' · the rewrite takes a minute or two, the picture about a minute, then the clip 3 to 5 minutes. This page refreshes itself.' : 'The writer is turning your note into a shot…') + '</p>'
+ '<p class="cont">' + esc(String(s.rough || "").slice(0, 300)) + '</p></div></div>';
}
var lines = (s.audio || []).map(function (a, ai) {
var who = voice[a.character_id] || {};
return '<div class="line">'
+ '<button type="button" class="play" data-shot="' + esc(s.id) + '">&#9654;</button>'
+ '<div class="say"><span class="who">' + esc(who.name || a.character_id) + '</span> '
+ (a.lip_sync ? '<span class="lip">on camera</span>' : '<span class="vobadge">voice over</span>')
+ '<br>' + esc(a.text || "")
+ (a.from_book ? ' <span title="' + esc(a.from_book.kind === "quote" ? "This line is taken word for word from the book text you gave the site. Every spoken or narrated line is checked against the book so the film uses the book's own words wherever it can."
: a.from_book.kind === "close" ? "Nearly the book's words; the writer changed a little. " + (a.from_book.near ? "The book says: " + a.from_book.near : "")
: "The writer made this line up; it is not in the book. " + (a.from_book.near ? "Nearest in the book: " + a.from_book.near : "")) + '" style="font-size:11px;border-radius:9px;padding:1px 7px;cursor:help;'
+ (a.from_book.kind === "quote" ? 'background:#e3f3e6;color:#1f7a3a">word for word from the book'
: a.from_book.kind === "close" ? 'background:#fdf1dc;color:#8a5a00">close to the book'
: 'background:#f1e7e2;color:#b4531f">not in the book - written by the writer') + '</span>'
+ (a.from_book.near ? '<br><span class="dir">the book: “' + esc(a.from_book.near) + '”</span>' : '') : '')
+ ' <details style="display:inline"><summary style="cursor:pointer;color:var(--muted);font-size:12px;display:inline">change the words or when they are said</summary>'
+ '<input type="text" maxlength="400" style="width:100%;margin:4px 0" data-linetext="' + esc(s.id) + '|' + ai + '" value="' + esc(a.text || "") + '">'
+ (a.lip_sync ? '<label style="display:block;font-size:12px;color:var(--muted);margin:2px 0 4px">Said '
+ '<select data-linewhen="' + esc(s.id) + '|' + ai + '">'
+ [["start", "at the start of the shot"], ["middle", "in the middle of the shot"], ["end", "at the end of the shot"]].map(function (o) {
return '<option value="' + o[0] + '"' + ((a.say_at || "middle") === o[0] ? ' selected' : '') + '>' + o[1] + '</option>'; }).join("")
+ '</select> — the rest of the shot plays in quiet around it.</label>' : '')
+ '<button type="button" class="btn" data-lineset="' + esc(s.id) + '|' + ai + '">Save</button></details>'
+ (a.delivery ? '<br><span class="dir">' + esc(a.delivery) + '</span>' : '')
+ '</div><span class="dur" data-dur="' + esc(s.id) + '"></span></div>';
}).join("");
var media = backgroundHtml(s) + openingHtml(s);
if (s.clip_url) {
media += '<div class="frames"><figure><video src="' + esc(s.clip_url) + '" controls '
+ 'preload="metadata"></video><figcaption>the filmed shot'
+ (s.clip_seconds ? ' &middot; ' + Number(s.clip_seconds).toFixed(1) + 's' : '')
+ (s.clip_stale ? ' &middot; <b style="color:#b4531f">filmed from an older picture — film it again</b>' : '')
+ '</figcaption></figure>'
+ (s.opening_url ? '<figure><img data-zoom="' + esc("shot " + s.id + " — the frame it was filmed from")
+ '" src="' + esc(s.opening_url) + '" alt=""><figcaption>filmed from this frame</figcaption></figure>' : '')
+ '</div>';
} else if (s.opening_url || s.closing_url) {
media += '<div class="frames">'
+ (s.opening_url ? '<figure><img data-zoom="' + esc("shot " + s.id + " opens on")
+ '" src="' + esc(s.opening_url) + '" alt=""><figcaption>opens on</figcaption></figure>' : '')
+ (s.closing_url ? '<figure><img data-zoom="' + esc("shot " + s.id + " ends on")
+ '" src="' + esc(s.closing_url) + '" alt=""><figcaption>ends on</figcaption></figure>' : '')
+ '</div>';
}
var chk = s.audio_check || {};
var chkHtml = "";
if (chk.verdict && chk.verdict !== "no dialogue") {
var good = chk.verdict === "ok" || chk.verdict === "drifted";
chkHtml = '<span class="chk ' + (good ? "ok" : "bad") + '" title="'
+ esc("heard: " + String(chk.heard || "")) + '">audio ' + esc(chk.verdict)
+ (chk.coverage != null ? ' ' + Math.round(chk.coverage * 100) + '%' : '')
+ (chk.missing ? ' &mdash; lost “' + esc(String(chk.missing).slice(0, 40)) + '”' : '')
+ '</span>';
}
// What the eyes found, if they have looked. Kinds: render (the picture is
// broken), continuity (the frames disagree), script (wrong shot, well made).
var vc = s.vision_check || null;
var eyeHtml = "";
if (vc && vc.verdict) {
var bad = vc.verdict === "broken";
var faults = vc.faults || [];
eyeHtml = '<span class="chk ' + (bad ? "bad" : (faults.length ? "" : "ok"))
+ '" title="' + esc(vc.summary || "") + '">picture ' + esc(vc.verdict)
+ (faults.length ? ' &middot; ' + faults.length + ' fault'
+ (faults.length === 1 ? '' : 's') : '')
+ (vc.people_on_screen != null ? ' &middot; ' + vc.people_on_screen
+ ' on screen' : '') + '</span>';
}
var tools;
if (s.making && (s.writing || s.drawing || stillFilming(s))) {
var since = Date.parse(s.making_since || "") || Date.now(), mins = Math.floor((Date.now() - since) / 60000), secs = Math.floor(((Date.now() - since) % 60000) / 1000);
var expect = /rewriting/.test(s.making) ? "the rewrite takes a minute or two, the picture about a minute, then the clip 3 to 5 minutes" : /drawing/.test(s.making) ? "the picture takes about a minute, then the clip 3 to 5 minutes" : "the clip takes 3 to 5 minutes";
tools = '<span class="busy">Making this shot: ' + esc(s.making) + ' &middot; ' + mins + ':' + String(secs).padStart(2, "0") + ' elapsed &middot; ' + expect + '. This page refreshes itself; the clip appears here when it is done.</span>';
}
else if (s.drawing) tools = '<span class="busy">drawing…</span>';
else if (stillFilming(s)) tools = '<span class="busy">filming…</span>';
else if (EDITING === s.id) {
// An EMPTY box: this is guidance for the writer - what should happen,
// how the camera moves, what is said - and it applies the same whether
// the shot is spoken or silent. Everything not mentioned is kept. The
// last request is shown, not pre-filled, so it is not mistaken for the
// thing to change.
tools = '<div style="width:100%"><textarea class="rough" id="rough" rows="3" placeholder="'
+ 'What should change in this shot? Direction, camera, what is done, or what is said - the writer keeps everything you do not mention.'
+ '"></textarea>'
+ (s.rough ? '<div class="tiny" style="margin:2px 0 6px">Last time you asked for: ' + esc(String(s.rough).slice(0, 240)) + '</div>' : '')
+ '<div class="tools"><button type="button" class="btn primary" data-rewrite="' + esc(s.id)
+ '">Rewrite this shot</button>'
+ '<button type="button" class="btn" data-cancel="1">Cancel</button></div></div>';
} else {
tools = (s.make_error ? '<div class="warn" style="width:100%">' + esc(s.make_error) + '</div>' : '')
+ '<button type="button" class="btn primary" data-make="' + esc(s.id) + '" title="Draws the picture (the actors in the background) and then films the clip, in one go">'
+ (NOTES[s.id] ? 'Apply my note, redraw and film' : s.clip_url ? 'Make this shot again' : (s.opening_url && !s.picture_stale ? 'Make this shot (film the picture)' : 'Make this shot')) + ' &middot; about $' + ((s.opening_url && !s.picture_stale && !NOTES[s.id] ? 0 : 0.14) + filmCost(s)).toFixed(2) + '</button>'
+ '<button type="button" class="btn" data-edit="' + esc(s.id) + '">Change this shot</button>'
+ '<button type="button" class="btn" data-drawone="' + esc(s.id) + '">'
+ (s.opening_url ? 'Redraw the shot picture' : 'Draw the shot picture (actors in the background)') + ' &middot; $0.14</button>'
+ (s.opening_url ? '<button type="button" class="btn" data-filmone="' + esc(s.id) + '">'
+ (s.clip_url ? 'Film it again' : 'Film this shot') + ' &middot; about $' + filmCost(s).toFixed(2) + '</button>' : '')
+ (s.clip_url ? '<button type="button" class="btn" data-shareshot="' + esc(s.id) + '">Share this shot</button>'
+ (SHARES["shot:" + s.id] ? ' <a class="sharelink" href="' + esc(SHARES["shot:" + s.id]) + '" target="_blank">' + esc(SHARES["shot:" + s.id]) + '</a>' : '') : '')
+ (s.clip_url && s.take_url ? '<button type="button" class="btn" data-recut="' + esc(s.id)
+ '" title="Cuts and mixes the shot again from the footage already paid for">Cut it again from the same footage &middot; free</button>' : '')
+ chkHtml + eyeHtml
+ '<button type="button" class="btn" data-delete="' + esc(s.id) + '">Remove</button>';
}
return '<div class="shot"><div class="num">' + esc(s.id) + '</div><div class="body">'
+ '<h3>' + esc(s.title || "") + '</h3>'
+ '<div class="where">' + esc(s.setting || "") + ' &middot; '
+ '<span class="secs"><input type="number" step="0.5" min="3" max="15" value="' + Number(s.edit_seconds || 0).toFixed(1) + '" data-secs="' + esc(s.id) + '" style="width:58px;font:inherit;font-size:12px"> s '
+ '<button type="button" class="btn small ghost" data-estsecs="' + esc(s.id) + '" title="From the lines (speaking time plus a lead-in and a lead-out), or from the action for a silent shot">Estimate</button>'
+ '<button type="button" class="btn small ghost" data-setsecs="' + esc(s.id) + '">Set length</button>'
+ '<span class="tiny" data-secsnote="' + esc(s.id) + '"></span></span>'
+ (s.scene ? ' &middot; <span class="scene">' + esc(s.scene) + '</span>' : '')
+ (s.day ? ' &middot; <span class="scene">' + esc(s.day) + '</span>' : '')
+ '</div>'
+ media
+ variantsHtml(s)
+ frameCheckHtml(s)
+ frameFaultHtml(s)
+ whoInHtml(s)
+ '<p class="act">' + esc(s.action || "") + '</p>'
+ (s.camera ? '<p class="cam">camera: ' + esc(s.camera) + '</p>' : '')
+ (s.continuity ? '<p class="cont">continuity: ' + esc(s.continuity) + '</p>' : '')
+ lines
+ faultsHtml(s)
+ filmNoteHtml(s)
+ '<div class="tools">' + tools + '</div>'
+ (s.draw_unchanged && !s.drawing ? '<p class="cont">Nothing about these pictures has changed since they '
+ 'were drawn, so they were not drawn again and nothing was charged. '
+ '<button type="button" class="btn" data-drawanyway="' + esc(s.id)
+ '">Draw another attempt anyway &middot; $0.14</button></p>' : '')
+ (s.film_unchanged && !s.filming ? '<p class="cont">Nothing that reaches the camera has changed since '
+ 'the last take - same pictures, same words, same voices - so it was not filmed again and nothing '
+ 'was charged. <button type="button" class="btn" data-filmanyway="' + esc(s.id)
+ '">Film another take anyway &middot; about $' + filmCost(s).toFixed(2) + '</button></p>' : '')
+ '<textarea class="note" rows="2" data-note="' + esc(s.id) + '" '
+ 'placeholder="What should be different in this shot? Type it here and press the button above: the writer rewrites the shot from your note, then it is drawn and filmed again.">'
+ esc(NOTES[s.id] || "") + '</textarea>'
+ '</div></div>';
}
async function act(name, payload) {
var r = await api(name, payload);
if (!r.ok) { showMsg("msg", r.error || "That did not work."); return null; }
hideMsg("msg");
return r;
}
document.getElementById("pane").addEventListener("click", async function (e) {
var hit = function (attr) { return e.target.closest("[" + attr + "]"); };
var el, r;
if ((el = hit("data-cancel"))) { EDITING = null; openTab(CUR); return; }
if ((el = hit("data-make"))) {
el.disabled = true;
var msid = el.getAttribute("data-make");
var nta = document.querySelector('[data-note="' + msid + '"]');
var mnote = nta ? nta.value.trim() : (NOTES[msid] || "");
r = await act("make_shot", { draft_id: CUR, shot_id: msid, note: mnote });
if (r) {
if (mnote) { await api("add_shot_note", { draft_id: CUR, shot_id: msid, note: "" }); NOTES[msid] = ""; }
showMsg("msg", "Making shot " + msid + (r.rewrite ? ": rewriting from your note, then the picture, then the clip" : r.redraw ? ": picture first, then the clip" : ": filming the picture") + " - about $" + r.usd + ", roughly " + r.minutes + " minutes. The page refreshes itself.", "ok"); await refresh(); }
else el.disabled = false;
return;
}
if ((el = hit("data-estsecs"))) {
var esid = el.getAttribute("data-estsecs");
r = await act("estimate_seconds", { draft_id: CUR, shot_id: esid });
if (r) {
var inp = document.querySelector('[data-secs="' + esid + '"]'), nt = document.querySelector('[data-secsnote="' + esid + '"]');
if (inp) inp.value = Number(r.suggested).toFixed(1);
if (nt) nt.textContent = " suggested " + Number(r.suggested).toFixed(1) + " s (" + r.why + "; now " + Number(r.current).toFixed(1) + " s) - press Set length to keep it";
}
return;
}
if ((el = hit("data-setsecs"))) {
var ssid = el.getAttribute("data-setsecs"), sinp = document.querySelector('[data-secs="' + ssid + '"]');
r = await act("set_seconds", { draft_id: CUR, shot_id: ssid, seconds: sinp ? sinp.value : "" });
if (r) { showMsg("msg", "Shot " + ssid + " is now " + Number(r.seconds).toFixed(1) + " s." + (PKG.shots.some(function (x) { return x.id === ssid && x.clip_url; }) ? " The clip was filmed at the old length; make the shot again to refilm." : ""), "ok"); await refresh(); }
return;
}
if ((el = hit("data-useframe"))) {
var uf = el.getAttribute("data-useframe").split("|");
el.disabled = true;
r = await act("use_frame", { draft_id: CUR, shot_id: uf[1], frame: uf[0] });
if (r) { showMsg("msg", "Shot " + uf[1] + " opens on the " + r.frame + " frame of shot " + r.from + (r.frame === "last" ? " - taking the frame from the clip, a few seconds." : "."), "ok"); await refresh(); }
else el.disabled = false;
return;
}
if ((el = hit("data-scene"))) { var sk = el.getAttribute("data-scene"); OPEN[sk] = !OPEN[sk]; rememberOpen(); openTab(CUR); return; }
if ((el = hit("data-shareshot"))) {
var shid = el.getAttribute("data-shareshot");
el.disabled = true;
r = await act("share_clips", { draft_id: CUR, shot_ids: [shid], label: "Shot " + shid });
if (r) { SHARES["shot:" + shid] = r.url; showMsg("msg", "Shot " + shid + ": " + r.url, "ok"); openTab(CUR); }
else el.disabled = false;
return;
}
if ((el = hit("data-sharescene"))) {
var sids = el.getAttribute("data-sharescene").split(",");
el.disabled = true;
r = await act("share_clips", { draft_id: CUR, shot_ids: sids, label: el.getAttribute("data-scenelabel") });
if (r) { SHARES["scene:" + sids.join(",")] = r.url; showMsg("msg", r.clips + " clips, played in order: " + r.url, "ok"); openTab(CUR); }
else el.disabled = false;
return;
}
if ((el = hit("data-bgedit"))) { BGEDIT = el.getAttribute("data-bgedit"); openTab(CUR); return; }
if ((el = hit("data-bgcancel"))) { BGEDIT = null; openTab(CUR); return; }
if ((el = hit("data-bggen"))) {
var sidb = el.getAttribute("data-bggen"), ta = document.querySelector('[data-bgdesc="' + sidb + '"]');
el.disabled = true;
r = await act("background", { draft_id: CUR, shot_id: sidb, op: "generate", description: ta ? ta.value : "" });
if (r) { BGEDIT = null; showMsg("msg", "Drawing the background (about $" + r.usd + ").", "ok"); await refresh(); setTimeout(refresh, 25000); }
else el.disabled = false;
return;
}
if ((el = hit("data-bgcopy"))) { r = await act("background", { draft_id: CUR, shot_id: el.getAttribute("data-bgcopy"), op: "copy" }); if (r) await refresh(); return; }
if ((el = hit("data-bgforward"))) { r = await act("background", { draft_id: CUR, shot_id: el.getAttribute("data-bgforward"), op: "forward" }); if (r) { showMsg("msg", "Carried to " + r.shots + " following shots.", "ok"); await refresh(); } return; }
if ((el = hit("data-bgclear"))) { r = await act("background", { draft_id: CUR, shot_id: el.getAttribute("data-bgclear"), op: "clear" }); if (r) await refresh(); return; }
if ((el = hit("data-drawcast"))) {
el.disabled = true;
r = await act("draw_cast", { draft_id: el.getAttribute("data-drawcast") });
if (r) { showMsg("msg", "Drawing reference sheets for " + r.characters + " characters (about $" + r.usd + "). They appear as they finish.", "ok"); setTimeout(refresh, 20000); }
return;
}
if ((el = hit("data-add"))) { EDITING = "new:" + el.getAttribute("data-add"); openTab(CUR); return; }
if ((el = hit("data-edit"))) { EDITING = el.getAttribute("data-edit"); openTab(CUR); return; }
if ((el = hit("data-addgo"))) {
var rough = document.getElementById("newrough").value.trim();
if (rough.length < 20) { showMsg("msg", "Say what happens in the scene."); return; }
el.disabled = true;
r = await act("add_shot", { draft_id: CUR, after: el.getAttribute("data-addgo"), rough: rough });
EDITING = null; if (r) await refresh(); return;
}
if ((el = hit("data-rewrite"))) {
var rw = document.getElementById("rough").value.trim();
if (rw.length < 20) { showMsg("msg", "Say what should change."); return; }
el.disabled = true;
r = await act("write_shot", { draft_id: CUR, shot_id: el.getAttribute("data-rewrite"), rough: rw });
EDITING = null; if (r) await refresh(); return;
}
if ((el = hit("data-drawone"))) {
el.disabled = true;
r = await act("draw_one_shot", { draft_id: CUR, shot_id: el.getAttribute("data-drawone") });
if (r) await refresh(); return;
}
if ((el = hit("data-filmone"))) {
el.disabled = true;
// NEVER force from here. If nothing that reaches the video model has
// changed, the worker films nothing, charges nothing and says so; the
// author can then ask for another take with the button that says what
// it costs. Forcing on every press made a slip of the finger cost $0.75.
var fsid1 = el.getAttribute("data-filmone");
r = await act("film_one_shot_now", { draft_id: CUR, shot_id: fsid1 });
if (r) { showMsg("msg", "Shot " + fsid1 + " sent to be filmed, about $" + r.usd
+ " if anything has changed since the last take.", "ok"); await refresh(); }
return;
}
if ((el = hit("data-recut"))) {
el.disabled = true;
r = await act("recut_shot", { draft_id: CUR, shot_id: el.getAttribute("data-recut") });
if (r) { showMsg("msg", "Cutting shot " + el.getAttribute("data-recut") + " again from the footage you already have. No charge.", "ok"); await refresh(); }
return;
}
if ((el = hit("data-filmanyway"))) {
el.disabled = true;
var fsid2 = el.getAttribute("data-filmanyway");
r = await act("film_one_shot_now", { draft_id: CUR, shot_id: fsid2, force: true });
if (r) { showMsg("msg", "Filming another take of shot " + fsid2 + ", about $" + r.usd
+ ". It takes a minute or two.", "ok"); await refresh(); }
return;
}
if ((el = hit("data-drawanyway"))) {
el.disabled = true;
r = await act("draw_one_shot", { draft_id: CUR, shot_id: el.getAttribute("data-drawanyway"), force: true });
if (r) await refresh(); return;
}
if ((el = hit("data-delete"))) {
if (!confirm("Remove shot " + el.getAttribute("data-delete") + " from this trailer?")) return;
el.disabled = true;
r = await act("delete_shot", { draft_id: CUR, shot_id: el.getAttribute("data-delete") });
if (r) await refresh(); return;
}
if ((el = hit("data-assemble"))) {
el.disabled = true;
r = await act("assemble_trailer", { draft_id: CUR });
if (r) { showMsg("msg", "Joining " + r.shots + " shots. It appears in My videos.", "ok");
setTimeout(function () { location.href = "/videos.html"; }, 2200); }
return;
}
if ((el = hit("data-lead"))) {
el.disabled = true;
r = await act("set_lead", { run_id: runId, draft_id: el.getAttribute("data-lead") });
if (r) await refresh(); return;
}
if ((el = hit("data-draw"))) {
el.disabled = true;
r = await act("draw_storyboard", { draft_id: el.getAttribute("data-draw") });
if (r) await refresh(); return;
}
if ((el = hit("data-reelgo"))) {
el.disabled = true;
var rl = await api("reel", { draft_id: CUR });
if (!rl.ok) { showMsg("msg", rl.error || "Could not load the storyboard."); return; }
if (String(PKG.reel_state || "") !== "ready") {
el.textContent = "Speaking every shot…";
REELWANT = true;
r = await act("prepare_reel", { draft_id: CUR });
if (r) showMsg("msg", "Speaking " + r.shots + " shots. This is the same "
+ "audio the film will use, so it is only paid for once. It starts "
+ "playing on its own when it is ready.", "ok");
else REELWANT = false;
await refresh();
return;
}
hideMsg("msg");
REEL = rl.shots || []; RAT = -1;
reelShow(0);
return;
}
if ((el = hit("data-reeltoggle"))) {
if (reelAudio.paused) { reelAudio.play().catch(function(){}); reelShow(RAT); }
else { reelAudio.pause(); clearInterval(RTIMER); RTIMER = null; reelPaint(); }
return;
}
if ((el = hit("data-reelstep"))) {
var d = Number(el.getAttribute("data-reelstep")) || 1;
reelShow(Math.max(0, Math.min((REEL || []).length - 1, RAT + d)));
return;
}
if ((el = hit("data-reelstop"))) { reelStop(); return; }
if ((el = hit("data-reeljump"))) {
var jid = el.getAttribute("data-reeljump");
clearInterval(RTIMER); RTIMER = null; reelAudio.pause();
var target = [...document.querySelectorAll(".shot")].find(function (x) {
var nu = x.querySelector(".num");
return nu && nu.textContent.trim() === jid; });
if (target) {
document.querySelectorAll(".shot.lit").forEach(function (x) {
x.classList.remove("lit"); });
target.classList.add("lit");
target.scrollIntoView({ behavior: "smooth", block: "center" });
var ta = target.querySelector("textarea.note");
if (ta) setTimeout(function () { ta.focus(); }, 500);
}
return;
}
if ((el = hit("data-eyes"))) {
el.disabled = true; el.textContent = "Looking at the footage…";
r = await act("check_clips", { draft_id: el.getAttribute("data-eyes") });
if (r) showMsg("msg", "Sampling frames from " + r.shots + " clips and "
+ "checking them against the script.", "ok");
await refresh();
return;
}
if ((el = hit("data-cuts"))) {
el.disabled = true; el.textContent = "Working out what to drop…";
r = await act("propose_cuts", { draft_id: el.getAttribute("data-cuts") });
if (r) { showMsg("msg", "Asking for " + r.count + " different cuts.", "ok");
setTimeout(refresh, 25000); }
return;
}
if ((el = hit("data-play"))) {
var cid = el.getAttribute("data-play");
el.disabled = true;
var cc = await api("cut_clips", { draft_id: CUR, cut_id: cid });
el.disabled = false;
if (!cc.ok) { showMsg("msg", cc.error || "That would not play."); return; }
QUEUE = (cc.shots || []).filter(function (s) { return s.clip_url; });
if (!QUEUE.length) {
showMsg("msg", "None of the shots in this cut are filmed yet, so there "
+ "is nothing to play. Film them first.");
return;
}
if (cc.filmed < cc.of)
showMsg("msg", "Playing the " + cc.filmed + " of " + cc.of
+ " shots that are filmed. The rest are skipped.");
PLAYING = cid; QAT = 0;
await openTab(CUR); // the video element does not exist until this lands
playCut();
return;
}
if ((el = hit("data-stop"))) { PLAYING = null; QUEUE = []; openTab(CUR); return; }
if ((el = hit("data-makecut"))) {
el.disabled = true;
r = await act("make_cut", { draft_id: CUR, cut_id: el.getAttribute("data-makecut") });
if (r) { showMsg("msg", "Joining " + r.shots + " shots into this version. "
+ "It appears in My videos with its own share page.", "ok");
await refresh(); }
return;
}
if ((el = hit("data-measure"))) {
el.disabled = true; el.textContent = "Speaking every line…";
r = await act("measure_script", { draft_id: el.getAttribute("data-measure") });
if (r) {
showMsg("msg", "Speaking every line in the cast voices. A minute or two.", "ok");
setTimeout(refresh, 20000);
}
return;
}
if ((el = hit("data-sceneviews"))) {
el.disabled = true;
r = await act("scene_views", { draft_id: el.getAttribute("data-sceneviews") });
if (r) { showMsg("msg", "Photographing each set-up from one master picture. About five minutes.", "ok"); await refresh(); }
return;
}
if ((el = hit("data-showspeakers"))) {
r = await act("show_speakers", { draft_id: el.getAttribute("data-showspeakers") });
if (r) { showMsg("msg", "Re-framing " + (r.shots || []).length + " shots onto the person speaking. The words do not change.", "ok"); await refresh(); }
return;
}
if ((el = hit("data-splitparts"))) {
el.disabled = true;
r = await act("split_parts", { run_id: runId, draft_id: el.getAttribute("data-splitparts") });
if (r) { showMsg("msg", "Split into " + r.parts + " parts. They are the new tabs above.", "ok"); await load(); }
return;
}
if ((el = hit("data-joinparts"))) {
el.disabled = true;
r = await act("join_parts", { run_id: runId });
if (r) { showMsg("msg", "Joining " + r.parts + " parts. It appears in My videos in a minute.", "ok");
setTimeout(function () { location.href = "/videos.html"; }, 2500); }
return;
}
if ((el = hit("data-judge"))) {
el.disabled = true;
r = await act("judge_scripts", { run_id: runId });
if (r) { showMsg("msg", "Every writer is reading all " + r.scripts + " scripts, without knowing whose is whose. About $" + r.usd + ".", "ok"); await load(); }
return;
}
if ((el = hit("data-review"))) {
el.disabled = true;
r = await act("start_review", { run_id: runId }); if (r) await refresh(); return;
}
var zi = e.target.closest && e.target.closest("img[data-zoom]");
if (zi) {
var z = document.getElementById("zoom");
z.querySelector("img").src = zi.getAttribute("src");
z.querySelector(".cap").textContent = zi.getAttribute("data-zoom") || "";
z.classList.add("on");
return;
}
if ((el = hit("data-vargo"))) {
var vsid = el.getAttribute("data-vargo");
var vbox = document.getElementById("ff-" + vsid);
el.disabled = true; el.textContent = "starting\u2026";
r = await act("draw_variants", { draft_id: CUR, shot_id: vsid, n: 4,
note: vbox ? vbox.value.trim() : "" });
if (r) { showMsg("msg", "Drawing " + r.n + " versions of shot " + vsid + ", about $" + r.usd
+ ". They appear here in about a minute.", "ok"); await refresh(); }
return;
}
if ((el = hit("data-pickvar"))) {
var pv = el.getAttribute("data-pickvar").split("|");
el.disabled = true; el.textContent = "working\u2026";
r = await act("pick_variant", { draft_id: CUR, shot_id: pv[0], index: Number(pv[1]) });
if (r) { showMsg("msg", "Using that version for shot " + pv[0] + " and drawing its ending picture to match.", "ok");
await refresh(); }
return;
}
if ((el = hit("data-chkpics"))) {
el.disabled = true; el.textContent = "starting\u2026";
r = await act("check_pictures", { draft_id: CUR });
if (r) { showMsg("msg", r.shots ? "Checking " + r.shots + " pictures. Broken ones are redrawn "
+ "automatically. This takes a few minutes." : (r.note || "Already checked."), "ok"); await refresh(); }
return;
}
if ((el = hit("data-addwriter"))) {
var aw = document.getElementById("addwsel");
el.disabled = true;
r = await act("add_writer", { run_id: runId, writer: aw ? aw.value : "" });
if (r) { showMsg("msg", "That writer is reading the book now. Its script appears as a new tab in a few minutes.", "ok"); await load(); }
return;
}
if ((el = hit("data-page"))) {
PAGE += el.getAttribute("data-page") === "next" ? 1 : -1;
await refresh(); window.scrollTo(0, (document.getElementById("pane").offsetTop || 0)); return;
}
if ((el = hit("data-lineset"))) {
var lk = el.getAttribute("data-lineset"), lp = lk.split("|");
var lin = document.querySelector('[data-linetext="' + lk + '"]');
el.disabled = true;
var lwh = document.querySelector('[data-linewhen="' + lk + '"]');
r = await act("set_line", { draft_id: CUR, shot_id: lp[0], index: Number(lp[1]), text: lin ? lin.value : "",
say_at: lwh ? lwh.value : "" });
if (r) { showMsg("msg", "Saved. The voice will be recorded again with the new words the next time it is played or filmed.", "ok"); await refresh(); }
return;
}
if ((el = hit("data-findprops"))) {
el.disabled = true;
r = await act("find_props", { draft_id: CUR });
if (r) { showMsg("msg", "Reading the script for objects that appear more than once, then drawing a reference for each. About two minutes.", "ok"); await refresh(); }
return;
}
if ((el = hit("data-propsave"))) {
var ppid = el.getAttribute("data-propsave");
var pta = document.querySelector('[data-propdesc="' + ppid + '"]');
el.disabled = true;
var psl = document.querySelector('[data-propshotlist="' + ppid + '"]');
r = await act("set_prop", { draft_id: CUR, prop_id: ppid, description: pta ? pta.value : "",
shots: psl ? psl.value : null, force: !!el.getAttribute("data-again") });
if (r) { showMsg("msg", el.getAttribute("data-again")
? "Drawing the reference picture again, about $" + r.usd + ". When it looks right, redraw the shots that show it."
: "Saved. If you changed the description its picture is being drawn again.", "ok"); await refresh(); }
return;
}
if ((el = hit("data-propshots"))) {
el.disabled = true;
r = await act("redraw_prop_shots", { draft_id: CUR, prop_id: el.getAttribute("data-propshots") });
if (r) { showMsg("msg", "Redrawing those shots one after another, about $" + r.usd + ". A few minutes.", "ok"); await refresh(); }
return;
}
if ((el = hit("data-propdel"))) {
if (!confirm("Stop keeping this object the same from shot to shot?")) return;
el.disabled = true;
r = await act("remove_prop", { draft_id: CUR, prop_id: el.getAttribute("data-propdel") });
if (r) await refresh(); return;
}
if ((el = hit("data-fixfault"))) {
var ff = el.getAttribute("data-fixfault").split("|");
var fs = (PKG.shots || []).filter(function (x) { return String(x.id) === ff[0]; })[0] || {};
var fcx = fs.frame_check || {};
var all = (fcx.duplicated || []).filter(Boolean).map(function (d) {
return "The same person appears twice: " + d + ". Each person appears once only."; })
.concat((fcx.faults || []).filter(function (f) { return f && f.what; })
.map(function (f) { return f.what; }));
var say = all[Number(ff[1])] || "";
if (!say) return;
el.disabled = true; el.textContent = "working\u2026";
r = await act("report_frame_fault", { draft_id: CUR, shot_id: ff[0], note: say });
if (r) { showMsg("msg", "Redrawing shot " + ff[0] + " to fix that. About a minute.", "ok"); await refresh(); }
return;
}
if ((el = hit("data-ffgo"))) {
var fsid = el.getAttribute("data-ffgo");
var fbox = document.getElementById("ff-" + fsid);
var fnote = fbox ? fbox.value.trim() : "";
if (fnote.length < 4) { if (fbox) fbox.focus(); return; }
el.disabled = true; el.textContent = "working\u2026";
r = await act("report_frame_fault", { draft_id: CUR, shot_id: fsid, note: fnote });
if (r) { showMsg("msg", "Redrawing shot " + fsid + " from your note. It takes about a minute.", "ok");
await refresh(); }
else { el.disabled = false; el.innerHTML = "Redraw &middot; $0.16"; }
return;
}
if ((el = hit("data-lookgo"))) {
var pr = el.getAttribute("data-lookgo").split("|");
var box = document.getElementById("look-" + pr[0] + "-" + pr[1]);
var ask = box ? box.value.trim() : "";
if (ask.length < 3) { if (box) box.focus(); return; }
el.disabled = true; el.textContent = "working…";
r = await act("set_look", { draft_id: CUR, shot_id: pr[0], character_id: pr[1], prompt: ask });
if (r) {
showMsg("msg", "Redrawing that look" + (r.frames ? " and " + r.frames + " scene"
+ (r.frames === 1 ? "" : "s") + " it appears in" : "") + ", about $" + r.usd + ".", "ok");
await refresh();
} else { el.disabled = false; el.textContent = "Change"; }
return;
}
if ((el = hit("data-revise"))) {
el.disabled = true;
r = await act("start_revision", { run_id: runId });
// Say so. Pressing this used to change nothing on screen for several
// seconds, which reads as a button that does not work.
if (r) { showMsg("msg", "The head writer is rewriting the script with your notes. "
+ "It takes a few minutes and appears as a new tab.", "ok"); await refresh(); }
return;
}
if ((el = hit("data-film"))) {
el.disabled = true;
r = await act("film_draft", { draft_id: el.getAttribute("data-film") });
if (r) { showMsg("msg", "Filming " + r.shots + " shots, about $" + r.usd
+ ". It appears in My videos as it goes.", "ok");
setTimeout(function () { location.href = "/videos.html"; }, 2500); }
return;
}
if ((el = hit("data-retry"))) {
el.disabled = true;
r = await act("retry_writer", { draft_id: el.getAttribute("data-retry") });
if (r) await refresh(); return;
}
var btn = e.target.closest(".play");
if (!btn) return;
var sid = btn.getAttribute("data-shot");
btn.disabled = true; btn.innerHTML = "&hellip;";
var a = await api("shot_audio", { draft_id: CUR, shot_id: sid });
btn.disabled = false; btn.innerHTML = "&#9654;";
if (!a.ok) { showMsg("msg", a.error || "That would not play."); return; }
hideMsg("msg");
if (!a.url) return;
document.querySelectorAll('[data-dur="' + sid + '"]').forEach(function (x) {
x.textContent = a.seconds.toFixed(1) + "s / " + Number(a.planned).toFixed(1) + "s";
x.className = "dur" + (a.seconds > Number(a.planned) ? " over" : "");
});
audio.pause(); audio.src = a.url; audio.play();
});
// Play the cut's clips one after another in a single element. This is the
// whole preview: no joining, no encoding, no upload, nothing to pay for.
function playCut() {
var v = document.getElementById("cutv");
if (!v || !QUEUE.length) return;
var at = document.getElementById("cutat");
function step() {
if (QAT >= QUEUE.length) {
if (at) at.textContent = "end of this cut";
return;
}
var s = QUEUE[QAT];
if (at) at.textContent = "shot " + s.id + " · " + (QAT + 1)
+ " of " + QUEUE.length + (s.title ? " · " + s.title : "");
v.src = s.clip_url;
v.play().catch(function () {});
}
v.onended = function () { QAT += 1; step(); };
v.onerror = function () { QAT += 1; step(); };
step();
}
var filmNoteTimers = {};
document.getElementById("pane").addEventListener("input", function (e) {
var el = e.target;
var fsid = el.getAttribute && el.getAttribute("data-filmnote");
if (!fsid) return;
el.classList.remove("saved");
clearTimeout(filmNoteTimers[fsid]);
filmNoteTimers[fsid] = setTimeout(async function () {
var r = await api("set_film_note", { draft_id: CUR, shot_id: fsid, note: el.value });
if (r.ok) {
el.classList.add("saved");
(PKG.shots || []).forEach(function (x) { if (String(x.id) === fsid) x.film_note = r.note; });
}
}, 700);
});
document.getElementById("pane").addEventListener("change", async function (e) {
var el = e.target;
if (el.getAttribute && el.getAttribute("data-pagepick")) {
PAGE = Number(el.value) || 0; await refresh(); return; }
var asid = el.getAttribute && el.getAttribute("data-audiomode");
if (!asid) return;
var inp = document.querySelector('[data-filmnote="' + asid + '"]');
var r = await api("set_film_note", { draft_id: CUR, shot_id: asid, note: inp ? inp.value : "", audio_mode: el.value });
if (r.ok) { (PKG.shots || []).forEach(function (x) { if (String(x.id) === asid) x.audio_mode = el.value; });
showMsg("msg", "Saved. Press “Film it again” to hear the difference.", "ok"); }
});
var noteTimers = {};
document.getElementById("pane").addEventListener("input", function (e) {
var ta = e.target;
var sid = ta.getAttribute && ta.getAttribute("data-note");
if (!sid) return;
ta.classList.remove("saved");
clearTimeout(noteTimers[sid]);
noteTimers[sid] = setTimeout(async function () {
var r = await api("add_shot_note", { draft_id: CUR, shot_id: sid, note: ta.value });
if (r.ok) { NOTES[sid] = ta.value; ta.classList.add("saved"); }
}, 700);
});
(function () {
var z = document.createElement("div");
z.id = "zoom"; z.innerHTML = '<img alt=""><div class="cap"></div>';
z.addEventListener("click", function () { z.classList.remove("on"); });
document.addEventListener("keydown", function (e) {
if (e.key === "Escape") z.classList.remove("on"); });
document.body.appendChild(z);
})();
load();
})();
</script>
</body>
</html>