paytolaugh/trailer.html
gator 7b4ee1895a Wire up the cuts: propose, play in order, publish
playCut steps one video element through the cut's clips. No render, no
upload, no cost - which is the whole reason ten proposals are worth looking
at rather than one.
2026-09-17 12:44:44 +00:00

810 lines
39 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:7px;font-weight:600}
.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:16/9;object-fit:cover}
.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}
.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)}
</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 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)$/.test(String(RUN.stage || ""));
}
function busyShots() {
return (PKG.shots || []).some(function (s) {
return s.writing || s.drawing || s.filming; });
}
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 busy = DRAFTS.some(busyDraft) || busyRun() || busyShots();
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(" · ");
}
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("");
}
document.getElementById("tabs").addEventListener("click", function (e) {
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 || {};
pane.innerHTML = paneHtml(meta, r.draft);
}
// 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("");
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;
var unfilmed = shots.filter(function (s) { return !s.clip_url; }).length;
var filmed = shots.length - unfilmed;
var imgs = cast.length * 3 + shots.length * 2;
var s = String(meta.draw_state || "");
var drawing = s && !/^(drawn|failed)/.test(s);
var isLead = meta.writer === RUN.lead_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'))
+ ' &middot; $' + (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 && String(PKG.cuts_state || "") !== "thinking")
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;
if (REVIEWS.length || Object.keys(NOTES).length || over > 5)
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 (unfilmed && !undrawn)
out.push('<button type="button" class="btn primary" data-film="' + esc(meta.id)
+ '">Film ' + (filmed ? 'the ' + unfilmed + ' remaining shots' : 'all ' + shots.length + ' shots')
+ ' &middot; $' + (unfilmed * 0.75).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("");
}
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 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 (st === "thinking") out += '<p class="busy">Working out what to drop…</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(" ")) + '</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</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 castHtml = '<div class="cast">' + cast.map(function (c) {
var sheets = (c.head_url || c.front_url || c.side_url)
? '<div class="sheets">' + [c.head_url, c.front_url, c.side_url].map(function (u) {
return u ? '<img src="' + esc(u) + '" alt="">' : ''; }).join("") + '</div>' : '';
return '<div class="ch">' + sheets + '<b>' + esc(c.name || c.id) + '</b>'
+ '<span class="vo">' + esc(c.role || "") + (c.voice_id ? " · voiced" : " · silent")
+ '</span><div class="ap">' + esc((c.appearance || "").slice(0, 150)) + '</div></div>';
}).join("") + '</div>';
var body = insertHtml("start");
shots.forEach(function (s) { body += shotHtml(s, voice) + insertHtml(s.id); });
return head + probs + '<div class="bar">' + barHtml(meta) + '</div>'
+ spineHtml() + cutsHtml(meta) + reviewsHtml(meta) + castHtml + body;
}
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>';
}
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">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) {
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.delivery ? '<br><span class="dir">' + esc(a.delivery) + '</span>' : '')
+ '</div><span class="dur" data-dur="' + esc(s.id) + '"></span></div>';
}).join("");
var media = "";
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' : '')
+ '</figcaption></figure>'
+ (s.opening_url ? '<figure><img 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 src="' + esc(s.opening_url) + '" alt=""><figcaption>opens on</figcaption></figure>' : '')
+ (s.closing_url ? '<figure><img 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>';
}
var tools;
if (s.drawing) tools = '<span class="busy">drawing…</span>';
else if (s.filming) tools = '<span class="busy">filming…</span>';
else if (EDITING === s.id) {
tools = '<div style="width:100%"><textarea class="rough" id="rough" rows="3" placeholder="'
+ 'What should change in this shot?">' + esc(s.rough || "") + '</textarea>'
+ '<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 = '<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' : 'Draw') + ' &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; $0.75</button>' : '')
+ chkHtml
+ '<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; '
+ Number(s.edit_seconds || 0).toFixed(1) + 's'
+ (s.scene ? ' &middot; <span class="scene">' + esc(s.scene) + '</span>' : '')
+ '</div>'
+ media
+ '<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
+ '<div class="tools">' + tools + '</div>'
+ '<textarea class="note" rows="2" data-note="' + esc(s.id) + '" '
+ 'placeholder="Your note on this shot, for the next full revision.">'
+ 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-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;
r = await act("film_one_shot_now", { draft_id: CUR, shot_id: el.getAttribute("data-filmone") });
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-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; openTab(CUR);
setTimeout(playCut, 60);
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-review"))) {
el.disabled = true;
r = await act("start_review", { run_id: runId }); if (r) await refresh(); return;
}
if ((el = hit("data-revise"))) {
el.disabled = true;
r = await act("start_revision", { run_id: runId }); if (r) 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 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);
});
load();
})();
</script>
</body>
</html>