1964 lines
110 KiB
HTML
1964 lines
110 KiB
HTML
<!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}
|
||
.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}
|
||
.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…</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; }
|
||
}
|
||
|
||
async function refresh() {
|
||
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… 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 ? ' · ' + 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> — ' + 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) + '–' + 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 ? ' · ' + 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… 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’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> · ' + d.shots + ' shots · ' + Math.round(d.runtime_seconds / 6) / 10 + ' min · ' + st
|
||
+ (d.film_url ? ' · <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') + ' · 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 · 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.
|
||
+ ' · $' + ((Number(PKG.pictures_to_draw) || imgs) * 0.068).toFixed(2) + '</button>');
|
||
else out.push('<span class="soon">storyboard drawn'
|
||
+ (meta.draw_usd ? ' · $' + 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")
|
||
+ ' · ' + "$" + (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'))
|
||
+ ' · about $' + unfilmedUsd.toFixed(2) + '</button>');
|
||
if (filmed)
|
||
out.push('<button type="button" class="btn primary" data-assemble="' + esc(meta.id)
|
||
+ '">Make the trailer · ' + 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(" |