Once you navigated away from a trailer, the only route back to it was the URL. The page offered a way to start work and no way to return to it, which meant every finished run was effectively lost the moment you closed the tab. The list leads with what someone actually wants to know at a glance - is it finished, did every writer survive, has anything been drawn, what has it cost - rather than with an id and a timestamp. A run still working shows what it is doing and keeps polling, so this is also the page you land on to check progress. trailer.html gets a link back, so the two pages are a loop rather than a one-way trip.
408 lines
18 KiB
HTML
408 lines
18 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Book trailer — PayToLaugh</title>
|
|
<meta name="description" content="Upload a manuscript and have several models each write a cinematic trailer from it.">
|
|
<meta name="robots" content="noindex,nofollow">
|
|
<link rel="stylesheet" href="/assets/app.css">
|
|
<style>
|
|
.books{margin-top:18px}
|
|
.bk{display:flex;gap:14px;align-items:flex-start;padding:14px 0;border-top:1px solid var(--line)}
|
|
.bk:first-child{border-top:0}
|
|
.bk .meta{flex:1;min-width:0}
|
|
.bk h3{margin:0 0 4px;font-size:16px}
|
|
.bk .sub{color:var(--muted);font-size:13px}
|
|
.bk .acts{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
|
.state{font-size:12px;padding:2px 8px;border-radius:999px;border:1px solid var(--line)}
|
|
.state.ready{color:var(--accent2);border-color:var(--accent2)}
|
|
.state.reading{color:var(--muted)}
|
|
.state.failed{color:#e0685f;border-color:#e0685f}
|
|
.why{color:#e0685f;font-size:13px;margin-top:4px}
|
|
.tiny{font-size:12px;color:var(--muted)}
|
|
.pick{display:flex;gap:14px;align-items:center;flex-wrap:wrap;
|
|
border:1px dashed var(--line);border-radius:12px;padding:18px}
|
|
.pick.on{border-color:var(--accent);border-style:solid}
|
|
.pick .or{color:var(--muted);font-size:13px}
|
|
.rename{display:flex;gap:8px;flex-wrap:wrap;margin-top:8px}
|
|
.rename input{font:inherit;font-size:13px;background:transparent;color:var(--ink);
|
|
border:1px solid var(--line);border-radius:8px;padding:6px 9px}
|
|
.writers{display:flex;flex-wrap:wrap;gap:10px;margin-top:8px}
|
|
.wr{display:flex;gap:8px;align-items:center;border:1px solid var(--line);
|
|
border-radius:10px;padding:9px 12px;cursor:pointer}
|
|
.wr.on{border-color:var(--accent)}
|
|
.wr input{margin:0}
|
|
.lead{margin-left:6px;font-size:12px;color:var(--muted)}
|
|
.run{display:flex;gap:14px;align-items:center;padding:14px 0;
|
|
border-top:1px solid var(--line);flex-wrap:wrap}
|
|
.run:first-child{border-top:0}
|
|
.run .meta{flex:1;min-width:220px}
|
|
.run h3{margin:0 0 3px;font-size:16px}
|
|
.run .sub{color:var(--muted);font-size:13px}
|
|
.run .who{color:var(--muted);font-size:12px;margin-top:2px}
|
|
.dot{font-size:12px;padding:2px 8px;border-radius:999px;border:1px solid var(--line);
|
|
color:var(--muted)}
|
|
.dot.done{color:var(--accent2);border-color:var(--accent2)}
|
|
.dot.work{color:var(--accent);border-color:var(--accent)}
|
|
.dot.bad{color:#e0685f;border-color:#e0685f}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
|
|
<header class="page">
|
|
<h1>Make a trailer from a book</h1>
|
|
<p>Upload the manuscript and say what you want the trailer to do. Several models each read the whole book and write their own cinematic trailer — script, cast and shot list. You read them side by side, mark up the shots you want changed, and let them critique each other before one gets filmed.</p>
|
|
</header>
|
|
|
|
<section id="runsec" style="display:none">
|
|
<div class="card">
|
|
<h2 class="sec">Trailers you have run</h2>
|
|
<div id="runs"></div>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<div class="card">
|
|
<div class="msg" id="msg"></div>
|
|
|
|
<h2 class="sec">1 · The manuscript</h2>
|
|
<div class="field">
|
|
<label>The book</label>
|
|
<span class="hint">A Word <strong>.docx</strong> or a <strong>.pdf</strong>, up to 6MB. A scanned PDF has no text in it and will be refused — upload the original document instead.</span>
|
|
<div class="pick" id="pick">
|
|
<button type="button" class="btn primary" id="choose">Choose a file…</button>
|
|
<span class="or" id="pickmsg">or drag the manuscript onto this panel</span>
|
|
</div>
|
|
<input type="file" id="file" accept=".docx,.pdf,.txt,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document" style="display:none">
|
|
</div>
|
|
|
|
<div class="books" id="books"><p class="empty">No manuscripts yet.</p></div>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="runcard" style="display:none">
|
|
<div class="card">
|
|
<h2 class="sec">2 · What you want from the trailer</h2>
|
|
<div class="field">
|
|
<label for="guidance">Your guidance</label>
|
|
<span class="hint">You know this book better than any of them will. Say what the trailer has to carry, which scenes matter, which it must not give away, who the audience is, and the tone. This outranks the house defaults.</span>
|
|
<textarea id="guidance" rows="7" placeholder="What the trailer has to carry, what it must not give away, and the tone you want."></textarea>
|
|
</div>
|
|
|
|
<h2 class="sec" style="margin-top:34px">3 · Who writes it</h2>
|
|
<div class="field">
|
|
<label>Writers</label>
|
|
<span class="hint">Each one reads the whole book and writes its own trailer, with its own cast. Tick at least two if you want them to critique each other. The <em>lead</em> is the one that will write the revision after the reviews come back — click a ticked name to make it the lead.</span>
|
|
<div class="writers" id="writers"></div>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<p class="tiny" id="quote"></p>
|
|
<button type="button" class="btn primary" id="go">Write the trailers</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
</div>
|
|
|
|
<script src="/assets/app.js"></script>
|
|
<script>
|
|
// Send a document as the bytes it actually is.
|
|
//
|
|
// app.js has resizeImage, and it is image-only by construction - FileReader
|
|
// into an <img> into a canvas. That is right for a 10MB phone photo and fatal
|
|
// for a .docx, which must arrive byte for byte or the zip will not open. This
|
|
// lives here rather than in app.js because this is the only page that uploads
|
|
// a document, and app.js is loaded by every page.
|
|
//
|
|
// The ceiling matches the server's MAX_BOOK_BYTES, so an oversized file is
|
|
// refused instantly with a suggestion rather than after a slow upload that was
|
|
// always going to be rejected.
|
|
var MAX_DOC_BYTES = 6 * 1024 * 1024;
|
|
|
|
function readFileB64(file) {
|
|
return new Promise(function (resolve, reject) {
|
|
if (!file) { reject(new Error("No file was chosen.")); return; }
|
|
if (file.size > MAX_DOC_BYTES) {
|
|
reject(new Error("That file is " + (file.size / 1048576).toFixed(1)
|
|
+ "MB and the limit is 6MB. Upload the .docx rather than an "
|
|
+ "illustrated PDF."));
|
|
return;
|
|
}
|
|
var reader = new FileReader();
|
|
reader.onerror = function () { reject(new Error("Could not read that file.")); };
|
|
reader.onload = function () {
|
|
var s = String(reader.result || "");
|
|
var i = s.indexOf(",");
|
|
resolve({ b64: i >= 0 ? s.slice(i + 1) : s, name: file.name, bytes: file.size });
|
|
};
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
(function () {
|
|
var email = requireSession();
|
|
if (!email) return;
|
|
navBar("trailer");
|
|
|
|
var BOOKS = [], PICKED = null, WRITERS = [], CHOSEN = {}, LEAD = "", EDIT = null;
|
|
var pollTimer = null, runTimer = null;
|
|
|
|
// ---------------------------------------------------------------- runs
|
|
|
|
function runHtml(r) {
|
|
var bits = [];
|
|
if (r.ready) bits.push(r.ready + " written");
|
|
if (r.drawn) bits.push(r.drawn + " drawn");
|
|
if (r.failed) bits.push(r.failed + " failed");
|
|
if (r.cost_usd) bits.push("$" + Number(r.cost_usd).toFixed(2));
|
|
if (r.created_at) bits.push(when(r.created_at));
|
|
|
|
var cls = r.busy ? "work" : (r.failed && !r.ready ? "bad" : "done");
|
|
var word = r.busy ? "working" : (r.stage || "");
|
|
|
|
return '<div class="run">'
|
|
+ '<div class="meta"><h3>' + esc(r.book_title || "Untitled") + '</h3>'
|
|
+ '<div class="sub">' + esc(bits.join(" · ")) + '</div>'
|
|
+ '<div class="who">' + esc(r.writers || "")
|
|
+ (r.lead_writer ? ' · lead: ' + esc(r.lead_writer) : '') + '</div>'
|
|
+ '</div>'
|
|
+ '<span class="dot ' + cls + '">' + esc(word) + '</span>'
|
|
+ '<a class="btn" href="/trailer.html?run=' + encodeURIComponent(r.id) + '">'
|
|
+ (r.busy ? "Watch" : "Review") + '</a>'
|
|
+ '</div>';
|
|
}
|
|
|
|
async function loadRuns() {
|
|
var r = await api("list_runs", {});
|
|
if (!r.ok) return;
|
|
var runs = r.runs || [];
|
|
document.getElementById("runsec").style.display = runs.length ? "" : "none";
|
|
document.getElementById("runs").innerHTML = runs.map(runHtml).join("");
|
|
// A run that is still writing or drawing is worth watching from here, so
|
|
// this doubles as the progress page.
|
|
var busy = runs.some(function (x) { return x.busy; });
|
|
if (busy && !runTimer) runTimer = setInterval(loadRuns, 8000);
|
|
if (!busy && runTimer) { clearInterval(runTimer); runTimer = null; }
|
|
}
|
|
|
|
// ---------------------------------------------------------------- books
|
|
|
|
function bookHtml(b) {
|
|
var st = b.status || "reading";
|
|
var sub;
|
|
if (st === "ready") {
|
|
sub = Number(b.words || 0).toLocaleString() + " words · "
|
|
+ Number(b.chars || 0).toLocaleString() + " characters · " + esc(b.kind || "");
|
|
} else if (st === "failed") {
|
|
sub = "could not be read";
|
|
} else {
|
|
sub = "reading the manuscript…";
|
|
}
|
|
// A filename is not a title. Publishers name files after print runs, so
|
|
// the derived title is a starting point the author can correct.
|
|
var rename = EDIT === b.id
|
|
? '<div class="rename">'
|
|
+ '<input id="rt" value="' + esc(b.title || "") + '" placeholder="Title">'
|
|
+ '<input id="ra" value="' + esc(b.author || "") + '" placeholder="Author">'
|
|
+ '<button type="button" class="btn" data-save="' + esc(b.id) + '">Save</button>'
|
|
+ '<button type="button" class="btn" data-cancel="1">Cancel</button>'
|
|
+ '</div>'
|
|
: '';
|
|
return '<div class="bk" data-id="' + esc(b.id) + '">'
|
|
+ '<div class="meta">'
|
|
+ '<h3>' + esc(b.title || "Untitled") + '</h3>'
|
|
+ '<div class="sub">' + (b.author ? esc(b.author) + ' · ' : '') + sub
|
|
+ (b.created_at ? ' · ' + esc(when(b.created_at)) : '') + '</div>'
|
|
+ (st === "failed" && b.note ? '<div class="why">' + esc(b.note) + '</div>' : '')
|
|
+ rename
|
|
+ '</div>'
|
|
+ '<div class="acts">'
|
|
+ '<span class="state ' + esc(st) + '">' + esc(st) + '</span>'
|
|
+ (st === "ready"
|
|
? '<button type="button" class="btn" data-pick="' + esc(b.id) + '">'
|
|
+ (PICKED === b.id ? "Selected" : "Use this book") + '</button>'
|
|
+ '<button type="button" class="btn" data-edit="' + esc(b.id) + '">Rename</button>'
|
|
: '')
|
|
+ '<button type="button" class="btn" data-del="' + esc(b.id) + '">Remove</button>'
|
|
+ '</div></div>';
|
|
}
|
|
|
|
function paintBooks() {
|
|
var el = document.getElementById("books");
|
|
if (!BOOKS.length) { el.innerHTML = '<p class="empty">No manuscripts yet.</p>'; return; }
|
|
el.innerHTML = BOOKS.map(bookHtml).join("");
|
|
var ready = BOOKS.filter(function (b) { return b.status === "ready"; });
|
|
if (!PICKED && ready.length === 1) PICKED = ready[0].id;
|
|
document.getElementById("runcard").style.display = PICKED ? "" : "none";
|
|
}
|
|
|
|
async function loadBooks() {
|
|
var r = await api("list_books", {});
|
|
if (!r.ok) { showMsg("msg", r.error || "Could not load your manuscripts."); return; }
|
|
BOOKS = r.books || [];
|
|
paintBooks();
|
|
// Extraction runs on the server after the upload returns, so a book sits
|
|
// at "reading" for a moment. Poll only while one actually is.
|
|
var busy = BOOKS.some(function (b) { return b.status === "reading"; });
|
|
if (busy && !pollTimer) pollTimer = setInterval(loadBooks, 2500);
|
|
if (!busy && pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
|
}
|
|
|
|
document.getElementById("books").addEventListener("click", async function (e) {
|
|
var t = e.target;
|
|
var pick = t.getAttribute("data-pick"), del = t.getAttribute("data-del");
|
|
var edit = t.getAttribute("data-edit"), save = t.getAttribute("data-save");
|
|
if (t.getAttribute("data-cancel")) { EDIT = null; paintBooks(); return; }
|
|
if (edit) { EDIT = edit; paintBooks(); return; }
|
|
if (save) {
|
|
var title = document.getElementById("rt").value.trim();
|
|
var author = document.getElementById("ra").value.trim();
|
|
if (!title) { showMsg("msg", "A book needs a title."); return; }
|
|
t.disabled = true;
|
|
var u = await api("update_book", { book_id: save, title: title, author: author });
|
|
if (!u.ok) { showMsg("msg", u.error || "That did not save."); t.disabled = false; return; }
|
|
hideMsg("msg"); EDIT = null; loadBooks(); loadRuns();
|
|
return;
|
|
}
|
|
if (pick) { PICKED = pick; hideMsg("msg"); paintBooks(); paintQuote(); return; }
|
|
if (del) {
|
|
t.disabled = true;
|
|
var r = await api("delete_book", { book_id: del });
|
|
if (!r.ok) { showMsg("msg", r.error || "Could not remove that."); t.disabled = false; return; }
|
|
if (PICKED === del) PICKED = null;
|
|
loadBooks();
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------- upload
|
|
|
|
var pick = document.getElementById("pick");
|
|
var pickMsg = document.getElementById("pickmsg");
|
|
var fileInput = document.getElementById("file");
|
|
|
|
document.getElementById("choose").addEventListener("click", function () {
|
|
fileInput.click();
|
|
});
|
|
["dragenter", "dragover"].forEach(function (k) {
|
|
pick.addEventListener(k, function (e) { e.preventDefault(); pick.classList.add("on"); });
|
|
});
|
|
["dragleave", "drop"].forEach(function (k) {
|
|
pick.addEventListener(k, function (e) { e.preventDefault(); pick.classList.remove("on"); });
|
|
});
|
|
pick.addEventListener("drop", function (e) {
|
|
var f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
|
|
if (f) send(f);
|
|
});
|
|
fileInput.addEventListener("change", function () {
|
|
if (fileInput.files && fileInput.files[0]) send(fileInput.files[0]);
|
|
fileInput.value = "";
|
|
});
|
|
|
|
async function send(file) {
|
|
hideMsg("msg");
|
|
pickMsg.textContent = "reading " + file.name + "…";
|
|
var doc;
|
|
try { doc = await readFileB64(file); }
|
|
catch (err) { resetPick(); showMsg("msg", err.message); return; }
|
|
pickMsg.textContent = "uploading " + file.name + " ("
|
|
+ (doc.bytes / 1048576).toFixed(1) + "MB)…";
|
|
var r = await api("upload_book", { filename: doc.name, data_b64: doc.b64 });
|
|
resetPick();
|
|
if (!r.ok) { showMsg("msg", r.error || "That upload did not go through."); return; }
|
|
PICKED = null;
|
|
// Straight into rename: the derived title is a guess and this is the one
|
|
// moment the author is looking at it.
|
|
EDIT = r.book && r.book.id;
|
|
loadBooks();
|
|
}
|
|
|
|
function resetPick() {
|
|
pickMsg.textContent = "or drag the manuscript onto this panel";
|
|
}
|
|
|
|
// ---------------------------------------------------------------- writers
|
|
|
|
function paintWriters() {
|
|
document.getElementById("writers").innerHTML = WRITERS.map(function (w) {
|
|
var on = !!CHOSEN[w.id];
|
|
return '<label class="wr' + (on ? ' on' : '') + '" data-w="' + esc(w.id) + '">'
|
|
+ '<input type="checkbox" data-pick="' + esc(w.id) + '"' + (on ? ' checked' : '') + '>'
|
|
+ esc(w.label)
|
|
+ (LEAD === w.id ? '<span class="lead">lead</span>' : '')
|
|
+ '</label>';
|
|
}).join("");
|
|
}
|
|
|
|
document.getElementById("writers").addEventListener("change", function (e) {
|
|
var id = e.target.getAttribute("data-pick");
|
|
if (!id) return;
|
|
if (e.target.checked) { CHOSEN[id] = true; if (!LEAD) LEAD = id; }
|
|
else { delete CHOSEN[id]; if (LEAD === id) LEAD = Object.keys(CHOSEN)[0] || ""; }
|
|
paintWriters(); paintQuote();
|
|
});
|
|
|
|
// Clicking the name of a ticked writer makes it the lead.
|
|
document.getElementById("writers").addEventListener("click", function (e) {
|
|
var lab = e.target.closest ? e.target.closest(".wr") : null;
|
|
if (!lab || e.target.tagName === "INPUT") return;
|
|
var id = lab.getAttribute("data-w");
|
|
if (CHOSEN[id]) { LEAD = id; paintWriters(); paintQuote(); }
|
|
});
|
|
|
|
function paintQuote() {
|
|
var n = Object.keys(CHOSEN).length;
|
|
var el = document.getElementById("quote");
|
|
var go = document.getElementById("go");
|
|
if (!n) {
|
|
el.textContent = "Tick at least one writer.";
|
|
go.disabled = true; return;
|
|
}
|
|
go.disabled = false;
|
|
// Writing is the cheap half. Drawing the storyboards is priced separately,
|
|
// per writer, once you have read the scripts.
|
|
el.innerHTML = n + " writer" + (n > 1 ? "s" : "")
|
|
+ " · about $" + (n * 0.85).toFixed(2) + " to write a full-length novel"
|
|
+ " · storyboards are priced separately once you have read them";
|
|
}
|
|
|
|
(async function loadWriters() {
|
|
var r = await api("camera_presets", {});
|
|
WRITERS = (r && r.writers) || [];
|
|
if (!WRITERS.length) {
|
|
showMsg("msg", "The server has not published a writer list yet.");
|
|
return;
|
|
}
|
|
WRITERS.forEach(function (w) { if (w.trailer_default) { CHOSEN[w.id] = true; } });
|
|
LEAD = Object.keys(CHOSEN)[0] || "";
|
|
paintWriters(); paintQuote();
|
|
})();
|
|
|
|
document.getElementById("go").addEventListener("click", async function () {
|
|
var btn = this;
|
|
var writers = Object.keys(CHOSEN);
|
|
var guidance = document.getElementById("guidance").value.trim();
|
|
if (!PICKED) { showMsg("msg", "Pick a manuscript first."); return; }
|
|
if (!writers.length) { showMsg("msg", "Tick at least one writer."); return; }
|
|
hideMsg("msg");
|
|
btn.disabled = true; btn.textContent = "Starting…";
|
|
var r = await api("start_run", {
|
|
book_id: PICKED, guidance: guidance,
|
|
writers: writers.join(","), lead_writer: LEAD || writers[0]
|
|
});
|
|
if (!r.ok) {
|
|
btn.disabled = false; btn.textContent = "Write the trailers";
|
|
showMsg("msg", r.error || "That did not start.");
|
|
return;
|
|
}
|
|
location.href = "/trailer.html?run=" + encodeURIComponent(r.run.id);
|
|
});
|
|
|
|
loadBooks();
|
|
loadRuns();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|