paytolaugh/create.html

456 lines
21 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>New video — PayToLaugh</title>
<meta name="description" content="Pick your pet, paste a link, set the scene, tell the director what you think.">
<link rel="stylesheet" href="/assets/app.css">
</head>
<body>
<div class="wrap">
<header class="page">
<h1>Make a new video</h1>
<p>Pick the pet, paste whatever you want them to react to, and tell the director what you actually think. The opinion is yours; the delivery is theirs. You can direct every shot afterwards.</p>
</header>
<section>
<div class="card">
<div class="msg" id="msg"></div>
<form id="form">
<h2 class="sec">1 &middot; Who is talking</h2>
<div class="field">
<label for="pet">Pet</label>
<span class="hint">Profiles and photos live on <a href="/pets.html">My pets</a>.</span>
<select id="pet"></select>
</div>
<h2 class="sec" style="margin-top:34px">2 &middot; What they are reacting to</h2>
<div class="field">
<label for="url">Link to an article, post or story</label>
<span class="hint">Anything with a claim in it. News piece, a thread, a video.</span>
<input type="url" id="url" placeholder="https://…">
</div>
<div class="field">
<label for="notes">Your opinion, and notes for the director</label>
<span class="hint">Say what you think and how you want it played. Two sentences is plenty.</span>
<textarea id="notes" placeholder="Astro has hated our robot vacuum since day one and barks at it every time it docks. Play it like he is filing a formal grievance with management."></textarea>
</div>
<h2 class="sec" style="margin-top:34px">3 &middot; What kind of video</h2>
<div class="field">
<label for="format">Format</label>
<span class="hint">This decides the set, the framing and how it is written, before a word exists.</span>
<select id="format"><option value="podcast">Podcast episode</option></select>
</div>
<div class="field">
<label for="model">Filmed with</label>
<span class="hint">Different engines shoot different shot lengths and cost different amounts. Leave it alone if you are not sure.</span>
<select id="model"></select>
<p class="foot" id="modelnote" style="margin:8px 0 0;color:var(--muted)"></p>
</div>
<div class="field">
<label for="scene">Override the setting <span style="color:var(--muted);font-weight:400">(optional)</span></label>
<span class="hint">Leave blank to use the format's own set. Real or not: a field of tall grass, floating on a surfboard.</span>
<input type="text" id="scene" placeholder="">
<p class="foot" id="fmtscene" style="margin:8px 0 0;color:var(--muted)"></p>
</div>
<div class="field">
<label>Do they agree or disagree?</label>
<div class="chips" id="stance">
<span class="chip on" data-v="auto">Let the writer decide</span>
<span class="chip" data-v="agree">Agree</span>
<span class="chip" data-v="disagree">Disagree</span>
<span class="chip" data-v="mixed">A bit of both</span>
</div>
</div>
<h2 class="sec" style="margin-top:34px">4 &middot; How long</h2>
<div class="field">
<div class="chips" id="len">
<span class="chip" data-v="2">2 shots</span>
<span class="chip on" data-v="4">4 shots</span>
<span class="chip" data-v="6">6 shots</span>
</div>
<span class="hint" style="margin-top:10px;display:block" id="lenhint">The piece is written as one continuous monologue, then cut into shots that flow into each other.</span>
</div>
<div class="quote">
<div><div class="big" id="price">$8.85</div><div class="sub" id="plen">32 seconds &middot; 4 shots</div></div>
<div class="sub">You direct and approve before anything is filmed.</div>
</div>
<button class="btn" id="go" type="submit" style="margin-top:26px">Next &middot; add pictures and footage</button>
</form>
</div>
<div class="card" id="colcard" style="display:none;margin-top:22px">
<h2 class="sec">5 &middot; What you want shown on screen <span style="color:var(--muted);font-weight:400;font-size:15px">(optional)</span></h2>
<p class="hint" style="margin-bottom:16px">Anything here is given to the writer <em>before</em> it starts, so the script gets written around what you actually have rather than having pictures fitted to it afterwards. Leave it empty and the cutaways are generated.</p>
<div class="drop" id="drop" tabindex="0">Paste a picture, drop one here, or click to choose</div>
<input type="file" id="picker" accept="image/*" multiple style="display:none">
<div class="field" style="margin-top:18px">
<label for="vurl">Or a link to a video file</label>
<span class="hint">It has to be the video file itself &mdash; a link ending .mp4 or similar. A link to a <em>page</em> containing a video is a web page, and there is nothing there for us to cut.</span>
<div style="display:flex;gap:10px;flex-wrap:wrap">
<input type="text" id="vurl" placeholder="https://example.com/pool-robot.mp4" style="flex:1 1 320px">
<button type="button" class="btn ghost" id="vload">Load it</button>
</div>
</div>
<div id="vedit" style="display:none;margin-top:16px;padding:16px;border:1px solid var(--line);border-radius:12px">
<video id="vprev" controls playsinline preload="metadata" style="width:100%;max-width:420px;border-radius:10px;background:#000;display:block"></video>
<div style="margin-top:14px">
<label class="mini" style="font-size:13px;font-weight:600;display:block;margin-bottom:5px">Start <span id="inlab" style="color:var(--muted);font-weight:400">0.0s</span></label>
<input type="range" id="vin" min="0" max="100" step="0.1" value="0" style="width:100%">
<label class="mini" style="font-size:13px;font-weight:600;display:block;margin:12px 0 5px">End <span id="outlab" style="color:var(--muted);font-weight:400">0.0s</span></label>
<input type="range" id="vout" min="0" max="100" step="0.1" value="5" style="width:100%">
<p class="hint" id="vlen" style="margin:10px 0 0"></p>
</div>
<div class="field" style="margin-top:12px">
<label for="vdesc">What is in it</label>
<input type="text" id="vdesc" placeholder="the pool robot climbing the wall and falling off">
</div>
<button type="button" class="btn ghost" id="vplay">Play just that bit</button>
<button type="button" class="btn" id="vadd" style="margin-left:8px">Use this clip</button>
</div>
<div class="pics" id="pics" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:14px;margin-top:18px"></div>
<div style="margin-top:24px;padding-top:20px;border-top:1px solid var(--line)">
<button type="button" class="btn" id="write">Write the script</button>
<span class="hint" style="margin-left:12px">Nothing is charged and nothing is filmed yet.</span>
</div>
</div>
<p class="note" style="margin-top:22px">
<strong>What happens next.</strong> You add anything you want shown on screen, then the
writer runs knowing what it has to work with. You will be able to read it, change the
setting and camera on every shot, ask for rewrites, and approve it.
Nothing is charged and nothing is filmed yet.
</p>
</section>
<footer>PayToLaugh &middot; your pet has opinions.</footer>
</div>
<script src="/assets/app.js"></script>
<script>
navBar("create");
var email = requireSession();
var stance = "auto", segments = 4, havePets = false, FORMATS = [];
// The render engines the server will accept. Shot length and price both come
// from here rather than being assumed, because they differ per engine: Veo
// shoots 8s, Omni 1.1 shoots 10s.
var MODELS = [], MODEL = null;
function modelOf(id) {
for (var i = 0; i < MODELS.length; i++) if (MODELS[i].id === id) return MODELS[i];
return null;
}
function shotSeconds() { return (MODEL && MODEL.shot_seconds) || 8; }
// Some engines cap total length: extension stops at 40s, so 6 shots is not
// available there. Grey it out rather than let the server silently clamp.
function maxShots() { return (MODEL && MODEL.max_shots) || 0; }
(async function loadFormats() {
var r = await api("camera_presets", {});
if (!r.ok || !r.formats) return;
FORMATS = r.formats;
// Models. An older worker returns none, in which case the control stays
// hidden and the submission omits the field, so nothing breaks.
MODELS = r.models || [];
var msel = document.getElementById("model");
if (MODELS.length) {
msel.innerHTML = MODELS.map(function (m) {
return '<option value="' + esc(m.id) + '">' + esc(m.label) + '</option>';
}).join("");
msel.value = r.default_model || MODELS[0].id;
MODEL = modelOf(msel.value);
msel.addEventListener("change", function () {
MODEL = modelOf(msel.value);
paintModel();
paint();
});
paintModel();
paint();
} else {
msel.closest(".field").style.display = "none";
}
var sel = document.getElementById("format");
sel.innerHTML = FORMATS.map(function (f) {
return '<option value="' + esc(f.id) + '">' + esc(f.label) + '</option>';
}).join("");
function showScene() {
var f = FORMATS.filter(function (x) { return x.id === sel.value; })[0];
document.getElementById("fmtscene").textContent =
f ? "Uses: " + f.scene : "";
}
sel.addEventListener("change", showScene);
showScene();
})();
function chipGroup(id, cb) {
var g = document.getElementById(id);
g.addEventListener("click", function (e) {
var c = e.target.closest(".chip");
if (!c) return;
[].forEach.call(g.querySelectorAll(".chip"), function (x) { x.classList.remove("on"); });
c.classList.add("on");
cb(c.getAttribute("data-v"));
});
}
chipGroup("stance", function (v) { stance = v; });
chipGroup("len", function (v) { segments = parseInt(v, 10); paint(); });
// What the chosen engine costs us per shot, marked up the same way the server
// marks up. Mirrors quote_for(); if one changes, change both.
function paintModel() {
var el = document.getElementById("modelnote");
el.textContent = MODEL ? (MODEL.shot_seconds + "s per shot. " + (MODEL.note || "")) : "";
var chips = document.querySelectorAll("#len .chip");
var cap = maxShots();
for (var i = 0; i < chips.length; i++) {
var nshots = parseInt(chips[i].getAttribute("data-v"), 10);
var over = cap && nshots > cap;
chips[i].textContent = nshots + " shots · " + (nshots * shotSeconds()) + "s"
+ (over ? " — too long for this engine" : "");
chips[i].style.opacity = over ? "0.4" : "";
chips[i].style.pointerEvents = over ? "none" : "";
// If the disabled one was selected, fall back to the largest that fits.
if (over && chips[i].classList.contains("on")) {
chips[i].classList.remove("on");
for (var j = i - 1; j >= 0; j--) {
if (!chips[j].style.pointerEvents) {
chips[j].classList.add("on");
segments = parseInt(chips[j].getAttribute("data-v"), 10);
break;
}
}
}
}
document.getElementById("lenhint").textContent =
"The piece is written as one continuous monologue, then cut into "
+ shotSeconds() + " second shots that flow into each other.";
}
function paint() {
var p = priceFor(segments);
if (MODEL && MODEL.usd_per_second) {
var cost = segments * MODEL.shot_seconds * MODEL.usd_per_second + 0.17;
p = { price: Math.round(cost * 5 * 100) / 100,
seconds: segments * MODEL.shot_seconds };
}
document.getElementById("price").textContent = "$" + p.price.toFixed(2);
document.getElementById("plen").textContent = p.seconds + " seconds · " + segments + " shots";
}
paint();
var petSel = document.getElementById("pet");
(async function loadPets() {
if (!email) return;
var r = await api("list_pets", { email: email });
if (!r.ok) { showMsg("msg", r.error || "Could not load your pets."); return; }
var pets = r.pets || [];
if (!pets.length) {
petSel.innerHTML = '<option value="">No pets yet</option>';
petSel.disabled = true;
document.getElementById("go").disabled = true;
showMsg("msg", "Add a pet first — it only takes a moment on the My pets page.");
return;
}
havePets = true;
petSel.innerHTML = pets.map(function (p) {
return '<option value="' + esc(p.id) + '" data-name="' + esc(p.name) + '">'
+ esc(p.name) + (p.breed ? " · " + esc(p.breed) : "") + '</option>';
}).join("");
})();
// The project id, once step one has created it. Everything in the collateral
// step hangs off this.
var PID = new URLSearchParams(location.search).get("id") || "";
var COL = [], VID = null;
function showCollateral() {
document.getElementById("colcard").style.display = "";
document.getElementById("go").disabled = true;
document.getElementById("go").textContent = "Saved";
document.getElementById("colcard").scrollIntoView({ behavior: "smooth", block: "start" });
loadCol();
}
function colHtml(c) {
var body = c.kind === "video"
? '<video src="' + esc(c.url) + '#t=' + (c.in_seconds || 0) + '" preload="metadata" muted '
+ 'style="width:100%;aspect-ratio:1;object-fit:cover;border-radius:9px;border:1px solid var(--line);display:block;background:#000"></video>'
+ '<div style="font:11px/1.6 var(--mono);color:var(--accent2)">footage &middot; ' + (c.seconds || 0) + 's</div>'
: '<img src="' + esc(c.url) + '" alt="' + esc(c.description || "") + '" '
+ 'style="width:100%;aspect-ratio:1;object-fit:cover;border-radius:9px;border:1px solid var(--line);display:block">';
return '<div>' + body
+ '<div style="font-size:12.5px;color:var(--muted);margin-top:5px">' + esc(c.description || "") + '</div>'
+ '<button type="button" data-rm="' + esc(c.id) + '" style="background:none;border:0;color:var(--muted);'
+ 'cursor:pointer;text-decoration:underline;font:11px/1.8 var(--mono);padding:0">remove</button></div>';
}
async function loadCol() {
if (!PID) return;
var r = await api("list_project_images", { email: email, project_id: PID });
COL = (r.ok && r.images) || [];
var el = document.getElementById("pics");
el.innerHTML = COL.map(colHtml).join("");
[].forEach.call(el.querySelectorAll("[data-rm]"), function (b) {
b.onclick = async function () {
var d = await api("delete_project_image", { email: email, image_id: b.getAttribute("data-rm") });
if (!d.ok) { showMsg("msg", d.error || "Could not remove that."); return; }
loadCol();
};
});
}
async function addPicture(file) {
hideMsg("msg");
if (!file || !/^image\//.test(file.type)) { showMsg("msg", "That is not an image."); return; }
var desc = prompt("What is in this picture? The writer uses this to decide where it belongs.");
if (!desc || desc.trim().length < 3) { showMsg("msg", "It needs a description to be usable."); return; }
var drop = document.getElementById("drop");
drop.textContent = "Uploading…";
var img;
try { img = await resizeImage(file, 1600, 0.85); }
catch (err) { drop.textContent = "Paste a picture, drop one here, or click to choose"; showMsg("msg", err.message || "Could not read that."); return; }
var r = await api("add_project_image", { email: email, project_id: PID,
description: desc.trim(), data_b64: img.b64 });
drop.textContent = "Paste a picture, drop one here, or click to choose";
if (!r.ok) { showMsg("msg", r.error || "That did not upload."); return; }
loadCol();
}
(function wireCollateral() {
var drop = document.getElementById("drop"), picker = document.getElementById("picker");
drop.onclick = function () { picker.click(); };
picker.onchange = function () { [].forEach.call(picker.files || [], addPicture); picker.value = ""; };
drop.addEventListener("dragover", function (e) { e.preventDefault(); drop.classList.add("hot"); });
drop.addEventListener("dragleave", function () { drop.classList.remove("hot"); });
drop.addEventListener("drop", function (e) {
e.preventDefault(); drop.classList.remove("hot");
[].forEach.call((e.dataTransfer && e.dataTransfer.files) || [], addPicture);
});
document.addEventListener("paste", function (e) {
if (!PID) return;
var items = (e.clipboardData && e.clipboardData.items) || [];
for (var i = 0; i < items.length; i++) {
if (items[i].type && items[i].type.indexOf("image") === 0) {
var f = items[i].getAsFile();
if (f) { e.preventDefault(); addPicture(f); }
return;
}
}
});
var v = document.getElementById("vprev"),
vin = document.getElementById("vin"), vout = document.getElementById("vout");
function labels() {
document.getElementById("inlab").textContent = (+vin.value).toFixed(1) + "s";
document.getElementById("outlab").textContent = (+vout.value).toFixed(1) + "s";
var len = (+vout.value) - (+vin.value);
document.getElementById("vlen").textContent = len > 0
? "That is " + len.toFixed(1) + " seconds of footage."
: "The end has to come after the start.";
}
document.getElementById("vload").onclick = function () {
hideMsg("msg");
var u = document.getElementById("vurl").value.trim();
if (!/^https?:\/\//.test(u)) { showMsg("msg", "Paste a link starting http."); return; }
v.src = u;
v.onloadedmetadata = function () {
// Duration is readable cross-origin even when the pixels are not, which
// is all the scrubber needs.
var d = isFinite(v.duration) ? v.duration : 0;
if (!d) { showMsg("msg", "That link did not load as a video file."); return; }
vin.max = vout.max = d.toFixed(1);
vin.value = 0; vout.value = Math.min(5, d).toFixed(1);
labels();
document.getElementById("vedit").style.display = "";
VID = u;
};
v.onerror = function () { showMsg("msg", "That link did not load as a video file. It has to be the file itself, not a page."); };
};
vin.oninput = function () { labels(); v.currentTime = +vin.value; };
vout.oninput = labels;
document.getElementById("vplay").onclick = function () {
v.currentTime = +vin.value;
v.play();
var stop = setInterval(function () {
if (v.currentTime >= +vout.value) { v.pause(); clearInterval(stop); }
}, 80);
};
document.getElementById("vadd").onclick = async function () {
hideMsg("msg");
var d = document.getElementById("vdesc").value.trim();
if (d.length < 3) { showMsg("msg", "Say what is in the clip."); return; }
var r = await api("add_project_video", {
email: email, project_id: PID, source_url: VID, description: d,
in_seconds: +vin.value, out_seconds: +vout.value
});
if (!r.ok) { showMsg("msg", r.error || "Could not use that clip."); return; }
document.getElementById("vedit").style.display = "none";
document.getElementById("vurl").value = "";
document.getElementById("vdesc").value = "";
loadCol();
};
document.getElementById("write").onclick = async function () {
var b = this;
hideMsg("msg");
b.disabled = true; b.textContent = "Writing…";
var r = await api("write_script", { email: email, project_id: PID });
if (!r.ok) { b.disabled = false; b.textContent = "Write the script"; showMsg("msg", r.error || "That did not start."); return; }
location.href = "/script.html?id=" + encodeURIComponent(PID);
};
if (PID) showCollateral();
})();
document.getElementById("form").addEventListener("submit", async function (e) {
e.preventDefault();
hideMsg("msg");
if (!havePets) { showMsg("msg", "Add a pet first on the My pets page."); return; }
var btn = document.getElementById("go");
var url = document.getElementById("url").value.trim();
var notes = document.getElementById("notes").value.trim();
var scene = document.getElementById("scene").value.trim();
var opt = petSel.options[petSel.selectedIndex];
if (!url) { showMsg("msg", "Paste a link to whatever you want them to react to."); return; }
if (notes.length < 10) { showMsg("msg", "Tell the director what you think, in a sentence or two."); return; }
btn.disabled = true; btn.textContent = "Saving…";
// Held, so nothing is written until the collateral is attached.
var r = await api("create_project", {
email: email, pet_id: petSel.value,
pet_name: opt ? opt.getAttribute("data-name") : "",
source_url: url, owner_notes: notes, scene: scene,
format: document.getElementById("format").value,
render_model: MODEL ? MODEL.id : "",
stance: stance, segments: segments, hold: true
});
if (!r.ok) {
btn.disabled = false; btn.textContent = "Next · add pictures and footage";
showMsg("msg", r.error || "That did not go through. Please try again."); return;
}
PID = r.project.id;
history.replaceState(null, "", "/create.html?id=" + encodeURIComponent(PID));
showCollateral();
});
</script>
</body>
</html>