book.html: upload a manuscript and start a writers' room
Takes a .docx, a text .pdf or a .txt up to 6MB, or pasted text for anything that will not upload cleanly. The upload itself only stores the file and writes a row - extraction runs afterwards on the server, because ptl_api is synchronous with a five second budget and parsing a novel is not a five second job. The page polls until the book says ready, or says why it could not be read. readFileB64 lives here rather than in app.js: app.js's resizeImage goes through an <img> and a canvas, which is right for a phone photo and fatal for a .docx, and this is the only page that uploads a document.
This commit is contained in:
parent
5ea49c386c
commit
d5e6df23d2
1 changed files with 335 additions and 0 deletions
335
book.html
Normal file
335
book.html
Normal file
|
|
@ -0,0 +1,335 @@
|
||||||
|
<!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}
|
||||||
|
.drop.on{border-color:var(--accent)}
|
||||||
|
.tiny{font-size:12px;color:var(--muted)}
|
||||||
|
.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)}
|
||||||
|
</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>
|
||||||
|
<div class="card">
|
||||||
|
<div class="msg" id="msg"></div>
|
||||||
|
|
||||||
|
<h2 class="sec">1 · The manuscript</h2>
|
||||||
|
<div class="field">
|
||||||
|
<label>Upload a file</label>
|
||||||
|
<span class="hint">A <strong>.docx</strong>, a text <strong>.pdf</strong>, or a <strong>.txt</strong>, up to 6MB. A scanned PDF has no text in it and will be refused — upload the original document instead.</span>
|
||||||
|
<div class="drop" id="drop">
|
||||||
|
<p><strong>Drop the manuscript here</strong></p>
|
||||||
|
<p class="tiny">or click to choose a file</p>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="file" accept=".docx,.pdf,.txt,.md,.text" style="display:none">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="paste">Or paste the text</label>
|
||||||
|
<span class="hint">For anything that will not upload cleanly — Google Docs, Scrivener, a PDF that is really a scan.</span>
|
||||||
|
<textarea id="paste" rows="5" placeholder="Paste the whole manuscript here."></textarea>
|
||||||
|
<div style="margin-top:8px"><button type="button" class="btn" id="usepaste">Use this text</button></div>
|
||||||
|
</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="The bell is the spine of the story — it has to be in the first ten seconds and it has to be explained, not teased. Do not resolve the 1971 frame; the book leaves it open and so should the trailer. The dolphin never speaks aloud."></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.</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, or paste the text in instead."));
|
||||||
|
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 = "";
|
||||||
|
var pollTimer = 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…";
|
||||||
|
}
|
||||||
|
return '<div class="bk" data-id="' + esc(b.id) + '">'
|
||||||
|
+ '<div class="meta">'
|
||||||
|
+ '<h3>' + esc(b.title || "Untitled") + '</h3>'
|
||||||
|
+ '<div class="sub">' + sub + (b.created_at ? ' · ' + esc(when(b.created_at)) : '') + '</div>'
|
||||||
|
+ (st === "failed" && b.note ? '<div class="why">' + esc(b.note) + '</div>' : '')
|
||||||
|
+ '</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-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 pick = e.target.getAttribute("data-pick");
|
||||||
|
var del = e.target.getAttribute("data-del");
|
||||||
|
if (pick) { PICKED = pick; hideMsg("msg"); paintBooks(); paintQuote(); return; }
|
||||||
|
if (del) {
|
||||||
|
e.target.disabled = true;
|
||||||
|
var r = await api("delete_book", { book_id: del });
|
||||||
|
if (!r.ok) { showMsg("msg", r.error || "Could not remove that."); e.target.disabled = false; return; }
|
||||||
|
if (PICKED === del) PICKED = null;
|
||||||
|
loadBooks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- upload
|
||||||
|
|
||||||
|
var drop = document.getElementById("drop");
|
||||||
|
var fileInput = document.getElementById("file");
|
||||||
|
drop.addEventListener("click", function () { fileInput.click(); });
|
||||||
|
["dragenter", "dragover"].forEach(function (k) {
|
||||||
|
drop.addEventListener(k, function (e) { e.preventDefault(); drop.classList.add("on"); });
|
||||||
|
});
|
||||||
|
["dragleave", "drop"].forEach(function (k) {
|
||||||
|
drop.addEventListener(k, function (e) { e.preventDefault(); drop.classList.remove("on"); });
|
||||||
|
});
|
||||||
|
drop.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");
|
||||||
|
drop.innerHTML = '<p><strong>Reading ' + esc(file.name) + '…</strong></p>';
|
||||||
|
var doc;
|
||||||
|
try { doc = await readFileB64(file); }
|
||||||
|
catch (err) { resetDrop(); showMsg("msg", err.message); return; }
|
||||||
|
drop.innerHTML = '<p><strong>Uploading ' + esc(file.name) + '…</strong></p>';
|
||||||
|
var r = await api("upload_book", { filename: doc.name, data_b64: doc.b64 });
|
||||||
|
resetDrop();
|
||||||
|
if (!r.ok) { showMsg("msg", r.error || "That upload did not go through."); return; }
|
||||||
|
PICKED = null;
|
||||||
|
loadBooks();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDrop() {
|
||||||
|
drop.innerHTML = '<p><strong>Drop the manuscript here</strong></p>'
|
||||||
|
+ '<p class="tiny">or click to choose a file</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("usepaste").addEventListener("click", async function () {
|
||||||
|
var btn = this;
|
||||||
|
var text = document.getElementById("paste").value;
|
||||||
|
if (text.trim().length < 2000) {
|
||||||
|
showMsg("msg", "That is " + text.trim().length + " characters. Paste the manuscript, not a summary.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hideMsg("msg");
|
||||||
|
btn.disabled = true; btn.textContent = "Saving…";
|
||||||
|
var r = await api("paste_book", { text: text, filename: "pasted.txt" });
|
||||||
|
btn.disabled = false; btn.textContent = "Use this text";
|
||||||
|
if (!r.ok) { showMsg("msg", r.error || "That did not save."); return; }
|
||||||
|
document.getElementById("paste").value = "";
|
||||||
|
PICKED = null;
|
||||||
|
loadBooks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 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.5).toFixed(2) + " to write"
|
||||||
|
+ " · 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();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in a new issue