Step one creates the project on hold and reveals the collateral panel; step two writes the script. A held project can be reopened with ?id= so a half-finished setup is not lost.
379 lines
18 KiB
HTML
379 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>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 · 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 · 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 · 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="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 · How long</h2>
|
|
<div class="field">
|
|
<div class="chips" id="len">
|
|
<span class="chip" data-v="2">16 seconds</span>
|
|
<span class="chip on" data-v="4">32 seconds</span>
|
|
<span class="chip" data-v="6">48 seconds</span>
|
|
</div>
|
|
<span class="hint" style="margin-top:10px;display:block">The piece is written as one continuous monologue, then cut into 8 second 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 · 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 · add pictures and footage</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="card" id="colcard" style="display:none;margin-top:22px">
|
|
<h2 class="sec">5 · 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 — 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 · 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 = [];
|
|
|
|
(async function loadFormats() {
|
|
var r = await api("camera_presets", {});
|
|
if (!r.ok || !r.formats) return;
|
|
FORMATS = r.formats;
|
|
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(); });
|
|
|
|
function paint() {
|
|
var p = priceFor(segments);
|
|
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 · ' + (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,
|
|
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>
|