Reading a trailer tells you what happens; hearing it tells you the line is four seconds too long and the grandmother sounds thirty. So the play button ships before the draw button - speaking a whole trailer costs fractions of a cent against five dollars to draw it - and it prints the spoken length against the length the writer planned, which is the most common fault in these scripts. The note box saves as you stop typing. A note you have to remember to save is a note that gets lost when you switch tabs. The draw button is priced from what the writer actually wrote - three sheets a character plus an opening and a closing frame a shot - rather than from an estimate, and is disabled until that stage lands.
277 lines
12 KiB
HTML
277 lines
12 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 .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:10px;margin:0 0 22px}
|
|
.ch{border:1px solid var(--line);border-radius:10px;padding:9px 12px;font-size:13px;max-width:330px}
|
|
.ch b{display:block}
|
|
.ch .vo{color:var(--muted);font-size:12px}
|
|
.ch .ap{color:var(--muted);font-size:12px;margin-top:3px}
|
|
.shot{border-top:1px solid var(--line);padding:16px 0;display:flex;gap:16px}
|
|
.shot .num{width:34px;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{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}
|
|
</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 = {}, timer = null;
|
|
var runId = new URLSearchParams(location.search).get("run") || "";
|
|
var audio = new Audio();
|
|
|
|
if (!runId) { showMsg("msg", "No run was named in the link."); return; }
|
|
|
|
// ------------------------------------------------------------ polling
|
|
|
|
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 = DRAFTS[0].id;
|
|
paintTabs();
|
|
var busy = DRAFTS.some(function (d) { return d.state === "writing"; });
|
|
if (busy && !timer) timer = setInterval(load, 4000);
|
|
if (!busy && timer) { clearInterval(timer); timer = null; }
|
|
if (!busy) openTab(CUR);
|
|
else paintWriting();
|
|
}
|
|
|
|
function sub() {
|
|
var bits = [];
|
|
if (RUN.stage) bits.push(RUN.stage);
|
|
bits.push(DRAFTS.length + " writer" + (DRAFTS.length === 1 ? "" : "s"));
|
|
if (RUN.cost_usd) bits.push("$" + Number(RUN.cost_usd).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 extra = d.state === "writing" ? '<span class="st">writing…</span>'
|
|
: d.state === "failed" ? '<span class="st">failed</span>'
|
|
: d.score ? '<span class="sc">' + d.score + '</span>'
|
|
: '';
|
|
return '<button type="button" class="tab' + (d.id === CUR ? ' on' : '')
|
|
+ '" data-tab="' + esc(d.id) + '">' + esc(d.label || d.writer)
|
|
+ extra + '</button>';
|
|
}).join("");
|
|
}
|
|
|
|
function paintWriting() {
|
|
document.getElementById("pane").innerHTML =
|
|
'<p class="empty">Each writer is reading the whole book and writing its own '
|
|
+ 'trailer. This takes a few minutes.</p>';
|
|
}
|
|
|
|
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");
|
|
paintTabs(); openTab(CUR);
|
|
});
|
|
|
|
// ------------------------------------------------------------ one trailer
|
|
|
|
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>';
|
|
return;
|
|
}
|
|
if (meta.state === "writing") { paintWriting(); return; }
|
|
pane.innerHTML = '<p class="empty">Loading…</p>';
|
|
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);
|
|
}
|
|
|
|
function paneHtml(meta, draft) {
|
|
var p = PKG, shots = p.shots || [], cast = p.characters || [];
|
|
var voice = {};
|
|
cast.forEach(function (c) { voice[c.id] = c; });
|
|
|
|
var secs = shots.reduce(function (a, s) { return a + (Number(s.edit_seconds) || 0); }, 0);
|
|
// Drawing is priced off what this writer actually wrote, not an estimate:
|
|
// three sheets a character plus an opening and a closing frame a shot.
|
|
var imgs = cast.length * 3 + shots.length * 2;
|
|
var price = imgs * 0.068;
|
|
|
|
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) + ' · ' + shots.length + ' shots · '
|
|
+ Math.floor(secs / 60) + ':' + String(Math.round(secs % 60)).padStart(2, "0")
|
|
+ ' · ' + cast.length + ' characters'
|
|
+ (meta.write_usd ? ' · $' + Number(meta.write_usd).toFixed(2) + ' to write' : '')
|
|
+ '</p>';
|
|
|
|
var probs = "";
|
|
if (draft.problems) {
|
|
probs = '<div class="probs"><b>The checker still has objections</b><ul>'
|
|
+ draft.problems.split("\n").filter(Boolean).map(function (x) {
|
|
return '<li>' + esc(x) + '</li>'; }).join("")
|
|
+ '</ul></div>';
|
|
}
|
|
|
|
var bar = '<div class="bar">'
|
|
+ '<button type="button" class="btn" disabled>Draw this storyboard · $'
|
|
+ price.toFixed(2) + '</button>'
|
|
+ '<span class="soon">' + imgs + ' pictures. Not wired up yet.</span>'
|
|
+ '</div>';
|
|
|
|
var castHtml = '<div class="cast">' + cast.map(function (c) {
|
|
return '<div class="ch"><b>' + esc(c.name || c.id) + '</b>'
|
|
+ '<span class="vo">' + esc(c.role || "") + (c.age ? " · " + esc(c.age) : "")
|
|
+ (c.voice_id ? " · voiced" : " · silent") + '</span>'
|
|
+ '<div class="ap">' + esc((c.appearance || "").slice(0, 150)) + '</div></div>';
|
|
}).join("") + '</div>';
|
|
|
|
return head + probs + bar + castHtml
|
|
+ shots.map(function (s) { return shotHtml(s, voice); }).join("");
|
|
}
|
|
|
|
function shotHtml(s, voice) {
|
|
var lines = (s.audio || []).map(function (a, i) {
|
|
var who = voice[a.character_id] || {};
|
|
return '<div class="line">'
|
|
+ '<button type="button" class="play" data-shot="' + esc(s.id) + '" title="Hear this shot">▶</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 frames = "";
|
|
if (s.opening_url || s.closing_url) {
|
|
frames = '<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>';
|
|
}
|
|
|
|
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 || "") + ' · '
|
|
+ Number(s.edit_seconds || 0).toFixed(1) + 's</div>'
|
|
+ frames
|
|
+ '<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
|
|
+ '<textarea class="note" rows="2" data-note="' + esc(s.id) + '" '
|
|
+ 'placeholder="Your note on this shot. It gets the highest priority in the revision.">'
|
|
+ esc(NOTES[s.id] || "") + '</textarea>'
|
|
+ '</div></div>';
|
|
}
|
|
|
|
// ------------------------------------------------------------ play + notes
|
|
|
|
document.getElementById("pane").addEventListener("click", async function (e) {
|
|
var btn = e.target.closest ? e.target.closest(".play") : null;
|
|
if (!btn) return;
|
|
var sid = btn.getAttribute("data-shot");
|
|
btn.disabled = true; btn.innerHTML = "…";
|
|
var r = await api("shot_audio", { draft_id: CUR, shot_id: sid });
|
|
btn.disabled = false; btn.innerHTML = "▶";
|
|
if (!r.ok) { showMsg("msg", r.error || "That would not play."); return; }
|
|
hideMsg("msg");
|
|
if (!r.url) return;
|
|
// Spoken length against the length the writer planned for. A line that
|
|
// overruns its shot is the single most common fault in these scripts.
|
|
document.querySelectorAll('[data-dur="' + sid + '"]').forEach(function (el) {
|
|
el.textContent = r.seconds.toFixed(1) + "s / " + Number(r.planned).toFixed(1) + "s";
|
|
el.className = "dur" + (r.seconds > Number(r.planned) ? " over" : "");
|
|
});
|
|
audio.pause(); audio.src = r.url; audio.play();
|
|
});
|
|
|
|
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]);
|
|
// Saved as you stop typing. A note the author has to remember to save is
|
|
// a note that gets lost between tabs.
|
|
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>
|